From 1228d50ce6f084c9363fd4c6c1c73cc599ab1964 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 16:02:02 -0700 Subject: [PATCH] migrate history to new cli framework --- src/commands/history.zig | 147 +++++++++++++++++++++++++++++++++------ src/main.zig | 26 +------ 2 files changed, 126 insertions(+), 47 deletions(-) diff --git a/src/commands/history.zig b/src/commands/history.zig index c806726..69c1b94 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -32,6 +32,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 atomic = @import("../atomic.zig"); const timeline = @import("../analytics/timeline.zig"); const history = @import("../history.zig"); @@ -40,6 +41,54 @@ const view = @import("../views/history.zig"); const fmt = cli.fmt; const Date = @import("../Date.zig"); +/// Parsed args for `zfin history`. Tagged union since the command has +/// two genuinely different modes: +/// +/// - Symbol mode: `zfin history ` shows the last 30 days of +/// candles for one symbol. Triggered when `args[0]` exists and +/// doesn't start with `-`. +/// - Portfolio mode: `zfin history [flags]` shows the portfolio-value +/// timeline derived from `history/*-portfolio.srf` snapshots. +/// Triggered when there are no positional args (or only flags). +pub const ParsedArgs = union(enum) { + symbol: []const u8, + portfolio: PortfolioOpts, +}; + +pub const meta = struct { + pub const name: []const u8 = "history"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Price history (symbol) or portfolio value timeline"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: + \\ zfin history # last 30 days of candles + \\ zfin history [flags] # portfolio-value timeline + \\ + \\Two modes in one command. Symbol mode (positional symbol) + \\shows the last 30 trading days of candles for that symbol. + \\Portfolio mode (no positional, optionally with flags) reads + \\`history/*-portfolio.srf` snapshots and renders rolling-windows + \\returns + a braille chart + a recent-snapshots table. + \\ + \\Portfolio-mode flags: + \\ --since earliest as-of date (inclusive) + \\ --until latest as-of date (inclusive) + \\ --metric liquid (default), illiquid, or net_worth + \\ --resolution daily | weekly | monthly | auto + \\ (auto: daily ≤90d, weekly ≤730d, else monthly) + \\ --limit cap recent-snapshots table to N rows (default 40) + \\ --rebuild-rollup regenerate history/rollup.srf and exit + \\ + \\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y). + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + pub const Error = error{ UnexpectedArg, InvalidFlagValue, @@ -116,35 +165,39 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port return opts; } -/// Entry point. Dispatches to symbol mode or portfolio mode based on -/// the first argument. -pub fn run( - io: std.Io, - allocator: std.mem.Allocator, - svc: *zfin.DataService, - portfolio_path: []const u8, - args: []const []const u8, - as_of: zfin.Date, - color: bool, - out: *std.Io.Writer, -) !void { - if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') { - try runSymbol(io, svc, args[0], as_of, color, out); - return; +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') { + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; } - - const opts = parsePortfolioOpts(as_of, args) catch |err| { + const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| { switch (err) { - error.UnexpectedArg => try cli.stderrPrint(io, "Error: unknown flag in 'history'. See --help.\n"), - error.MissingFlagValue => try cli.stderrPrint(io, "Error: flag requires a value.\n"), - error.InvalidFlagValue => try cli.stderrPrint(io, "Error: invalid flag value.\n"), - error.UnknownMetric => try cli.stderrPrint(io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), - error.UnknownResolution => try cli.stderrPrint(io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), + error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"), + error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"), + error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), + error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), + error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), } return err; }; + return .{ .portfolio = opts }; +} - try runPortfolio(io, allocator, portfolio_path, opts, color, out); +/// Entry point. Dispatches to symbol mode or portfolio mode based on +/// the parsed args. +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + switch (parsed) { + .symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out), + .portfolio => |opts| { + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(ctx.allocator); + try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out); + }, + } } // ── Symbol mode (legacy) ───────────────────────────────────── @@ -627,6 +680,54 @@ fn renderCascadingTable( const testing = std.testing; +test "parseArgs: positional symbol → .symbol variant" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{"AAPL"}; + const parsed = try parseArgs(&ctx, &args); + switch (parsed) { + .symbol => |s| try std.testing.expectEqualStrings("AAPL", s), + .portfolio => try std.testing.expect(false), + } +} + +test "parseArgs: empty args → .portfolio variant with defaults" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{}; + const parsed = try parseArgs(&ctx, &args); + switch (parsed) { + .symbol => try std.testing.expect(false), + .portfolio => |opts| { + try std.testing.expect(opts.since == null); + try std.testing.expect(opts.until == null); + try std.testing.expectEqual(timeline.Metric.liquid, opts.metric); + }, + } +} + +test "parseArgs: --since flag → .portfolio variant" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "--since", "2026-01-01" }; + const parsed = try parseArgs(&ctx, &args); + switch (parsed) { + .symbol => try std.testing.expect(false), + .portfolio => |opts| try std.testing.expect(opts.since.?.eql(Date.fromYmd(2026, 1, 1))), + } +} + +test "parseArgs: symbol mode rejects extra positional" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + ctx.today = Date.fromYmd(2026, 5, 9); + const args = [_][]const u8{ "AAPL", "MSFT" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "parsePortfolioOpts: defaults" { const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &.{}); try testing.expect(o.since == null); diff --git a/src/main.zig b/src/main.zig index b7eb772..ebd1185 100644 --- a/src/main.zig +++ b/src/main.zig @@ -15,6 +15,7 @@ const command_modules = .{ // Per-symbol lookups .perf = @import("commands/perf.zig"), .quote = @import("commands/quote.zig"), + .history = @import("commands/history.zig"), .divs = @import("commands/divs.zig"), .splits = @import("commands/splits.zig"), .options = @import("commands/options.zig"), @@ -468,30 +469,7 @@ fn runCli(init: std.process.Init) !u8 { cmd_args = owned; } - if (std.mem.eql(u8, command, "history")) { - // Two modes in one command: - // zfin history → candle history for a symbol (legacy) - // zfin history [flags] → portfolio timeline from history/*.srf - // - // Only portfolio mode needs portfolio.srf; symbol mode must keep - // working in directories without a configured portfolio. Dispatch - // at this level so that constraint is visible here, not buried - // inside the command. - const is_symbol_mode = cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-'; - if (is_symbol_mode) { - commands.history.run(io, allocator, &svc, "", cmd_args, today, color, out) catch |err| switch (err) { - error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1, - else => return err, - }; - } else { - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - commands.history.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) { - error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1, - else => return err, - }; - } - } else if (std.mem.eql(u8, command, "portfolio")) { + if (std.mem.eql(u8, command, "portfolio")) { // Parse --refresh flag; reject any other token (including old // positional FILE, which is now a global -p). var force_refresh = false;