diff --git a/src/commands/divs.zig b/src/commands/divs.zig index a2b02e4..f962d15 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -1,30 +1,71 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; -pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { - const result = svc.getDividends(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "divs"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show dividend history (with TTM yield) for a symbol"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin divs + \\ + \\Show the dividend history for a symbol from Polygon.io. Cached + \\for 14 days. The TTM (trailing-twelve-month) total + yield are + \\computed against the current Yahoo quote when available. + \\ + \\Examples: + \\ zfin divs VTI # quarterly distributions + yield + \\ zfin divs T # historical AT&T dividends + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getDividends(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching dividend data.\n"); + try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n"); return; }, }; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached dividend data)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached dividend data)\n"); // Fetch current price for yield calculation via DataService var current_price: ?f64 = null; - if (svc.getQuote(symbol)) |q| { + if (svc.getQuote(parsed.symbol)) |q| { current_price = q.close; } else |_| {} - try display(result.data, symbol, current_price, as_of, color, out); + try display(result.data, parsed.symbol, current_price, ctx.today, ctx.color, ctx.out); } pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_price: ?f64, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { @@ -81,6 +122,28 @@ pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_pri // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"VTI"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("VTI", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "VTI", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "display shows dividend data with yield" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index c4e9e8f..89d65c4 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -1,16 +1,63 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; -pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getEarnings(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "earnings"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show earnings history (with EPS surprise) and upcoming events"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin earnings + \\ + \\Show the earnings history (estimate vs. actual + surprise %) + \\and any scheduled future events for a symbol from Financial + \\Modeling Prep. Cached for 30 days; the cache is also smart- + \\refreshed when a past event is missing its `actual` field + \\(catches "results just released" cases without waiting for + \\TTL expiry). + \\ + \\Output is sorted newest-first; pipe through `| tail` for + \\oldest-first. + \\ + \\Examples: + \\ zfin earnings NVDA + \\ zfin earnings AAPL | head -8 # last two years of quarters + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getEarnings(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); + try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching earnings data.\n"); + try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n"); return; }, }; @@ -28,9 +75,9 @@ pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, }.f); } - if (result.source == .cached) try cli.stderrPrint(io, "(using cached earnings data)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n"); - try display(result.data, symbol, color, out); + try display(result.data, parsed.symbol, ctx.color, ctx.out); } pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { @@ -71,6 +118,28 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"NVDA"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("NVDA", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "NVDA", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "display shows earnings with beat" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/etf.zig b/src/commands/etf.zig index d0f5394..cca3353 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -1,16 +1,58 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; -pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getEtfProfile(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "etf"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show ETF profile (holdings, sectors, expense ratio)"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin etf + \\ + \\Show the ETF profile (expense ratio, AUM, dividend yield, + \\sector allocation, top holdings) for a fund symbol from + \\Alpha Vantage. Cached for 30 days. Leveraged funds are + \\flagged in red. + \\ + \\Examples: + \\ zfin etf VTI # broad market index + \\ zfin etf TQQQ # leveraged (warning surfaced) + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getEtfProfile(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); + try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching ETF profile.\n"); + try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n"); return; }, }; @@ -18,9 +60,9 @@ pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, const profile = result.data; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached ETF profile)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n"); - try printProfile(profile, symbol, color, out); + try printProfile(profile, parsed.symbol, ctx.color, ctx.out); } pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { @@ -86,6 +128,28 @@ pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, o // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"VTI"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("VTI", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "VTI", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "printProfile minimal ETF no color" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 03153c5..019ccc2 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -130,7 +130,7 @@ pub const RunCtx = struct { /// Process environment. Used by `interactive` to thread into /// the TUI's Config refresh, and by anything that needs to /// inspect env vars at command time. - environ_map: *const std.process.EnvMap, + environ_map: *const std.process.Environ.Map, config: zfin.Config, /// Null for commands that don't need provider access (`version`, /// `cache`). All other commands must error gracefully if they @@ -260,6 +260,21 @@ pub fn validateCommandModule(comptime Module: type) void { "pub const help: []const u8 = \"multi-line text for `zfin --help`\";", ); + // Optional: `uppercase_first_arg`. Symbol-taking commands + // (perf, quote, divs, ...) set this to `true` so the + // dispatcher uppercases `cmd_args[0]` before calling + // `parseArgs`. When omitted, defaults to false. + if (@hasDecl(meta_decl, "uppercase_first_arg")) { + validator.expectDeclWithType( + "Command module", + mod_name, + meta_decl, + "uppercase_first_arg", + bool, + "pub const uppercase_first_arg: bool = true; // (optional, defaults to false)", + ); + } + // ── parseArgs ────────────────────────────────────────── validator.expectFnInferredError( "Command module", @@ -286,6 +301,41 @@ pub fn validateCommandModule(comptime Module: type) void { // ── Help rendering ──────────────────────────────────────────── +/// Whether a command module opts into having its first positional +/// argument uppercased before `parseArgs` runs. Symbol-taking +/// commands set `meta.uppercase_first_arg = true` so users can type +/// `zfin perf aapl` and have it normalize to `AAPL`. Honors the +/// "skip when the arg is a flag" rule (i.e. starts with `-`). +pub fn uppercasesFirstArg(comptime Module: type) bool { + if (!@hasDecl(Module.meta, "uppercase_first_arg")) return false; + return Module.meta.uppercase_first_arg; +} + +/// If `cmd_args[0]` exists, is non-empty, and isn't a flag, return +/// a copy with the first byte-string uppercased. Otherwise return +/// `cmd_args` unchanged. The new slice is owned by `allocator` +/// (typically `RunCtx.allocator`, the per-invocation arena). +/// +/// Mirrors the existing pre-dispatch normalization in +/// `main.zig:runCli` so migrated commands behave identically to +/// the legacy if-else chain. +pub fn normalizeFirstArg( + allocator: std.mem.Allocator, + cmd_args: []const []const u8, +) ![]const []const u8 { + if (cmd_args.len == 0) return cmd_args; + const first = cmd_args[0]; + if (first.len == 0 or first[0] == '-') return cmd_args; + + const upper = try allocator.dupe(u8, first); + for (upper) |*c| c.* = std.ascii.toUpper(c.*); + + const owned = try allocator.alloc([]const u8, cmd_args.len); + owned[0] = upper; + for (cmd_args[1..], 1..) |a, i| owned[i] = a; + return owned; +} + /// Print a single command's help text. Called by main.zig when the /// user invokes `zfin --help` or `zfin -h`. The help /// text comes from the module's `meta.help` field verbatim — no @@ -371,3 +421,59 @@ test "printCommandHelp: appends newline when meta.help lacks one" { try printCommandHelp(&w, NoNewline); try testing.expectEqualStrings("no trailing newline\n", w.buffered()); } + +test "uppercasesFirstArg: defaults to false when meta omits the field" { + try testing.expect(!uppercasesFirstArg(ProbeModule)); +} + +test "uppercasesFirstArg: honors meta.uppercase_first_arg when present" { + const M = struct { + pub const ParsedArgs = struct {}; + pub const meta = struct { + pub const name: []const u8 = "m"; + pub const group: Group = .symbol_lookup; + pub const synopsis: []const u8 = "m"; + pub const help: []const u8 = "m"; + pub const uppercase_first_arg: bool = true; + }; + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} + }; + comptime validateCommandModule(M); + try testing.expect(uppercasesFirstArg(M)); +} + +test "normalizeFirstArg: empty args returns slice unchanged" { + const empty: []const []const u8 = &.{}; + const out = try normalizeFirstArg(testing.allocator, empty); + try testing.expectEqual(@as(usize, 0), out.len); +} + +test "normalizeFirstArg: lowercase symbol becomes uppercase" { + const args = [_][]const u8{ "aapl", "extra" }; + const out = try normalizeFirstArg(testing.allocator, &args); + // Save out[0] before freeing the slice — once `out` is freed, + // indexing it is use-after-free. + const upper = out[0]; + defer testing.allocator.free(out); + defer testing.allocator.free(upper); + try testing.expectEqualStrings("AAPL", out[0]); + try testing.expectEqualStrings("extra", out[1]); +} + +test "normalizeFirstArg: leading flag is left untouched" { + const args = [_][]const u8{ "--since", "1W" }; + const out = try normalizeFirstArg(testing.allocator, &args); + // Returned the original slice unchanged — no allocation to free. + try testing.expectEqual(@as(usize, 2), out.len); + try testing.expectEqualStrings("--since", out[0]); + try testing.expectEqualStrings("1W", out[1]); +} + +test "normalizeFirstArg: empty first arg is left untouched" { + const args = [_][]const u8{ "", "AAPL" }; + const out = try normalizeFirstArg(testing.allocator, &args); + try testing.expectEqualStrings("", out[0]); +} diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index 2b06d4f..611c43e 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -1,18 +1,66 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const isCusipLike = @import("../models/portfolio.zig").isCusipLike; -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void { - if (!isCusipLike(cusip)) { - try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip}); +pub const ParsedArgs = struct { + cusip: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "lookup"; + pub const group: framework.Group = .hygiene; + pub const synopsis: []const u8 = "Look up a CUSIP to ticker via OpenFIGI"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin lookup + \\ + \\Look up a CUSIP (9-char alphanumeric security identifier) to + \\its ticker via the OpenFIGI API. Successful results are cached + \\indefinitely in `cusip_tickers.srf`. OPENFIGI_API_KEY raises + \\the rate limit but the unauthenticated tier works for low + \\volume. + \\ + \\Mutual funds frequently have no OpenFIGI coverage; the + \\command surfaces that and suggests a manual portfolio entry. + \\ + \\Examples: + \\ zfin lookup 037833100 # → AAPL + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n"); + return error.MissingCusip; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n"); + return error.UnexpectedArg; + } + return .{ .cusip = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const out = ctx.out; + const color = ctx.color; + const allocator = ctx.allocator; + if (!isCusipLike(parsed.cusip)) { + try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip}); } - try cli.stderrPrint(io, "Looking up via OpenFIGI...\n"); + try cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n"); // Try full batch lookup for richer output - const results = svc.lookupCusips(&.{cusip}) catch { - try cli.stderrPrint(io, "Error: OpenFIGI request failed (network error)\n"); + const results = svc.lookupCusips(&.{parsed.cusip}) catch { + try cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n"); return; }; defer { @@ -25,15 +73,15 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, cus } if (results.len == 0 or !results[0].found) { - try out.print("No result from OpenFIGI for '{s}'\n", .{cusip}); + try out.print("No result from OpenFIGI for '{s}'\n", .{parsed.cusip}); return; } - try display(results[0], cusip, color, out); + try display(results[0], parsed.cusip, color, out); // Also cache it if (results[0].ticker) |ticker| { - svc.cacheCusipTicker(cusip, ticker); + svc.cacheCusipTicker(parsed.cusip, ticker); } } @@ -63,6 +111,28 @@ pub fn display(result: zfin.CusipResult, cusip: []const u8, color: bool, out: *s // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single CUSIP" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"037833100"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("037833100", parsed.cusip); +} + +test "parseArgs: missing CUSIP errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingCusip, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "037833100", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "display shows ticker mapping" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/options.zig b/src/commands/options.zig index c86538e..19efa56 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -1,31 +1,96 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); -pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { - const result = svc.getOptions(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, + /// `--ntm `: show ±N strikes near the money on the auto-expanded + /// expiration. Default 8. + ntm: usize = 8, +}; + +pub const meta = struct { + pub const name: []const u8 = "options"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show options chain (all expirations) for a symbol"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin options [--ntm ] + \\ + \\Show the options chain (all expirations) for a symbol from + \\CBOE. Cached for 1 hour. The nearest monthly expiration is + \\auto-expanded with ±N strikes near the money; other + \\expirations are listed collapsed. + \\ + \\Options: + \\ --ntm Show ±N strikes near the money (default: 8) + \\ + \\Examples: + \\ zfin options SPY + \\ zfin options AAPL --ntm 12 + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n"); + return error.MissingSymbol; + } + var parsed: ParsedArgs = .{ .symbol = cmd_args[0] }; + var i: usize = 1; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; + if (std.mem.eql(u8, a, "--ntm")) { + if (i + 1 >= cmd_args.len) { + try cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n"); + return error.MissingFlagValue; + } + parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch { + try cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n"); + return error.InvalidFlagValue; + }; + i += 1; + } else { + try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': "); + try cli.stderrPrint(ctx.io, a); + try cli.stderrPrint(ctx.io, "\n"); + return error.UnexpectedArg; + } + } + return parsed; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getOptions(parsed.symbol) catch |err| switch (err) { zfin.DataError.FetchFailed => { - try cli.stderrPrint(io, "Error fetching options data from CBOE.\n"); + try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n"); return; }, else => { - try cli.stderrPrint(io, "Error loading options data.\n"); + try cli.stderrPrint(ctx.io, "Error loading options data.\n"); return; }, }; const ch = result.data; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached options data)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached options data)\n"); if (ch.len == 0) { - try cli.stderrPrint(io, "No options data found.\n"); + try cli.stderrPrint(ctx.io, "No options data found.\n"); return; } - try display(out, ch, symbol, ntm, color); + try display(ctx.out, ch, parsed.symbol, parsed.ntm, ctx.color); } pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void { @@ -117,6 +182,51 @@ pub fn printSection( // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a symbol with default ntm" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"SPY"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("SPY", parsed.symbol); + try std.testing.expectEqual(@as(usize, 8), parsed.ntm); +} + +test "parseArgs: --ntm overrides default" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "SPY", "--ntm", "12" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqual(@as(usize, 12), parsed.ntm); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: --ntm without value errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "SPY", "--ntm" }; + try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); +} + +test "parseArgs: --ntm with non-numeric value errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "SPY", "--ntm", "abc" }; + try std.testing.expectError(error.InvalidFlagValue, parseArgs(&ctx, &args)); +} + +test "parseArgs: unknown flag errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "SPY", "--bogus" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "printSection shows header and contracts" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/perf.zig b/src/commands/perf.zig index c85823d..d5a5abd 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -1,30 +1,80 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { - const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "perf"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show 1y/3y/5y/10y trailing returns (Morningstar-style)"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin perf + \\ + \\Show Morningstar-style trailing returns for a symbol — 1Y, + \\3Y, 5Y, 10Y price-only and total-return CAGR plus risk + \\metrics (Sharpe, max drawdown, vol). Total returns require + \\POLYGON_API_KEY (for dividend history); price-only + \\returns work without it. + \\ + \\Two return tables are produced: + \\ - As-of: returns through the latest cached close. + \\ - Month-end: returns through the most recent calendar + \\ month-end (matches how mutual funds quote their stats). + \\ + \\Examples: + \\ zfin perf VTI # total-market index, 30+ years of data + \\ zfin perf NVDA # individual stock + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getTrailingReturns(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); + try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching data.\n"); + try cli.stderrPrint(ctx.io, "Error fetching data.\n"); return; }, }; - defer allocator.free(result.candles); - defer if (result.dividends) |d| zfin.Dividend.freeSlice(allocator, d); + defer ctx.allocator.free(result.candles); + defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached data)\n"); const c = result.candles; const end_date = c[c.len - 1].date; - const month_end = as_of.lastDayOfPriorMonth(); + const month_end = ctx.today.lastDayOfPriorMonth(); - try cli.printBold(out, color, "\nTrailing Returns for {s}\n", .{symbol}); + const out = ctx.out; + const color = ctx.color; + try cli.printBold(out, color, "\nTrailing Returns for {s}\n", .{parsed.symbol}); try out.print("========================================\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print("Data points: {d} (", .{c.len}); @@ -160,6 +210,28 @@ pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bo // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"VTI"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("VTI", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "VTI", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "printReturnsTable price-only with no data" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 5aa1f26..21c3708 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -1,9 +1,41 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; const Money = @import("../Money.zig"); +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "quote"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show latest quote with chart and 20-day history"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin quote + \\ + \\Show the latest real-time quote for a symbol (Yahoo / TwelveData) + \\plus a braille price chart of the last 60 candles and a table + \\of the last 20 trading days. + \\ + \\If real-time fetch fails, falls back to the cached close. The + \\Yahoo path is free and unauthenticated; TwelveData requires + \\TWELVEDATA_API_KEY. + \\ + \\Examples: + \\ zfin quote AAPL + \\ zfin quote spy # symbols are case-insensitive + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + /// Quote data extracted from the real-time API (or synthesized from candles). pub const QuoteData = struct { price: f64, @@ -15,15 +47,28 @@ pub const QuoteData = struct { date: zfin.Date, }; -pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; // Fetch candle data for chart and history - const candle_result = svc.getCandles(symbol) catch |err| switch (err) { + const candle_result = svc.getCandles(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: No API key configured for candle data.\n"); + try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching candle data.\n"); + try cli.stderrPrint(ctx.io, "Error fetching candle data.\n"); return; }, }; @@ -32,7 +77,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym // Fetch real-time quote via DataService var quote: ?QuoteData = null; - if (svc.getQuote(symbol)) |q| { + if (svc.getQuote(parsed.symbol)) |q| { quote = .{ .price = q.close, .open = q.open, @@ -40,11 +85,11 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym .low = q.low, .volume = q.volume, .prev_close = q.previous_close, - .date = if (candles.len > 0) candles[candles.len - 1].date else as_of, + .date = if (candles.len > 0) candles[candles.len - 1].date else ctx.today, }; } else |_| {} - try display(allocator, candles, quote, symbol, as_of, color, out); + try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out); } pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { @@ -122,6 +167,28 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"AAPL"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("AAPL", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "AAPL", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "display with candles only" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/commands/splits.zig b/src/commands/splits.zig index 62558c4..7917afa 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -1,24 +1,65 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const framework = @import("framework.zig"); const fmt = cli.fmt; -pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { - const result = svc.getSplits(symbol) catch |err| switch (err) { +pub const ParsedArgs = struct { + symbol: []const u8, +}; + +pub const meta = struct { + pub const name: []const u8 = "splits"; + pub const group: framework.Group = .symbol_lookup; + pub const synopsis: []const u8 = "Show split history for a symbol"; + pub const uppercase_first_arg: bool = true; + pub const help: []const u8 = + \\Usage: zfin splits + \\ + \\Show the split history for a symbol from Polygon.io. Cached + \\for 14 days; subsequent calls within that window serve the + \\cached data without an API call. + \\ + \\Examples: + \\ zfin splits AAPL # 4:1 (2020-08-31), 7:1 (2014-06-09) + \\ zfin splits NVDA # 10:1 (2024-06-10), 4:1 (2021-07-20), ... + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { + if (cmd_args.len < 1) { + try cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n"); + return error.MissingSymbol; + } + if (cmd_args.len > 1) { + try cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n"); + return error.UnexpectedArg; + } + return .{ .symbol = cmd_args[0] }; +} + +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const svc = ctx.svc orelse return error.MissingDataService; + const result = svc.getSplits(parsed.symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching split data.\n"); + try cli.stderrPrint(ctx.io, "Error fetching split data.\n"); return; }, }; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached split data)\n"); + if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached split data)\n"); - try display(result.data, symbol, color, out); + try display(result.data, parsed.symbol, ctx.color, ctx.out); } pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { @@ -43,6 +84,28 @@ pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: accepts a single symbol" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"AAPL"}; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("AAPL", parsed.symbol); +} + +test "parseArgs: missing symbol errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingSymbol, parseArgs(&ctx, &args)); +} + +test "parseArgs: extra args error" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "AAPL", "extra" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "display shows split data" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/main.zig b/src/main.zig index a95d16b..f9011c2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -370,6 +370,45 @@ fn runCli(init: std.process.Init) !u8 { var svc = zfin.DataService.init(io, allocator, config); defer svc.deinit(); + // ── Framework dispatch ─────────────────────────────────────── + // + // Comptime walk over `command_modules`. Each registered command + // owns its own flag parsing (`parseArgs`) and execution (`run`) + // — both take `*RunCtx`. As more commands migrate the legacy + // if-else chain below shrinks; once empty (commit 17) it goes + // away entirely along with the per-command symbol-uppercasing + // hack. + inline for (std.meta.fields(@TypeOf(command_modules))) |f| { + if (std.mem.eql(u8, command, f.name)) { + const Module = @field(command_modules, f.name); + var ctx: cmd_framework.RunCtx = .{ + .io = io, + .allocator = allocator, + .gpa = gpa_alloc, + .environ_map = init.environ_map, + .config = config, + .svc = &svc, + .globals = .{ + .no_color = globals.no_color, + .portfolio_path = globals.portfolio_path, + .watchlist_path = globals.watchlist_path, + }, + .today = today, + .now_s = now_s, + .color = color, + .out = out, + }; + const dispatched_args = if (comptime cmd_framework.uppercasesFirstArg(Module)) + try cmd_framework.normalizeFirstArg(allocator, cmd_args) + else + cmd_args; + const parsed = Module.parseArgs(&ctx, dispatched_args) catch return 1; + try Module.run(&ctx, parsed); + try out.flush(); + return 0; + } + } + // Normalize symbol argument (cmd_args[0]) to uppercase for commands // that take a symbol. Skip for commands whose first arg is a subcommand // or operand of a different kind. @@ -406,19 +445,7 @@ fn runCli(init: std.process.Init) !u8 { cmd_args = owned; } - if (std.mem.eql(u8, command, "perf")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'perf' requires a symbol argument\n"); - return 1; - } - try commands.perf.run(io, allocator, &svc, cmd_args[0], today, color, out); - } else if (std.mem.eql(u8, command, "quote")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'quote' requires a symbol argument\n"); - return 1; - } - try commands.quote.run(io, allocator, &svc, cmd_args[0], today, color, out); - } else if (std.mem.eql(u8, command, "history")) { + 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 @@ -441,45 +468,6 @@ fn runCli(init: std.process.Init) !u8 { else => return err, }; } - } else if (std.mem.eql(u8, command, "divs")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'divs' requires a symbol argument\n"); - return 1; - } - try commands.divs.run(io, &svc, cmd_args[0], today, color, out); - } else if (std.mem.eql(u8, command, "splits")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'splits' requires a symbol argument\n"); - return 1; - } - try commands.splits.run(io, &svc, cmd_args[0], color, out); - } else if (std.mem.eql(u8, command, "options")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'options' requires a symbol argument\n"); - return 1; - } - // Parse --ntm flag. - var ntm: usize = 8; - var ai: usize = 1; - while (ai < cmd_args.len) : (ai += 1) { - if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) { - ai += 1; - ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8; - } - } - try commands.options.run(io, &svc, cmd_args[0], ntm, color, out); - } else if (std.mem.eql(u8, command, "earnings")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'earnings' requires a symbol argument\n"); - return 1; - } - try commands.earnings.run(io, &svc, cmd_args[0], color, out); - } else if (std.mem.eql(u8, command, "etf")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'etf' requires a symbol argument\n"); - return 1; - } - try commands.etf.run(io, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "portfolio")) { // Parse --refresh flag; reject any other token (including old // positional FILE, which is now a global -p). @@ -498,12 +486,6 @@ fn runCli(init: std.process.Init) !u8 { defer if (wl.resolved) |r| r.deinit(allocator); const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null; try commands.portfolio.run(io, allocator, &svc, pf.path, wl_path, force_refresh, today, color, out); - } else if (std.mem.eql(u8, command, "lookup")) { - if (cmd_args.len < 1) { - try cli.stderrPrint(io, "Error: 'lookup' requires a CUSIP argument\n"); - return 1; - } - try commands.lookup.run(io, allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "cache")) { if (cmd_args.len < 1) { try cli.stderrPrint(io, "Error: 'cache' requires a subcommand (stats, clear)\n"); @@ -816,17 +798,38 @@ fn globalOffender(args: []const []const u8) ?[]const u8 { } // ── Command modules ────────────────────────────────────────── +const cmd_framework = @import("commands/framework.zig"); + +/// Comptime registry of CLI commands that have migrated to the +/// framework contract. The dispatcher in `runCli` walks this with +/// `inline for`; the field name is the user-facing command name +/// and the value is the imported module struct. Each registered +/// module satisfies `framework.validateCommandModule` (enforced at +/// the registry's comptime block below). +/// +/// During the migration window, this registry coexists with the +/// legacy if-else chain in `runCli`. Once every command is +/// registered, the legacy chain goes away (commit 17). +const command_modules = .{ + .divs = @import("commands/divs.zig"), + .earnings = @import("commands/earnings.zig"), + .etf = @import("commands/etf.zig"), + .lookup = @import("commands/lookup.zig"), + .options = @import("commands/options.zig"), + .perf = @import("commands/perf.zig"), + .quote = @import("commands/quote.zig"), + .splits = @import("commands/splits.zig"), +}; + +comptime { + for (std.meta.fields(@TypeOf(command_modules))) |f| { + cmd_framework.validateCommandModule(@field(command_modules, f.name)); + } +} + const commands = struct { - const perf = @import("commands/perf.zig"); - const quote = @import("commands/quote.zig"); const history = @import("commands/history.zig"); - const divs = @import("commands/divs.zig"); - const splits = @import("commands/splits.zig"); - const options = @import("commands/options.zig"); - const earnings = @import("commands/earnings.zig"); - const etf = @import("commands/etf.zig"); const portfolio = @import("commands/portfolio.zig"); - const lookup = @import("commands/lookup.zig"); const cache = @import("commands/cache.zig"); const analysis = @import("commands/analysis.zig"); const audit = @import("commands/audit.zig");