migrate contributions to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 17:07:50 -07:00
parent 4f02c937d0
commit 62d058bb11
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 203 additions and 102 deletions

View file

@ -162,6 +162,8 @@ const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const git = @import("../git.zig"); const git = @import("../git.zig");
const framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig");
const analysis = @import("../analytics/analysis.zig"); const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig"); const transaction_log = @import("../models/transaction_log.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
@ -180,7 +182,137 @@ const Endpoints = struct {
label: []const u8, 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 <DATE> commit-at-or-before(DATE) vs HEAD (or
\\ working copy when dirty)
\\ --since <D1> --until <D2> commit-at-or-before(D1) vs
\\ commit-at-or-before(D2)
\\ --until <DATE> alone rejected; window is ambiguous
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
\\Options:
\\ --since <DATE> Earliest side (resolves to commit-at-
\\ or-before).
\\ --until <DATE> Latest side. Pair with --since.
\\ --commit-before <SPEC> Pin the before commit directly. Same
\\ grammar as --commit-after, minus
\\ `working`. Useful when you committed
\\ after your review date.
\\ --commit-after <SPEC> 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, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
svc: *zfin.DataService, svc: *zfin.DataService,
@ -2488,6 +2620,75 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3
// Tests // 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" { test "computeReport: fresh stock purchase counts as new contribution" {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit(); defer arena_state.deinit();

View file

@ -30,6 +30,7 @@ const command_modules = .{
// Time-series & journaling // Time-series & journaling
.snapshot = @import("commands/snapshot.zig"), .snapshot = @import("commands/snapshot.zig"),
.compare = @import("commands/compare.zig"), .compare = @import("commands/compare.zig"),
.contributions = @import("commands/contributions.zig"),
// Data hygiene // Data hygiene
.enrich = @import("commands/enrich.zig"), .enrich = @import("commands/enrich.zig"),
@ -580,107 +581,6 @@ fn runCli(init: std.process.Init) !u8 {
} else { } else {
try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out); 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 { } else {
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
return 1; return 1;