From e3dbd429df92357c9579951241f5f3ebb44e3bd4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 16:44:43 -0700 Subject: [PATCH] migrate audit to new cli framework --- src/commands/audit.zig | 149 +++++++++++++++++++++++++++++++++++------ src/main.zig | 4 -- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 3578ba5..f67c639 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -1,6 +1,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); const analysis = @import("../analytics/analysis.zig"); @@ -2241,30 +2242,86 @@ fn hasAccountDiscrepancies(results: []const AccountComparison) bool { // ── CLI entry point ───────────────────────────────────────── -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, as_of: Date, now_s: i64, color: bool, out: *std.Io.Writer) !void { - var fidelity_csv: ?[]const u8 = null; - var schwab_csv: ?[]const u8 = null; - var schwab_summary = false; - var verbose = false; - var stale_days: u32 = default_stale_days; +pub const ParsedArgs = struct { + fidelity_csv: ?[]const u8 = null, + schwab_csv: ?[]const u8 = null, + schwab_summary: bool = false, + verbose: bool = false, + stale_days: u32 = default_stale_days, +}; +pub const meta = struct { + pub const name: []const u8 = "audit"; + pub const group: framework.Group = .hygiene; + pub const synopsis: []const u8 = "Reconcile portfolio against brokerage exports + portfolio hygiene check"; + pub const help: []const u8 = + \\Usage: zfin audit [opts] + \\ + \\Two modes in one command: + \\ + \\ Flagless: run the portfolio hygiene check — surfaces stale + \\ manual prices, account-cadence violations, and brokerage-file + \\ candidates discovered automatically. + \\ + \\ With brokerage flags: reconcile the portfolio against the + \\ given export and report discrepancies. + \\ + \\Options: + \\ --verbose Show full reconciliation output even when clean + \\ --stale-days Manual price staleness threshold (default 3) + \\ --fidelity Fidelity positions CSV export + \\ ("All accounts" → Positions tab → Download) + \\ --schwab Schwab per-account positions CSV export + \\ --schwab-summary Schwab account summary; copy from accounts + \\ summary page, paste to stdin, then ^D + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(_: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + var parsed: ParsedArgs = .{}; var i: usize = 0; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) { + while (i < cmd_args.len) : (i += 1) { + if (std.mem.eql(u8, cmd_args[i], "--fidelity") and i + 1 < cmd_args.len) { i += 1; - fidelity_csv = args[i]; - } else if (std.mem.eql(u8, args[i], "--schwab") and i + 1 < args.len) { + parsed.fidelity_csv = cmd_args[i]; + } else if (std.mem.eql(u8, cmd_args[i], "--schwab") and i + 1 < cmd_args.len) { i += 1; - schwab_csv = args[i]; - } else if (std.mem.eql(u8, args[i], "--schwab-summary")) { - schwab_summary = true; - } else if (std.mem.eql(u8, args[i], "--verbose")) { - verbose = true; - } else if (std.mem.eql(u8, args[i], "--stale-days") and i + 1 < args.len) { + parsed.schwab_csv = cmd_args[i]; + } else if (std.mem.eql(u8, cmd_args[i], "--schwab-summary")) { + parsed.schwab_summary = true; + } else if (std.mem.eql(u8, cmd_args[i], "--verbose")) { + parsed.verbose = true; + } else if (std.mem.eql(u8, cmd_args[i], "--stale-days") and i + 1 < cmd_args.len) { i += 1; - stale_days = std.fmt.parseInt(u32, args[i], 10) catch default_stale_days; + parsed.stale_days = std.fmt.parseInt(u32, cmd_args[i], 10) catch default_stale_days; } } + return parsed; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const io = ctx.io; + const allocator = ctx.allocator; + const out = ctx.out; + const color = ctx.color; + const as_of = ctx.today; + const now_s = ctx.now_s; + + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const portfolio_path = pf.path; + + const fidelity_csv = parsed.fidelity_csv; + const schwab_csv = parsed.schwab_csv; + const schwab_summary = parsed.schwab_summary; + const verbose = parsed.verbose; + const stale_days = parsed.stale_days; // Flagless mode: run portfolio hygiene check if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { @@ -2386,13 +2443,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, por }; defer allocator.free(csv_data); - const parsed = parseSchwabCsv(allocator, csv_data) catch { + const csv = parseSchwabCsv(allocator, csv_data) catch { try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); return; }; - defer allocator.free(parsed.positions); + defer allocator.free(csv.positions); - const results = try compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of); + const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of); defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); @@ -2405,6 +2462,58 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, por // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: defaults" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.fidelity_csv == null); + try std.testing.expect(parsed.schwab_csv == null); + try std.testing.expect(!parsed.schwab_summary); + try std.testing.expect(!parsed.verbose); + try std.testing.expectEqual(default_stale_days, parsed.stale_days); +} + +test "parseArgs: --fidelity captures CSV path" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?); +} + +test "parseArgs: --schwab captures CSV path" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?); +} + +test "parseArgs: --schwab-summary boolean" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--schwab-summary"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.schwab_summary); +} + +test "parseArgs: --verbose boolean" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--verbose"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expect(parsed.verbose); +} + +test "parseArgs: --stale-days parses integer" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--stale-days", "5" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqual(@as(u32, 5), parsed.stale_days); +} + test "parseDollarAmount" { try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01); diff --git a/src/main.zig b/src/main.zig index ff75155..20aba60 100644 --- a/src/main.zig +++ b/src/main.zig @@ -480,10 +480,6 @@ fn runCli(init: std.process.Init) !u8 { if (std.mem.eql(u8, command, "portfolio")) { // Parse --refresh flag; reject any other token (including old // positional FILE, which is now a global -p). - } else if (std.mem.eql(u8, command, "audit")) { - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - try commands.audit.run(io, allocator, &svc, pf.path, cmd_args, today, now_s, color, out); } else if (std.mem.eql(u8, command, "projections")) { var events_enabled = true; var as_of: ?zfin.Date = null;