diff --git a/src/commands/compare.zig b/src/commands/compare.zig index cb79afe..7373ea5 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -48,6 +48,8 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); +const TimeRange = @import("TimeRange.zig"); const git = @import("../git.zig"); const fmt = cli.fmt; const Date = zfin.Date; @@ -68,112 +70,127 @@ pub const Error = error{ PortfolioLoadFailed, }; -/// Command entry point. -pub fn run( - io: std.Io, - allocator: std.mem.Allocator, - svc: *zfin.DataService, - portfolio_path: []const u8, - cmd_args: []const []const u8, - as_of: Date, - color: bool, - out: *std.Io.Writer, -) !void { - // ── Parse args ─────────────────────────────────────────── - // - // Compare has four orthogonal axes for specifying the before/ - // after endpoints, each with a convenient default and an explicit - // override: - // - // Snapshot (for liquid totals + per-symbol table): - // --snapshot-before — default from positional arg 1 - // --snapshot-after — default: live (or arg 2 if given) - // - // Commit (for the attribution / contributions diff): - // --commit-before — default from positional arg 1 - // --commit-after — default: HEAD clean / WORKING dirty - // - // Positional date(s) still drive the happy path (one or two - // dates → all four axes). The overrides let the user pull a - // single axis independently — e.g. `compare 1W --commit-before HEAD` - // when they committed mid-week and the 1W snapshot is still correct - // but the 1W commit-at-or-before resolves to the wrong reconciliation. - // - // Other flags: --projections (embeds the projected-return delta - // block), --no-events (with --projections, excludes life events). - // - // We do NOT accept `-p` as a shortcut because `-p` is already the - // global `--portfolio` flag; shadowing it would confuse users - // reaching for the short form. - var with_projections = false; - var events_enabled = true; - var snapshot_before_override: ?Date = null; - var snapshot_after_override: ?Date = null; - var snapshot_after_live = false; // true if user explicitly set --snapshot-after live - var commit_before_override: ?git.CommitSpec = null; - var commit_after_override: ?git.CommitSpec = null; +/// Parsed compare args. Carries already-resolved values so `run` is +/// pure orchestration. +pub const ParsedArgs = struct { + with_projections: bool = false, + events_enabled: bool = true, + /// Resolved before-side snapshot date. Null if neither + /// --snapshot-before, a positional date, nor --commit-before + /// (with a date spec) was supplied — `run` errors on null. + snapshot_before: ?Date = null, + /// Resolved after-side snapshot date. Null + !after_is_live + /// means "compare against today's live portfolio." + snapshot_after: ?Date = null, + after_is_live: bool = true, + commit_before: ?git.CommitSpec = null, + commit_after: ?git.CommitSpec = null, +}; + +pub const meta = struct { + pub const name: []const u8 = "compare"; + pub const group: framework.Group = .timeseries; + pub const synopsis: []const u8 = "Compare portfolio at two points in time (or vs. today)"; + pub const help: []const u8 = + \\Usage: + \\ zfin compare compare DATE vs. live + \\ zfin compare compare two historical dates + \\ zfin compare [--snapshot-before DATE] [--snapshot-after DATE] + \\ [--commit-before SPEC] [--commit-after SPEC] + \\ [--projections [--no-events]] + \\ + \\Compare two points in time for the portfolio: liquid totals, + \\per-symbol price moves, attribution / contributions diff. The + \\positional happy-path is what most users want; the named + \\overrides exist for the cases where the snapshot date and the + \\git-commit-at-that-date diverge (e.g. you committed two days + \\after a Sunday review). + \\ + \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). + \\Commit-spec forms: YYYY-MM-DD, relative, HEAD, HEAD~N, SHA, working. + \\ + \\Options: + \\ --projections Include projected-return + SWR@99% + \\ deltas. Adds ~1-2s per endpoint + \\ (Monte Carlo SWR search). + \\ --no-events (with --projections) exclude life + \\ events from the projection. + \\ --snapshot-before Override the before-side snapshot. + \\ --snapshot-after Override the after-side snapshot; + \\ accepts `live` for the current + \\ portfolio. + \\ --commit-before Pin the before-side commit for the + \\ attribution / contributions block. + \\ --commit-after Pin the after-side commit; accepts + \\ `working` for the working copy. + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + const io = ctx.io; + const today = ctx.today; + const allocator = ctx.allocator; + + // ── Pass 1: TimeRange.parse for endpoint flags ─────────── + const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{ + .accept_snapshot_before = true, + .accept_snapshot_after = true, + .accept_commit_before = true, + .accept_commit_after = true, + }) catch |err| switch (err) { + error.MissingValue, + error.InvalidValue, + error.WorkingCopyOnBeforeSide, + error.LiveNotAllowed, + => return error.InvalidDate, + error.DuplicateEndpoint, error.RepeatedFlag => return error.UnexpectedArg, + error.OutOfMemory => return error.OutOfMemory, + }; + defer allocator.free(tr_result.consumed); + // Build a "consumed" lookup so the second pass can skip the + // tokens TimeRange already absorbed. + var consumed_set = std.AutoHashMap(usize, void).init(allocator); + defer consumed_set.deinit(); + for (tr_result.consumed) |idx| try consumed_set.put(idx, {}); + + var parsed: ParsedArgs = .{}; + + // Translate TimeRange endpoints → ParsedArgs. We split the + // typed Endpoint back out because the rest of compare.zig wants + // separate `snapshot_*` and `commit_*` knobs (a single endpoint + // axis isn't expressive enough — compare can carry an + // independent commit-spec and snapshot-date on the same side). + if (tr_result.range.before) |ep| switch (ep) { + .date => |d| parsed.snapshot_before = d, + .commit_spec => |s| parsed.commit_before = s, + .live => unreachable, // --snapshot-before rejects live; --commit-before is commit_spec + }; + if (tr_result.range.after) |ep| switch (ep) { + .date => |d| { + parsed.snapshot_after = d; + parsed.after_is_live = false; + }, + .commit_spec => |s| parsed.commit_after = s, + .live => parsed.after_is_live = true, + }; + + // ── Pass 2: --projections / --no-events / positionals ──── var positional: std.ArrayList([]const u8) = .empty; defer positional.deinit(allocator); var arg_i: usize = 0; while (arg_i < cmd_args.len) : (arg_i += 1) { + if (consumed_set.contains(arg_i)) continue; const a = cmd_args[arg_i]; if (std.mem.eql(u8, a, "--projections")) { - with_projections = true; + parsed.with_projections = true; } else if (std.mem.eql(u8, a, "--no-events")) { - events_enabled = false; - } else if (std.mem.eql(u8, a, "--snapshot-before") or std.mem.eql(u8, a, "--snapshot-after")) { - if (arg_i + 1 >= cmd_args.len) { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " requires a date (YYYY-MM-DD, relative like 1W, or 'live' for --snapshot-after).\n"); - return error.UnexpectedArg; - } - const value = cmd_args[arg_i + 1]; - const is_after = std.mem.eql(u8, a, "--snapshot-after"); - // --snapshot-after supports 'live' (= current portfolio, - // not a snapshot file). --snapshot-before does not. - const parsed = cli.parseAsOfDate(value, as_of) catch |err| { - var ebuf: [256]u8 = undefined; - const msg = cli.fmtAsOfParseError(&ebuf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); - return error.InvalidDate; - }; - if (parsed) |d| { - if (is_after) snapshot_after_override = d else snapshot_before_override = d; - } else if (is_after) { - snapshot_after_live = true; - } else { - try cli.stderrPrint(io, "Error: --snapshot-before cannot be 'live' — the before side must be an actual snapshot.\n"); - return error.InvalidDate; - } - arg_i += 1; - } else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) { - if (arg_i + 1 >= cmd_args.len) { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " requires a value (working, YYYY-MM-DD, 1W/1M/1Q/1Y, HEAD, HEAD~N, or SHA).\n"); - return error.UnexpectedArg; - } - const value = cmd_args[arg_i + 1]; - const spec = cli.parseCommitSpec(value, as_of) catch |err| { - var ebuf: [256]u8 = undefined; - const msg = cli.fmtCommitSpecError(&ebuf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); - return error.InvalidDate; - }; - if (std.mem.eql(u8, a, "--commit-before")) { - if (spec == .working_copy) { - try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); - return error.InvalidDate; - } - commit_before_override = spec; - } else { - commit_after_override = spec; - } - arg_i += 1; + parsed.events_enabled = false; } else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) { try cli.stderrPrint(io, "Error: unknown flag for 'compare': "); try cli.stderrPrint(io, a); @@ -190,10 +207,51 @@ pub fn run( } const args = positional.items; - // Require at least one source of the "then" date — either a - // positional arg or a --snapshot-before / --commit-before - // override that gives us an anchor. - const have_then_anchor = args.len >= 1 or snapshot_before_override != null or commit_before_override != null; + // ── Resolve positional dates into the snapshot axes ────── + if (args.len > 2) { + try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n"); + return error.UnexpectedArg; + } + + const date1: ?Date = if (args.len >= 1) + (cli.parseRequiredDateOrStderr(io, args[0], today, "date1") catch |err| switch (err) { + error.InvalidDate => return error.InvalidDate, + }) + else + null; + const date2: ?Date = if (args.len == 2) + (cli.parseRequiredDateOrStderr(io, args[1], today, "date2") catch |err| switch (err) { + error.InvalidDate => return error.InvalidDate, + }) + else + null; + + // Positional fallbacks: positional[0] feeds the before-snapshot + // axis if --snapshot-before wasn't given; positional[1] feeds + // the after-snapshot axis if --snapshot-after wasn't given. + if (parsed.snapshot_before == null) parsed.snapshot_before = date1; + if (parsed.snapshot_after == null and date2 != null) { + parsed.snapshot_after = date2; + parsed.after_is_live = false; + } + + // Swap if user provided positional dates in reverse order and no + // explicit override. Overrides are respected as given (user + // asked for it explicitly). + if (!parsed.after_is_live and parsed.snapshot_after != null and + date1 != null and date2 != null and date1.?.days > date2.?.days and + // Only swap when there were no explicit overrides (the + // positional-fallback branches set snapshot_before/after + // from date1/date2 themselves). + cmd_args_has_no_snap_overrides(cmd_args)) + { + const tmp = parsed.snapshot_before.?; + parsed.snapshot_before = parsed.snapshot_after; + parsed.snapshot_after = tmp; + } + + // Require at least one source of the "then" date. + const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null; if (!have_then_anchor) { try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n"); try cli.stderrPrint(io, "Usage:\n"); @@ -204,56 +262,61 @@ pub fn run( try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n"); return error.MissingDateArg; } - if (args.len > 2) { - try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n"); - return error.UnexpectedArg; - } - // Parse positional dates (if any). These feed the snapshot axes - // by default; explicit overrides win. - const date1: ?Date = if (args.len >= 1) - (cli.parseRequiredDateOrStderr(io, args[0], as_of, "date1") catch |err| switch (err) { - error.InvalidDate => return error.InvalidDate, - }) - else - null; - const date2: ?Date = if (args.len == 2) - (cli.parseRequiredDateOrStderr(io, args[1], as_of, "date2") catch |err| switch (err) { - error.InvalidDate => return error.InvalidDate, - }) - else - null; + return parsed; +} - // Resolve the snapshot "then" / "now" dates. - // then_date_requested: user-facing before date (for reporting + - // attribution fallback). Derived from - // --snapshot-before override first, else - // positional[0]. - // now_date_requested: user-facing after date. From - // --snapshot-after override if non-live, - // else positional[1] if given, else today. - // now_is_live: true when no --snapshot-after-date and - // no positional[1] — equivalent to the - // old single-date mode. - const then_requested_opt: ?Date = snapshot_before_override orelse date1; - // If neither a snapshot override nor a positional date was given, - // we still need a then-date for the snapshot side. Fall back to - // the commit-before's date if it's a date spec. - const then_requested: Date = if (then_requested_opt) |d| d else fallback: { - if (commit_before_override) |cb| { - switch (cb) { - .date_at_or_before => |d| break :fallback d, - else => { - try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n"); - return error.MissingDateArg; - }, - } +/// Tiny helper for the swap-on-reverse-order check: confirms the +/// user did NOT pass --snapshot-before or --snapshot-after +/// explicitly. When either is set explicitly, we never swap. +fn cmd_args_has_no_snap_overrides(cmd_args: []const []const u8) bool { + for (cmd_args) |a| { + if (std.mem.eql(u8, a, "--snapshot-before") or + std.mem.eql(u8, a, "--snapshot-after")) + { + return false; } - unreachable; // have_then_anchor guarantees at least one source - }; + } + return true; +} - const now_is_live = !snapshot_after_live and snapshot_after_override == null and date2 == null; - const now_requested: Date = if (snapshot_after_override) |d| d else if (date2) |d| d else as_of; +/// Command entry point. +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 pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const portfolio_path = pf.path; + + const with_projections = parsed.with_projections; + const events_enabled = parsed.events_enabled; + const snapshot_after_live = parsed.after_is_live and parsed.snapshot_after == null; + const commit_before_override: ?git.CommitSpec = parsed.commit_before; + const commit_after_override: ?git.CommitSpec = parsed.commit_after; + + // Resolve the snapshot "then" / "now" dates from the parsed + // ParsedArgs. The before-side is guaranteed non-null by parseArgs + // (returned MissingDateArg otherwise). The after-side may be null + // when after_is_live; we substitute today's date so the rest of + // the pipeline can stay date-driven. + const then_requested: Date = parsed.snapshot_before orelse blk: { + // Fallback: --commit-before with a date_at_or_before spec. + if (commit_before_override) |cb| switch (cb) { + .date_at_or_before => |d| break :blk d, + else => { + try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n"); + return error.MissingDateArg; + }, + }; + unreachable; // parseArgs guarantees an anchor + }; + const now_is_live = parsed.after_is_live and parsed.snapshot_after == null; + const now_requested: Date = parsed.snapshot_after orelse as_of; // Validate snapshot date ordering. if (now_is_live) { @@ -272,17 +335,10 @@ pub fn run( } } - // Swap if user provided positional dates in reverse order and no - // explicit override. Overrides are respected as given (user - // asked for it explicitly). + // parseArgs already handled the date1/date2 swap-on-reverse case, + // so then_date / now_date here just reflect what's in `parsed`. var then_date: Date = then_requested; var now_date: Date = now_requested; - if (!now_is_live and snapshot_before_override == null and snapshot_after_override == null and - date1 != null and date2 != null and date1.?.days > date2.?.days) - { - then_date = date2.?; - now_date = date1.?; - } // ── Resolve history dir ────────────────────────────────── const hist_dir = try history.deriveHistoryDir(allocator, portfolio_path); @@ -763,6 +819,109 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; + +fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { + var ctx: framework.RunCtx = .{ + .io = std.testing.io, + .allocator = std.testing.allocator, + .gpa = std.testing.allocator, + .environ_map = undefined, + .config = .{ .cache_dir = "" }, + .svc = null, + .globals = .{}, + .today = today, + .now_s = 0, + .color = false, + .out = undefined, + }; + return parseArgs(&ctx, args); +} + +test "parseArgs: single positional date populates snapshot_before" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"2026-04-01"}; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1))); + try testing.expect(parsed.snapshot_after == null); + try testing.expect(parsed.after_is_live); +} + +test "parseArgs: two positional dates populate both axes" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "2026-05-01" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1))); + try testing.expect(parsed.snapshot_after.?.eql(Date.fromYmd(2026, 5, 1))); + try testing.expect(!parsed.after_is_live); +} + +test "parseArgs: reverse-order positional dates auto-swap" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-05-01", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1))); + try testing.expect(parsed.snapshot_after.?.eql(Date.fromYmd(2026, 5, 1))); +} + +test "parseArgs: --projections sets the flag" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "--projections" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.with_projections); + try testing.expect(parsed.events_enabled); // default +} + +test "parseArgs: --projections --no-events" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "--projections", "--no-events" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.with_projections); + try testing.expect(!parsed.events_enabled); +} + +test "parseArgs: --snapshot-before override wins over positional" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--snapshot-before", "2026-03-01", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 3, 1))); +} + +test "parseArgs: --commit-before captures git ref" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "--commit-before", "HEAD~1" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.commit_before != null); + switch (parsed.commit_before.?) { + .git_ref => |r| try testing.expectEqualStrings("HEAD~1", r), + else => try testing.expect(false), + } +} + +test "parseArgs: --snapshot-after live keeps after_is_live" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "--snapshot-after", "live" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.snapshot_after == null); + try testing.expect(parsed.after_is_live); +} + +test "parseArgs: missing anchor errors" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{}; + try testing.expectError(error.MissingDateArg, parseArgsForTest(today, &args)); +} + +test "parseArgs: unknown flag errors" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "--bogus" }; + try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args)); +} + +test "parseArgs: too many positional dates errors" { + const today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "2026-04-01", "2026-05-01", "2026-06-01" }; + try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args)); +} const snapshot_model = @import("../models/snapshot.zig"); // Aggregation and liquid-from-snapshot tests moved to src/compare.zig. @@ -1167,6 +1326,38 @@ fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.me return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" }); } +/// Test helper: build a `RunCtx` with the bare-minimum fields the +/// compare command needs, then parse args and run. Wraps the +/// pre-framework `run(...)` signature so the existing tests below +/// keep working with minimal churn. `today` is injected so tests +/// stay deterministic. +fn runArgs( + io: std.Io, + allocator: std.mem.Allocator, + svc: *zfin.DataService, + pf_path: []const u8, + cmd_args: []const []const u8, + today: Date, + color: bool, + out: *std.Io.Writer, +) !void { + var ctx: framework.RunCtx = .{ + .io = io, + .allocator = allocator, + .gpa = allocator, + .environ_map = undefined, + .config = .{ .cache_dir = "" }, + .svc = svc, + .globals = .{ .portfolio_path = pf_path }, + .today = today, + .now_s = 0, + .color = color, + .out = out, + }; + const parsed = try parseArgs(&ctx, cmd_args); + try run(&ctx, parsed); +} + test "run: zero args returns MissingDateArg" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -1179,7 +1370,7 @@ test "run: zero args returns MissingDateArg" { var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - const result = run(io, testing.allocator, &svc, pf, &.{}, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &.{}, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.MissingDateArg, result); } @@ -1196,7 +1387,7 @@ test "run: three args returns UnexpectedArg" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-02-15", "2024-03-15" }; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.UnexpectedArg, result); } @@ -1213,7 +1404,7 @@ test "run: bad date1 returns InvalidDate" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"not-a-date"}; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } @@ -1230,7 +1421,7 @@ test "run: valid date1 + bad date2 returns InvalidDate" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024/03/15" }; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } @@ -1247,7 +1438,7 @@ test "run: same date twice returns SameDate" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-01-15" }; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SameDate, result); } @@ -1268,7 +1459,7 @@ test "run: one date equal to today returns SameDate" { const today_str = std.fmt.bufPrint(&today_buf, "{f}", .{today_date}) catch unreachable; const args = [_][]const u8{today_str}; - const result = run(io, testing.allocator, &svc, pf, &args, today_date, false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, today_date, false, &stream); try testing.expectError(error.SameDate, result); } @@ -1285,7 +1476,7 @@ test "run: single-date past-date with empty history returns SnapshotNotFound" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2020-01-01"}; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } @@ -1302,7 +1493,7 @@ test "run: single-date future-date rejected as InvalidDate" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2099-01-01"}; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } @@ -1323,7 +1514,7 @@ test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty hist var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"1W"}; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } @@ -1340,7 +1531,7 @@ test "run: 'live' string rejected as InvalidDate (not a real prior date)" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"live"}; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } @@ -1359,7 +1550,7 @@ test "run: two-date with empty history returns SnapshotNotFound (auto-swap path) // Intentionally reversed — verifies the swap happens without // error (both dates will fail to load with SnapshotNotFound). const args = [_][]const u8{ "2024-03-15", "2024-01-15" }; - const result = run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } @@ -1417,7 +1608,7 @@ test "run: two-date happy path via fixtures" { var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-03-15" }; - try run(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); + try runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); diff --git a/src/main.zig b/src/main.zig index 20aba60..bcf4781 100644 --- a/src/main.zig +++ b/src/main.zig @@ -29,6 +29,7 @@ const command_modules = .{ // Time-series & journaling .snapshot = @import("commands/snapshot.zig"), + .compare = @import("commands/compare.zig"), // Data hygiene .enrich = @import("commands/enrich.zig"), @@ -680,21 +681,6 @@ fn runCli(init: std.process.Init) !u8 { const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.contributions.run(io, allocator, &svc, pf.path, before_final, after_final, today, color, out); - } else if (std.mem.eql(u8, command, "compare")) { - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - commands.compare.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) { - // All user-level validation errors return 1 silently — the - // command already printed a message to stderr. - error.UnexpectedArg, - error.MissingDateArg, - error.InvalidDate, - error.SameDate, - error.SnapshotNotFound, - error.PortfolioLoadFailed, - => return 1, - else => return err, - }; } else { try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1;