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