migrate contributions to new cli framework
This commit is contained in:
parent
4f02c937d0
commit
62d058bb11
2 changed files with 203 additions and 102 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
102
src/main.zig
102
src/main.zig
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue