From 62d058bb115bfc262eeb051c2d151d55f2e42e6b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 17:07:50 -0700 Subject: [PATCH] migrate contributions to new cli framework --- src/commands/contributions.zig | 203 ++++++++++++++++++++++++++++++++- src/main.zig | 102 +---------------- 2 files changed, 203 insertions(+), 102 deletions(-) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 58d336a..61c55a5 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -162,6 +162,8 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const git = @import("../git.zig"); +const framework = @import("framework.zig"); +const TimeRange = @import("TimeRange.zig"); const analysis = @import("../analytics/analysis.zig"); const transaction_log = @import("../models/transaction_log.zig"); const fmt = cli.fmt; @@ -180,7 +182,137 @@ const Endpoints = struct { label: []const u8, }; -pub fn run( +pub const ParsedArgs = struct { + before: ?git.CommitSpec = null, + after: ?git.CommitSpec = null, +}; + +pub const meta = struct { + pub const name: []const u8 = "contributions"; + pub const group: framework.Group = .timeseries; + pub const synopsis: []const u8 = "Show money added since the last recorded state in git"; + pub const help: []const u8 = + \\Usage: zfin contributions [opts] + \\ + \\Show contributions, withdrawals, and lot-level changes between + \\two points in the portfolio's git history. Four modes: + \\ + \\ No flags (default): + \\ dirty working tree: HEAD vs working copy + \\ clean working tree: HEAD~1 vs HEAD (review last commit) + \\ --since commit-at-or-before(DATE) vs HEAD (or + \\ working copy when dirty) + \\ --since --until commit-at-or-before(D1) vs + \\ commit-at-or-before(D2) + \\ --until alone rejected; window is ambiguous + \\ + \\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y). + \\ + \\Options: + \\ --since Earliest side (resolves to commit-at- + \\ or-before). + \\ --until Latest side. Pair with --since. + \\ --commit-before Pin the before commit directly. Same + \\ grammar as --commit-after, minus + \\ `working`. Useful when you committed + \\ after your review date. + \\ --commit-after Pin the after commit. SPEC accepts + \\ YYYY-MM-DD, relative (1W/1M/1Q/1Y), + \\ HEAD, HEAD~N, hex SHA, or `working` + \\ for the working copy. + \\ + \\--since and --commit-before describe the same axis; pass at most + \\one. Same for --until and --commit-after. + \\ + ; +}; + +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; + + const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{ + .accept_since = true, + .accept_until = true, + .accept_commit_before = true, + .accept_commit_after = true, + }) catch |err| switch (err) { + error.MissingValue, + error.InvalidValue, + error.WorkingCopyOnBeforeSide, + error.LiveNotAllowed, + => return error.InvalidArg, + error.DuplicateEndpoint, error.RepeatedFlag => return error.DuplicateEndpoint, + error.OutOfMemory => return error.OutOfMemory, + }; + defer allocator.free(tr_result.consumed); + + // Reject any tokens TimeRange didn't consume — contributions has + // no other flags or positionals. + var consumed_set = std.AutoHashMap(usize, void).init(allocator); + defer consumed_set.deinit(); + for (tr_result.consumed) |idx| try consumed_set.put(idx, {}); + for (cmd_args, 0..) |a, i| { + if (consumed_set.contains(i)) continue; + try cli.stderrPrint(io, "Error: unexpected argument to 'contributions': "); + try cli.stderrPrint(io, a); + try cli.stderrPrint(io, "\n"); + return error.UnexpectedArg; + } + + // Translate Endpoint to CommitSpec. `--since` produces a date + // endpoint; `--commit-before` a commit_spec endpoint. Map both + // to the same CommitSpec union the existing run() expects. + var parsed: ParsedArgs = .{}; + if (tr_result.range.before) |ep| switch (ep) { + .date => |d| parsed.before = .{ .date_at_or_before = d }, + .commit_spec => |s| parsed.before = s, + .live => unreachable, + }; + if (tr_result.range.after) |ep| switch (ep) { + .date => |d| parsed.after = .{ .date_at_or_before = d }, + .commit_spec => |s| parsed.after = s, + .live => unreachable, + }; + + // Validate `--since on or before --until` ordering for the + // date-only form, matching legacy behavior. We can only check + // when BOTH sides are date_at_or_before (the commit-spec form + // is opaque until git resolves it). + if (parsed.before) |b| if (parsed.after) |a| { + if (b == .date_at_or_before and a == .date_at_or_before) { + if (b.date_at_or_before.days > a.date_at_or_before.days) { + try cli.stderrPrint(io, "Error: --since must be on or before --until.\n"); + return error.InvalidArg; + } + } + }; + 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 before = parsed.before; + const after = parsed.after; + + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const portfolio_path = pf.path; + + return runImpl(io, allocator, svc, portfolio_path, before, after, as_of, color, out); +} + +fn runImpl( io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, @@ -2488,6 +2620,75 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3 // ── Tests ──────────────────────────────────────────────────── +const testing = std.testing; + +fn parseArgsForTest(today: zfin.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: empty args produces both null" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const parsed = try parseArgsForTest(today, &.{}); + try testing.expect(parsed.before == null); + try testing.expect(parsed.after == null); +} + +test "parseArgs: --since populates before as date_at_or_before" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--since", "2026-04-01" }; + const parsed = try parseArgsForTest(today, &args); + switch (parsed.before.?) { + .date_at_or_before => |d| try testing.expect(d.eql(zfin.Date.fromYmd(2026, 4, 1))), + else => try testing.expect(false), + } +} + +test "parseArgs: --since + --until populates both" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--since", "2026-04-01", "--until", "2026-05-01" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.before != null); + try testing.expect(parsed.after != null); +} + +test "parseArgs: --commit-after working" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--commit-after", "working" }; + const parsed = try parseArgsForTest(today, &args); + try testing.expect(parsed.after.? == .working_copy); +} + +test "parseArgs: --since + --commit-before is duplicate axis" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--since", "1W", "--commit-before", "HEAD" }; + try testing.expectError(error.DuplicateEndpoint, parseArgsForTest(today, &args)); +} + +test "parseArgs: --since after --until errors" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--since", "2026-05-01", "--until", "2026-04-01" }; + try testing.expectError(error.InvalidArg, parseArgsForTest(today, &args)); +} + +test "parseArgs: unknown flag errors" { + const today = zfin.Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--bogus", "value" }; + try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args)); +} + test "computeReport: fresh stock purchase counts as new contribution" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/main.zig b/src/main.zig index bcf4781..b0be489 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,6 +30,7 @@ const command_modules = .{ // Time-series & journaling .snapshot = @import("commands/snapshot.zig"), .compare = @import("commands/compare.zig"), + .contributions = @import("commands/contributions.zig"), // Data hygiene .enrich = @import("commands/enrich.zig"), @@ -580,107 +581,6 @@ fn runCli(init: std.process.Init) !u8 { } else { try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out); } - } else if (std.mem.eql(u8, command, "contributions")) { - var since: ?zfin.Date = null; - var until: ?zfin.Date = null; - var before_spec: ?cli.CommitSpec = null; - var after_spec: ?cli.CommitSpec = null; - var i: usize = 0; - while (i < cmd_args.len) : (i += 1) { - const a = cmd_args[i]; - if (std.mem.eql(u8, a, "--since") or std.mem.eql(u8, a, "--until")) { - if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " requires a value (YYYY-MM-DD or N[WMQY]).\n"); - return 1; - } - const value = cmd_args[i + 1]; - const parsed = cli.parseAsOfDate(value, today) catch |err| { - var buf: [256]u8 = undefined; - const msg = cli.fmtAsOfParseError(&buf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); - return 1; - }; - // `parsed == null` means the user typed "live" or an - // empty string — meaningless for --since/--until, which - // require concrete dates. - const resolved = parsed orelse { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n"); - return 1; - }; - if (std.mem.eql(u8, a, "--since")) { - since = resolved; - } else { - until = resolved; - } - i += 1; // consume the value - } else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) { - if (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 1; - } - const value = cmd_args[i + 1]; - const spec = cli.parseCommitSpec(value, today) catch |err| { - var buf: [256]u8 = undefined; - const msg = cli.fmtCommitSpecError(&buf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); - return 1; - }; - 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 1; - } - before_spec = spec; - } else { - after_spec = spec; - } - i += 1; // consume the value - } else { - try reportUnexpectedArg(io, "contributions", a); - return 1; - } - } - // Conflict detection: --since and --commit-before describe the - // same axis, same for --until and --commit-after. Taking both - // would be ambiguous about which wins. - if (since != null and before_spec != null) { - try cli.stderrPrint(io, "Error: --since and --commit-before both specify the before side. Pick one.\n"); - return 1; - } - if (until != null and after_spec != null) { - try cli.stderrPrint(io, "Error: --until and --commit-after both specify the after side. Pick one.\n"); - return 1; - } - if (since != null and until != null and since.?.days > until.?.days) { - try cli.stderrPrint(io, "Error: --since must be on or before --until.\n"); - return 1; - } - // Resolve to CommitSpec for the command. Date flags become - // `.date_at_or_before`, commit flags pass through. - const before_final: ?cli.CommitSpec = if (before_spec) |s| - s - else if (since) |d| - .{ .date_at_or_before = d } - else - null; - const after_final: ?cli.CommitSpec = if (after_spec) |s| - s - else if (until) |d| - .{ .date_at_or_before = d } - else - null; - - 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 { try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1;