migrate snapshot to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:42:45 -07:00
parent 682ebd10c0
commit b8e6732df1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 153 additions and 47 deletions

View file

@ -33,6 +33,7 @@ const std = @import("std");
const srf = @import("srf");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = @import("../format.zig");
const atomic = @import("../atomic.zig");
const version = @import("../version.zig");
@ -56,66 +57,111 @@ pub const SnapshotError = error{
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.
///
/// `args` is the slice after `zfin snapshot`. Accepted flags:
/// --force overwrite existing snapshot for as_of_date
/// --out <path> override output path (skips the default derivation)
/// --dry-run compute + print, do not write
///
/// Exit semantics:
/// 0 on success (including duplicate-skip)
/// non-zero on any error
pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
args: []const []const u8,
now_s: i64,
color: bool,
out: *std.Io.Writer,
) !void {
// Parse flags.
var force = false;
var dry_run = false;
var out_override: ?[]const u8 = null;
var as_of_override: ?Date = null;
pub const meta = struct {
pub const name: []const u8 = "snapshot";
pub const group: framework.Group = .timeseries;
pub const synopsis: []const u8 = "Write a daily portfolio snapshot to history/";
pub const help: []const u8 =
\\Usage: zfin snapshot [opts]
\\
\\Compute a portfolio snapshot for today (or a historical date with
\\`--as-of`) and write it as a discriminated SRF file under
\\`history/<as_of_date>-portfolio.srf`. The output records start with
\\`kind::<meta|total|tax_type|account|lot>` so readers can demux on
\\that field.
\\
\\Default mode: refresh the candle cache for held symbols, derive
\\`as_of_date` from the mode of cached candle dates, look up
\\close-on-or-before for each lot, and write atomically.
\\
\\Options:
\\ --force overwrite existing snapshot for as_of_date
\\ --dry-run compute + print to stdout, do not write
\\ --out <path> override output path
\\ --as-of <DATE> write a snapshot for a historical date
\\ (uses git to recover portfolio state and
\\ candle cache for pricing). DATE accepts
\\ 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;
while (i < args.len) : (i += 1) {
const a = args[i];
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--force")) {
force = true;
parsed.force = true;
} else if (std.mem.eql(u8, a, "--dry-run")) {
dry_run = true;
parsed.dry_run = true;
} else if (std.mem.eql(u8, a, "--out")) {
i += 1;
if (i >= args.len) {
try cli.stderrPrint(io, "Error: --out requires a path argument\n");
if (i >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n");
return error.UnexpectedArg;
}
out_override = args[i];
parsed.out_override = cmd_args[i];
} else if (std.mem.eql(u8, a, "--as-of")) {
i += 1;
if (i >= args.len) {
try cli.stderrPrint(io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
if (i >= cmd_args.len) {
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;
}
// Reference date for resolving relative forms in `--as-of`
// (e.g. "1W" 7 days before this anchor).
const flag_anchor = Date.fromEpoch(now_s);
as_of_override = cli.parseRequiredDateOrStderr(io, args[i], flag_anchor, "--as-of") catch |err| switch (err) {
const flag_anchor = Date.fromEpoch(ctx.now_s);
parsed.as_of_override = cli.parseRequiredDateOrStderr(ctx.io, cmd_args[i], flag_anchor, "--as-of") catch |err| switch (err) {
error.InvalidDate => return error.UnexpectedArg,
};
} else {
try cli.stderrPrint(io, "Error: unknown argument to 'snapshot': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\n");
try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': ");
try cli.stderrPrint(ctx.io, a);
try cli.stderrPrint(ctx.io, "\n");
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
// 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;
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" {
// Build the input portfolio path and expected output from
// 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"),
.milestones = @import("commands/milestones.zig"),
// Time-series & journaling
.snapshot = @import("commands/snapshot.zig"),
// Data hygiene
.enrich = @import("commands/enrich.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);
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 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")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);