//! `zfin compare []` //! //! Compare two points in time for the portfolio. //! //! Single-date mode: `zfin compare 2024-01-15` — compares the named //! snapshot against the current live portfolio. //! //! Two-date mode: `zfin compare 2024-01-15 2024-03-15` — compares two //! historical snapshots. Order of arguments doesn't matter; the command //! always displays older → newer. //! //! ## Output //! //! Shape only (values illustrative): //! //! ``` //! Portfolio comparison: (N days) //! //! Liquid: <+/-delta> <+/-pct%> //! //! Per-symbol price change (K held throughout) //! SYM1 <+/-pct%> <+/-dollar> //! SYM2 <+/-pct%> <+/-dollar> //! ... //! //! (A added, R removed since — hidden) //! ``` //! //! ## Missing snapshot //! //! If the exact date isn't in `history/`, we print the nearest earlier //! and later available dates to stderr and exit non-zero — we don't //! silently snap, because the user should pick which direction. //! //! ## Structure //! //! Most of the work happens elsewhere: //! - `src/history.zig` — single-snapshot IO (loadSnapshotAt, //! findNearestSnapshot) //! - `src/compare.zig` — Side-loading + aggregation //! - `src/views/compare.zig` — pure view model //! //! This file owns the CLI-specific pieces: arg parsing, the //! live-portfolio pipeline (fetch prices + build summary), the //! stderr-to-user "nearest snapshot" suggestions, and the ANSI //! renderer. 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; const Money = @import("../Money.zig"); const history = @import("../history.zig"); const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); const view_hist = @import("../views/history.zig"); const contributions = @import("contributions.zig"); const projections = @import("projections.zig"); pub const Error = error{ UnexpectedArg, MissingDateArg, InvalidDate, SameDate, SnapshotNotFound, PortfolioLoadFailed, }; /// 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: framework.Meta = .{ .name = "compare", .group = .timeseries, .synopsis = "Compare portfolio at two points in time (or vs. today)", .help = \\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. \\ , .uppercase_first_arg = false, }; 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")) { parsed.with_projections = true; } else if (std.mem.eql(u8, a, "--no-events")) { 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); try cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n"); if (std.mem.eql(u8, a, "-p")) { try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n"); try cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n"); try cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n"); } return error.UnexpectedArg; } else { try positional.append(allocator, a); } } const args = positional.items; // ── 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"); try cli.stderrPrint(io, " zfin compare (compare date vs current)\n"); try cli.stderrPrint(io, " zfin compare (compare two dates)\n"); try cli.stderrPrint(io, " zfin compare --snapshot-before [--commit-before ] (explicit axes)\n"); try cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n"); try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n"); return error.MissingDateArg; } return parsed; } /// 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; } } return true; } /// 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) { if (then_requested.days == as_of.days) { try cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n"); return error.SameDate; } if (then_requested.days > as_of.days) { try cli.stderrPrint(io, "Error: cannot compare against a future date.\n"); return error.InvalidDate; } } else if (!snapshot_after_live) { if (then_requested.days == now_requested.days) { try cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n"); return error.SameDate; } } // 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; // ── Resolve history dir ────────────────────────────────── const hist_dir = try history.deriveHistoryDir(allocator, portfolio_path); defer allocator.free(hist_dir); // ── Snap dates to nearest-earlier snapshots ────────────── // // The requested date may not correspond to an actual snapshot // file. Snap to the most recent snapshot at-or-before. Record // the original requested date so we can pass it to the // attribution git-window independently (liquid uses the snap'd // date; attribution uses the requested date unless --commit-* // overrides). var arena_state = std.heap.ArenaAllocator.init(allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const then_date_requested = then_date; const now_date_requested = now_date; const then_resolved = cli.resolveSnapshotOrExplain(io, arena, hist_dir, then_date) catch return error.SnapshotNotFound; if (!then_resolved.exact) { var stderr_buf: [256]u8 = undefined; var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); try printSnapNote(&stderr_writer.interface, color, then_resolved.requested, then_resolved.actual, "then"); try stderr_writer.interface.flush(); } then_date = then_resolved.actual; if (!now_is_live) { const now_resolved = cli.resolveSnapshotOrExplain(io, arena, hist_dir, now_date) catch return error.SnapshotNotFound; if (!now_resolved.exact) { var stderr_buf: [256]u8 = undefined; var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); try printSnapNote(&stderr_writer.interface, color, now_resolved.requested, now_resolved.actual, "now"); try stderr_writer.interface.flush(); } now_date = now_resolved.actual; } // ── Load both sides ────────────────────────────────────── // // "Then" is always a snapshot. "Now" is either another snapshot // (two-date mode) or the live portfolio (single-date mode). Once // loaded, both sides are shaped identically — a HoldingMap + liquid // total — and feed a single comparison path below. // // After the snap above, the dates are guaranteed to correspond to // actual snapshot files — FileNotFound here would be a disk race // (file deleted between the snap check and the load), not a // missing-snapshot UX problem. var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date); defer then_side.deinit(allocator); // Projections: only computed when --projections/-p flag is set. // Uses the SNAPPED dates (not requested) because projections are // snapshot-based — they need actual files on disk to load. // When `now_is_live`, pass null for the "now" side so projections // uses the live portfolio. When two-date mode, pass the snapped // now_date. var projections_result: ?projections.KeyComparisonResult = null; defer if (projections_result) |r| r.cleanup(); var projections_block: ?ProjectionsBlock = null; if (with_projections) { const proj_now_date: Date = if (now_is_live) as_of else now_date; projections_result = projections.computeKeyComparison( io, allocator, arena, svc, portfolio_path, .{ .events_enabled = events_enabled, .vs_date = then_date, .now_date = proj_now_date, .now_from_snapshot = !now_is_live, .refresh = ctx.globals.refresh_policy, }, ) catch |err| blk: { // Projections computation failed — fall back to compare // output without the block. User still gets the core // Liquid/attribution/per-symbol view. var ebuf: [160]u8 = undefined; const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n"; cli.stderrPrint(io, msg) catch {}; break :blk null; }; if (projections_result) |r| { projections_block = .{ .then = r.then, .now = r.now, .events_enabled = r.events_enabled }; } } // Build the CommitSpecs for attribution. Explicit --commit-* // overrides win; otherwise fall back to the requested snapshot // date. now_is_live implies `null` for after (= working copy // default handling downstream). const attr_before: git.CommitSpec = commit_before_override orelse .{ .date_at_or_before = then_date_requested }; const attr_after_opt: ?git.CommitSpec = if (commit_after_override) |s| s else if (now_is_live) null else .{ .date_at_or_before = now_date_requested }; if (now_is_live) { var now_live = try LiveSide.load(io, allocator, svc, portfolio_path, as_of, color, ctx.globals.refresh_policy); defer now_live.deinit(allocator); // Attribution uses the resolved CommitSpecs so --commit-* // overrides + date fallbacks share one classifier. The caller // adapts dates to `CommitSpec.date_at_or_before` upstream. const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); try renderFromParts(out, color, allocator, .{ .then_date = then_date, .now_date = now_date, .now_is_live = true, .then_liquid = then_side.liquid, .now_liquid = now_live.liquid, .then_map = &then_side.map, .now_map = &now_live.map, .attribution = attribution, .projections = projections_block, }); } else { var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date); defer now_side.deinit(allocator); const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy); try renderFromParts(out, color, allocator, .{ .then_date = then_date, .now_date = now_date, .now_is_live = false, .then_liquid = then_side.liquid, .now_liquid = now_side.liquid, .then_map = &then_side.map, .now_map = &now_side.map, .attribution = attribution, .projections = projections_block, }); } } /// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s) /// earlier)" note explaining that a requested as-of date was snapped /// backward to the nearest available snapshot. Pure formatter — caller /// supplies the writer (typically stderr) and decides about flushing. fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void { const days = requested.days - actual.days; var msg_buf: [160]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, "(requested {f} for {s}; nearest snapshot: {f}, {d} day{s} earlier)\n", .{ requested, label, actual, days, if (days == 1) "" else "s" }, ) catch "(snapped to nearest snapshot)\n"; if (color) try fmt.ansiSetFg(out, cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2]); try out.writeAll(msg); if (color) try fmt.ansiReset(out); } /// Inputs needed to build + render a `CompareView`. Bundled into a /// struct so `renderFromParts` stays one line of call-site noise /// instead of an 11-positional-arg parade. /// /// `then_map` / `now_map` are borrowed pointers; the caller keeps the /// underlying maps alive through the render call. `attribution` is /// optional and folded into the view only when set. `projections` is /// optional — when set, a compact projected-return + safe-withdrawal /// delta block renders between the attribution and the per-symbol /// table. Kept outside `CompareView` because CompareView is /// renderer-agnostic and the projection data carries CLI-specific /// computation (Monte Carlo SWR, trailing returns). const RenderArgs = struct { then_date: Date, now_date: Date, now_is_live: bool, then_liquid: f64, now_liquid: f64, then_map: *const view.HoldingMap, now_map: *const view.HoldingMap, attribution: ?contributions.AttributionSummary, projections: ?ProjectionsBlock = null, }; /// Pre-computed projections block for embedding in the compare /// output. Caller computes (runs the projections pipeline for both /// endpoints) and passes in; the renderer just prints. Gates the /// perf cost of projection computation on the `--projections` flag. const ProjectionsBlock = struct { then: projections.KeyMetrics, now: projections.KeyMetrics, events_enabled: bool, }; /// Build the view from two holdings maps + totals, then render. /// Factored out so both the live and snapshot "now" paths share a /// single call site. /// /// `args.attribution` is optional — when the contributions pipeline /// resolves cleanly against the portfolio's git history, the /// contributions-vs-gains split is surfaced in the rendered output. /// Null when git is unavailable or the window doesn't map to commits. fn renderFromParts( out: *std.Io.Writer, color: bool, allocator: std.mem.Allocator, args: RenderArgs, ) !void { var cv = try view.buildCompareView( allocator, args.then_date, args.now_date, args.now_is_live, args.then_liquid, args.now_liquid, args.then_map, args.now_map, ); defer cv.deinit(allocator); // Wire the attribution into the view so the renderer can surface // it. `total()` is the caller's numeric — gains are derived from // the liquid delta. if (args.attribution) |a| { cv.attribution = .{ .contributions = a.total(), .gains = cv.liquid.delta - a.total(), }; } try renderCompare(out, color, cv, args.projections); } // ── Live-portfolio side (CLI-only) ─────────────────────────── /// Owning bundle for the live-portfolio endpoint used by CLI /// single-date mode. Fetches prices, builds a PortfolioSummary, and /// aggregates the live stock lots into a HoldingMap. /// /// Not used by the TUI — the TUI uses its already-loaded portfolio /// state directly and calls `compare_core.aggregateLiveStocks` inline. const LiveSide = struct { loaded: cli.LoadedPortfolio, pf_data: cli.PortfolioData, prices: std.StringHashMap(f64), map: view.HoldingMap, liquid: f64, fn load( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, as_of: Date, color: bool, refresh: framework.RefreshPolicy, ) !LiveSide { var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed; errdefer loaded_pf.deinit(allocator); if (loaded_pf.portfolio.lots.len == 0) { try cli.stderrPrint(io, "Portfolio is empty.\n"); return error.PortfolioLoadFailed; } var prices = std.StringHashMap(f64).init(allocator); errdefer prices.deinit(); if (loaded_pf.syms.len > 0) { var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color); defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } var pf_data = cli.buildPortfolioData( allocator, loaded_pf.portfolio, loaded_pf.positions, loaded_pf.syms, &prices, svc, as_of, ) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return error.PortfolioLoadFailed; }, else => return err, }; errdefer pf_data.deinit(allocator); var map: view.HoldingMap = .init(allocator); errdefer map.deinit(); try compare_core.aggregateLiveStocks(as_of, &loaded_pf.portfolio, &prices, &map); return .{ .loaded = loaded_pf, .pf_data = pf_data, .prices = prices, .map = map, .liquid = pf_data.summary.total_value, }; } fn deinit(self: *LiveSide, allocator: std.mem.Allocator) void { self.map.deinit(); self.prices.deinit(); self.pf_data.deinit(allocator); self.loaded.deinit(allocator); } }; // ── ANSI rendering (CLI-only) ──────────────────────────────── // // Thin adapter: pulls pre-formatted cells from `views/compare.zig` // and drops them into an ANSI-colored layout. Column widths, money // formatting, and label pluralization all come from the view layer — // this function owns only the styling mechanism (ANSI escapes) and // the renderer-specific layout choices (leading indent, newline // placement, two-color totals line). fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void { var then_buf: [10]u8 = undefined; var now_buf: [24]u8 = undefined; const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_str = view.nowLabel(cv, &now_buf); // Header try cli.setBold(out, color); try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} → {s} ({d} day{s})\n", .{ then_str, now_str, cv.days_between, view.dayPlural(cv.days_between), }); try out.print("\n", .{}); // Totals line — two-color: muted "then → now", intent-colored delta/pct. try renderTotalsLine(out, color, cv.liquid); // Optional attribution line: breaks the liquid delta into // contributions vs. market gains/losses. Only present when the // `compare` CLI had a git repo to work with. if (cv.attribution) |a| { try renderAttributionLine(out, color, cv.liquid.delta, a); } // Optional projections block (opt-in via --projections/-p). // Slots between the attribution rows and the per-symbol table // so the "headline" numbers cluster together at the top. if (proj) |p| { try out.print("\n", .{}); try projections.renderKeyComparisonRows(out, color, p.then, p.now, p.events_enabled); } try out.print("\n", .{}); // Per-symbol table if (cv.held_count == 0) { try cli.printFg(out, color, cli.CLR_MUTED, "No symbols held throughout this period.\n", .{}); } else { try cli.printFg(out, color, cli.CLR_MUTED, "Per-symbol price change ({d} held throughout)\n", .{cv.held_count}); for (cv.symbols) |s| { try renderSymbolRow(out, color, s); } try renderGainerLoserSummary(out, color, cv); } // Hidden count if (cv.added_count > 0 or cv.removed_count > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{ cv.added_count, cv.removed_count, then_str, }); } } /// Render the gainer/loser/flat summary line under the per-symbol /// table. Flat counts only surface when non-zero to keep the signal /// tight — a full window of winners shouldn't read "0 flat". /// /// 21 gainers, 5 losers /// 21 gainers, 5 losers, 2 flat /// /// Colored segments match the per-symbol rows: gainers in the positive /// intent, losers in the negative intent, "flat" (and punctuation) in /// the muted intent. "gainer" and "loser" are colored unconditionally /// — a zero count still communicates something about the window (e.g. /// "0 losers" in negative tint reinforces "everything was green"). /// Callers gate on `cv.held_count > 0`. fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { try cli.printFg(out, color, cli.CLR_MUTED, " ", .{}); try cli.printFg(out, color, cli.CLR_POSITIVE, "{d} gainer{s}", .{ cv.gainer_count, if (cv.gainer_count == 1) "" else "s", }); try cli.printFg(out, color, cli.CLR_MUTED, ", ", .{}); try cli.printFg(out, color, cli.CLR_NEGATIVE, "{d} loser{s}", .{ cv.loser_count, if (cv.loser_count == 1) "" else "s", }); if (cv.flat_count > 0) { try cli.printFg(out, color, cli.CLR_MUTED, ", {d} flat\n", .{cv.flat_count}); } else { try out.print("\n", .{}); } } fn renderTotalsLine(out: *std.Io.Writer, color: bool, t: view.TotalsRow) !void { var then_buf: [24]u8 = undefined; var now_buf: [24]u8 = undefined; var delta_buf: [32]u8 = undefined; var pct_buf: [16]u8 = undefined; const c = view.buildTotalsCells(t, &then_buf, &now_buf, &delta_buf, &pct_buf); try out.print("Liquid: ", .{}); try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}{s}", .{ c.then, view.arrow, c.now }); try cli.printIntent(out, color, c.style, " {s} {s}\n", .{ c.delta, c.pct }); } /// Render the investment-gains vs. cash-contributions breakdown of /// the liquid delta, stacked as two labeled rows directly beneath /// the Liquid line: /// /// Investment gains: +$85,062.72 /// Cash contributions: +$7,866.22 /// /// Both amounts are signed. Negative gains (market loss) and /// negative contributions (net withdrawal) print with a leading `-` /// in the negative color. Labels use the muted color to stay out of /// the way of the eye scan for the numbers. /// /// Labels are padded to a fixed width so the `$` column aligns /// regardless of label length. Indent of 2 spaces matches the /// gainer/loser summary beneath the per-symbol table. /// /// Math identity: `delta = gains + contributions`, so /// `gains = delta - contributions`. The caller derives `gains` from /// the liquid delta before this is called; we just render. fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attribution: view.Attribution) !void { _ = delta; // delta is already shown on the Liquid row above; we // don't repeat it here. Kept in the signature so the // callsite stays readable (`renderAttributionLine(out, // color, cv.liquid.delta, a)`) and future revisions // that want to restate the Δ have it in scope. // 19-char label column aligns the amount columns. "Investment // gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is // 19 chars → 0 trailing pad. The 2-space gutter that follows // keeps the amounts clearly separated from the labels even on // narrow terminals. try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"}); try cli.printGainLoss(out, color, attribution.gains, "{f}\n", .{Money.from(attribution.gains).signed()}); try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Cash contributions:"}); try cli.printGainLoss(out, color, attribution.contributions, "{f}\n", .{Money.from(attribution.contributions).signed()}); } fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void { var then_buf: [24]u8 = undefined; var now_buf: [24]u8 = undefined; var pct_buf: [16]u8 = undefined; var dollar_buf: [32]u8 = undefined; const c = view.buildSymbolRowCells(s, &then_buf, &now_buf, &pct_buf, &dollar_buf); // Leading indent + symbol in default color. try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); // "then → now" in muted color. try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); // Delta/pct in intent color. try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); } // ── 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. // Snapshot-IO tests (findNearestSnapshot, loadSnapshotAt) moved to // src/history.zig. This file keeps only CLI-surface tests. test "renderCompare: basic output includes expected elements" { // Build a minimal comparison view by hand. Symbols and dollar // values are intentionally generic/round — this test is about the // rendering scaffolding, not about matching anyone's real portfolio. const symbols = [_]view.SymbolChange{ .{ .symbol = "FOO", .price_then = 100.00, .price_now = 110.00, .shares_held_throughout = 100, .pct_change = 0.10, .dollar_change = 1000, .style = .positive, }, .{ .symbol = "BAR", .price_then = 50.00, .price_now = 49.00, .shares_held_throughout = 200, .pct_change = -0.02, .dollar_change = -200, .style = .negative, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 25), .days_between = 10, .now_is_live = true, .liquid = view.buildTotalsRow(10_000, 10_500), .symbols = @constCast(&symbols), .held_count = 2, .added_count = 3, .removed_count = 1, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Header try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-01-25 (live)") != null); try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null); // Totals try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "$10,500.00") != null); try testing.expect(std.mem.indexOf(u8, out, "+$500.00") != null); // Per-symbol try testing.expect(std.mem.indexOf(u8, out, "held throughout") != null); try testing.expect(std.mem.indexOf(u8, out, "FOO") != null); try testing.expect(std.mem.indexOf(u8, out, "BAR") != null); // Hidden try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 — hidden)") != null); } test "renderCompare: two-snapshot mode shows real date, no (live) marker" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); try testing.expect(std.mem.indexOf(u8, out, "(live)") == null); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null); // No "hidden" line when both counts are zero try testing.expect(std.mem.indexOf(u8, out, "hidden") == null); } test "renderCompare: 1-day diff uses singular 'day'" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 16), .days_between = 1, .now_is_live = false, .liquid = view.buildTotalsRow(100, 100), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(1 day)") != null); try testing.expect(std.mem.indexOf(u8, out, "(1 days)") == null); } test "renderCompare: only added positions (no removed)" { const symbols = [_]view.SymbolChange{ .{ .symbol = "AAPL", .price_then = 150, .price_now = 160, .shares_held_throughout = 100, .pct_change = 0.0667, .dollar_change = 1000, .style = .positive, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(10000, 11000), .symbols = @constCast(&symbols), .held_count = 1, .added_count = 2, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null); } test "renderCompare: negative totals delta" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(1_000_000, 900_000), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Delta is signed negative; pct same try testing.expect(std.mem.indexOf(u8, out, "-$100,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); } test "renderCompare: attribution line when attribution is set" { const cv = view.CompareView{ .then_date = Date.fromYmd(2026, 3, 13), .now_date = Date.fromYmd(2026, 4, 2), .days_between = 20, .now_is_live = true, .liquid = view.buildTotalsRow(7_698_825.62, 7_728_973.64), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, // Numbers match the real-world email example: // +$30,148 delta = +$22,636 contributions + +$7,512 gains .attribution = .{ .contributions = 22_636.00, .gains = 7_512.02, }, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") != null); try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") != null); try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null); // on the Liquid row above try testing.expect(std.mem.indexOf(u8, out, "+$22,636.00") != null); try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null); // The old `Attribution:` prefix is gone. try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null); } test "renderCompare: no attribution line when attribution is null" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, // attribution intentionally omitted (defaults to null) }; var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") == null); try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") == null); } test "renderCompare: attribution handles negative gains" { // Window where contributions happened but market fell. const cv = view.CompareView{ .then_date = Date.fromYmd(2026, 3, 13), .now_date = Date.fromYmd(2026, 4, 2), .days_between = 20, .now_is_live = true, // Liquid went UP (net), but only because contributions // overcompensated for market losses. .liquid = view.buildTotalsRow(1_000_000, 1_005_000), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, .attribution = .{ .contributions = 15_000, .gains = -10_000, // delta − contributions = 5000 − 15000 = −10k }, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "+$15,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "-$10,000.00") != null); } test "renderCompare: gainer/loser summary line renders with pluralization" { const symbols = [_]view.SymbolChange{ .{ .symbol = "A", .price_then = 100, .price_now = 110, .shares_held_throughout = 1, .pct_change = 0.10, .dollar_change = 10, .style = .positive, }, .{ .symbol = "B", .price_then = 100, .price_now = 105, .shares_held_throughout = 1, .pct_change = 0.05, .dollar_change = 5, .style = .positive, }, .{ .symbol = "C", .price_then = 100, .price_now = 95, .shares_held_throughout = 1, .pct_change = -0.05, .dollar_change = -5, .style = .negative, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 22), .days_between = 7, .now_is_live = true, .liquid = view.buildTotalsRow(300, 310), .symbols = @constCast(&symbols), .held_count = 3, .added_count = 0, .removed_count = 0, .gainer_count = 2, .loser_count = 1, .flat_count = 0, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Plural gainers, singular loser try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null); try testing.expect(std.mem.indexOf(u8, out, "1 loser") != null); // Singular "loser" shouldn't have trailing 's' — look for the // comma-terminated form to disambiguate. try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null); // No flat segment when flat_count == 0 try testing.expect(std.mem.indexOf(u8, out, "flat") == null); } test "renderCompare: gainer/loser summary suppressed when no held symbols" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Neither "gainer" nor "loser" should appear — the summary is // gated on held_count > 0. try testing.expect(std.mem.indexOf(u8, out, "gainer") == null); try testing.expect(std.mem.indexOf(u8, out, "loser") == null); } test "renderCompare: gainer/loser summary includes flat when present" { const symbols = [_]view.SymbolChange{ .{ .symbol = "UP", .price_then = 100, .price_now = 110, .shares_held_throughout = 1, .pct_change = 0.10, .dollar_change = 10, .style = .positive, }, .{ .symbol = "FLAT1", .price_then = 100, .price_now = 100, .shares_held_throughout = 1, .pct_change = 0, .dollar_change = 0, .style = .muted, }, .{ .symbol = "FLAT2", .price_then = 100, .price_now = 100, .shares_held_throughout = 1, .pct_change = 0, .dollar_change = 0, .style = .muted, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 22), .days_between = 7, .now_is_live = true, .liquid = view.buildTotalsRow(300, 310), .symbols = @constCast(&symbols), .held_count = 3, .added_count = 0, .removed_count = 0, .gainer_count = 1, .loser_count = 0, .flat_count = 2, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "1 gainer") != null); try testing.expect(std.mem.indexOf(u8, out, "0 losers") != null); try testing.expect(std.mem.indexOf(u8, out, "2 flat") != null); } // ── run() entry-point validation tests ───────────────────────── fn makeTestSvc() zfin.DataService { // Minimal in-memory config. `cache_dir` must be set; "/tmp" is fine // since these tests never hit the cache. const config = zfin.Config{ .cache_dir = "/tmp" }; return zfin.DataService.init(std.testing.io, testing.allocator, config); } fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 { const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); defer allocator.free(dir_path); 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(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const result = runArgs(io, testing.allocator, &svc, pf, &.{}, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.MissingDateArg, result); } test "run: three args returns UnexpectedArg" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-02-15", "2024-03-15" }; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.UnexpectedArg, result); } test "run: bad date1 returns InvalidDate" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"not-a-date"}; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: valid date1 + bad date2 returns InvalidDate" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024/03/15" }; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: same date twice returns SameDate" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-01-15" }; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SameDate, result); } test "run: one date equal to today returns SameDate" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); var today_buf: [10]u8 = undefined; const today_date = Date.fromYmd(2024, 3, 15); const today_str = std.fmt.bufPrint(&today_buf, "{f}", .{today_date}) catch unreachable; const args = [_][]const u8{today_str}; const result = runArgs(io, testing.allocator, &svc, pf, &args, today_date, false, &stream); try testing.expectError(error.SameDate, result); } test "run: single-date past-date with empty history returns SnapshotNotFound" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2020-01-01"}; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } test "run: single-date future-date rejected as InvalidDate" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2099-01-01"}; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" { const io = std.testing.io; // Verifies that `zfin compare 1W` doesn't bail with InvalidDate // for a non-ISO string — the relative shortcut resolves to an // absolute date, which then tries to load a snapshot that // doesn't exist. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"1W"}; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } test "run: 'live' string rejected as InvalidDate (not a real prior date)" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"live"}; const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); // 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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream); try testing.expectError(error.SnapshotNotFound, result); } test "run: two-date happy path via fixtures" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var svc = makeTestSvc(); defer svc.deinit(); try tmp.dir.createDirPath(io, "history"); var hist_dir = try tmp.dir.openDir(io, "history", .{}); defer hist_dir.close(io); const d1 = Date.fromYmd(2024, 1, 15); const d2 = Date.fromYmd(2024, 3, 15); const lots_then = [_]snapshot_model.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Roth", .security_type = "Stock", .shares = 100, .open_price = 120, .cost_basis = 12_000, .value = 15_000, .price = 150.0, .quote_date = d1, }, }; const lots_now = [_]snapshot_model.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Roth", .security_type = "Stock", .shares = 100, .open_price = 120, .cost_basis = 12_000, .value = 16_500, .price = 165.0, .quote_date = d2, }, }; try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2024-01-15-portfolio.srf", d1, 15_000, 15_000, &lots_then); try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2024-03-15-portfolio.srf", d2, 16_500, 16_500, &lots_now); const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator); defer testing.allocator.free(pf); var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-03-15" }; 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); try testing.expect(std.mem.indexOf(u8, out, "+10.00%") != null); } fn writeFixtureSnapshot( io: std.Io, dir: std.Io.Dir, allocator: std.mem.Allocator, filename: []const u8, as_of: Date, liquid: f64, net_worth: f64, stock_rows: []const snapshot_model.LotRow, ) !void { const snapshot = @import("snapshot.zig"); const totals = [_]snapshot_model.TotalRow{ .{ .kind = "total", .scope = "net_worth", .value = net_worth }, .{ .kind = "total", .scope = "liquid", .value = liquid }, .{ .kind = "total", .scope = "illiquid", .value = net_worth - liquid }, }; const snap: snapshot_model.Snapshot = .{ .meta = .{ .kind = "meta", .snapshot_version = 1, .as_of_date = as_of, .captured_at = 1_745_222_400, .zfin_version = "test", .stale_count = 0, }, .totals = @constCast(&totals), .tax_types = &.{}, .accounts = &.{}, .lots = @constCast(stock_rows), }; const rendered = try snapshot.renderSnapshot(allocator, snap); defer allocator.free(rendered); try dir.writeFile(io, .{ .sub_path = filename, .data = rendered }); } test "printSnapNote: 1 day earlier uses singular 'day'" { var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 14), "then"); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "requested 2024-03-15 for then") != null); try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2024-03-14") != null); try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null); // Singular: must NOT contain "1 days" try testing.expect(std.mem.indexOf(u8, out, "1 days") == null); // Trailing newline try testing.expectEqual(@as(u8, '\n'), out[out.len - 1]); } test "printSnapNote: multi-day uses plural 'days'" { var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 8), "now"); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "requested 2024-03-15 for now") != null); try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2024-03-08") != null); try testing.expect(std.mem.indexOf(u8, out, "7 days earlier") != null); } test "printSnapNote: label is interpolated verbatim" { var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "vs"); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "for vs;") != null); } test "printSnapNote: color=false emits no ANSI escapes" { var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "then"); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); } test "printSnapNote: color=true emits muted-fg ANSI escape and reset" { var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, true, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "then"); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "\x1b[38;2;") != null); // Reset ANSI before newline try testing.expect(std.mem.indexOf(u8, out, "\x1b[0m") != null); } test "printSnapNote: month-boundary day delta computes calendar days" { // 2024-04-01 requested, 2024-03-30 actual → 2 days earlier. var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then"); try testing.expect(std.mem.indexOf(u8, w.buffered(), "2 days earlier") != null); }