migrate snapshot to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:42:45 -07:00
parent a4be58fa05
commit 177016a153
2 changed files with 153 additions and 47 deletions

View file

@ -33,6 +33,7 @@ const std = @import("std");
const srf = @import("srf"); const srf = @import("srf");
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 fmt = @import("../format.zig"); const fmt = @import("../format.zig");
const atomic = @import("../atomic.zig"); const atomic = @import("../atomic.zig");
const version = @import("../version.zig"); const version = @import("../version.zig");
@ -56,66 +57,111 @@ pub const SnapshotError = error{
WriteFailed, WriteFailed,
}; };
// ── Entry point ────────────────────────────────────────────── pub const ParsedArgs = struct {
force: bool = false,
dry_run: bool = false,
out_override: ?[]const u8 = null,
as_of_override: ?Date = null,
};
/// Run the snapshot command. pub const meta = struct {
/// pub const name: []const u8 = "snapshot";
/// `args` is the slice after `zfin snapshot`. Accepted flags: pub const group: framework.Group = .timeseries;
/// --force overwrite existing snapshot for as_of_date pub const synopsis: []const u8 = "Write a daily portfolio snapshot to history/";
/// --out <path> override output path (skips the default derivation) pub const help: []const u8 =
/// --dry-run compute + print, do not write \\Usage: zfin snapshot [opts]
/// \\
/// Exit semantics: \\Compute a portfolio snapshot for today (or a historical date with
/// 0 on success (including duplicate-skip) \\`--as-of`) and write it as a discriminated SRF file under
/// non-zero on any error \\`history/<as_of_date>-portfolio.srf`. The output records start with
pub fn run( \\`kind::<meta|total|tax_type|account|lot>` so readers can demux on
io: std.Io, \\that field.
allocator: std.mem.Allocator, \\
svc: *zfin.DataService, \\Default mode: refresh the candle cache for held symbols, derive
portfolio_path: []const u8, \\`as_of_date` from the mode of cached candle dates, look up
args: []const []const u8, \\close-on-or-before for each lot, and write atomically.
now_s: i64, \\
color: bool, \\Options:
out: *std.Io.Writer, \\ --force overwrite existing snapshot for as_of_date
) !void { \\ --dry-run compute + print to stdout, do not write
// Parse flags. \\ --out <path> override output path
var force = false; \\ --as-of <DATE> write a snapshot for a historical date
var dry_run = false; \\ (uses git to recover portfolio state and
var out_override: ?[]const u8 = null; \\ candle cache for pricing). DATE accepts
var as_of_override: ?Date = null; \\ YYYY-MM-DD or relative shortcuts
\\ (1W/1M/1Q/1Y).
\\
\\If `history/<as_of_date>-portfolio.srf` already exists and `--force`
\\wasn't passed, the run skips with a stderr message.
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
var i: usize = 0; var i: usize = 0;
while (i < args.len) : (i += 1) { while (i < cmd_args.len) : (i += 1) {
const a = args[i]; const a = cmd_args[i];
if (std.mem.eql(u8, a, "--force")) { if (std.mem.eql(u8, a, "--force")) {
force = true; parsed.force = true;
} else if (std.mem.eql(u8, a, "--dry-run")) { } else if (std.mem.eql(u8, a, "--dry-run")) {
dry_run = true; parsed.dry_run = true;
} else if (std.mem.eql(u8, a, "--out")) { } else if (std.mem.eql(u8, a, "--out")) {
i += 1; i += 1;
if (i >= args.len) { if (i >= cmd_args.len) {
try cli.stderrPrint(io, "Error: --out requires a path argument\n"); try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
out_override = args[i]; parsed.out_override = cmd_args[i];
} else if (std.mem.eql(u8, a, "--as-of")) { } else if (std.mem.eql(u8, a, "--as-of")) {
i += 1; i += 1;
if (i >= args.len) { if (i >= cmd_args.len) {
try cli.stderrPrint(io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); try cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
// Reference date for resolving relative forms in `--as-of` // Reference date for resolving relative forms in `--as-of`
// (e.g. "1W" → 7 days before this anchor). // (e.g. "1W" → 7 days before this anchor).
const flag_anchor = Date.fromEpoch(now_s); const flag_anchor = Date.fromEpoch(ctx.now_s);
as_of_override = cli.parseRequiredDateOrStderr(io, args[i], flag_anchor, "--as-of") catch |err| switch (err) { parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
error.InvalidDate => return error.UnexpectedArg, error.InvalidDate => return error.UnexpectedArg,
}; };
} else { } else {
try cli.stderrPrint(io, "Error: unknown argument to 'snapshot': "); try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
try cli.stderrPrint(io, a); try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(io, "\n"); try cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg; return error.UnexpectedArg;
} }
} }
return parsed;
}
// ── Entry point ──────────────────────────────────────────────
/// Run the snapshot command.
///
/// Exit semantics:
/// 0 on success (including duplicate-skip)
/// non-zero on any error
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 now_s = ctx.now_s;
const force = parsed.force;
const dry_run = parsed.dry_run;
const out_override = parsed.out_override;
const as_of_override = parsed.as_of_override;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
// Load portfolio bytes. In normal (no --as-of) mode this is the // Load portfolio bytes. In normal (no --as-of) mode this is the
// current working-copy of portfolio.srf. With --as-of, we first try // current working-copy of portfolio.srf. With --as-of, we first try
@ -906,6 +952,70 @@ pub fn renderSnapshot(allocator: std.mem.Allocator, snap: Snapshot) ![]const u8
const testing = std.testing; const testing = std.testing;
test "parseArgs: defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(!parsed.force);
try std.testing.expect(!parsed.dry_run);
try std.testing.expect(parsed.out_override == null);
try std.testing.expect(parsed.as_of_override == null);
}
test "parseArgs: --force --dry-run" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{ "--force", "--dry-run" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.force);
try std.testing.expect(parsed.dry_run);
}
test "parseArgs: --out captures path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{ "--out", "/tmp/snap.srf" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("/tmp/snap.srf", parsed.out_override.?);
}
test "parseArgs: --out without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{"--out"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --as-of with explicit date" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{ "--as-of", "2026-01-15" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.as_of_override.?.eql(Date.fromYmd(2026, 1, 15)));
}
test "parseArgs: --as-of without value errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{"--as-of"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: unknown arg errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.now_s = 0;
const args = [_][]const u8{"--bogus"};
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "deriveSnapshotPath: standard layout" { test "deriveSnapshotPath: standard layout" {
// Build the input portfolio path and expected output from // Build the input portfolio path and expected output from
// path-joined components so the test runs on both POSIX and Windows. // path-joined components so the test runs on both POSIX and Windows.

View file

@ -27,6 +27,9 @@ const command_modules = .{
.analysis = @import("commands/analysis.zig"), .analysis = @import("commands/analysis.zig"),
.milestones = @import("commands/milestones.zig"), .milestones = @import("commands/milestones.zig"),
// Time-series & journaling
.snapshot = @import("commands/snapshot.zig"),
// Data hygiene // Data hygiene
.enrich = @import("commands/enrich.zig"), .enrich = @import("commands/enrich.zig"),
.lookup = @import("commands/lookup.zig"), .lookup = @import("commands/lookup.zig"),
@ -681,13 +684,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, "snapshot")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.snapshot.run(io, allocator, &svc, pf.path, cmd_args, now_s, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
else => return err,
};
} else if (std.mem.eql(u8, command, "compare")) { } else if (std.mem.eql(u8, command, "compare")) {
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);