migrate snapshot to new cli framework
This commit is contained in:
parent
682ebd10c0
commit
b8e6732df1
2 changed files with 153 additions and 47 deletions
|
|
@ -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.
|
||||
|
|
|
|||
10
src/main.zig
10
src/main.zig
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue