diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 019ccc2..ffa180c 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -348,6 +348,54 @@ pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void { } } +/// Print the grouped `zfin help` output. Walks the supplied +/// `Modules` registry (an anonymous-struct literal) and groups +/// commands by `meta.group`, rendering one section per group with +/// the group's display label as the header. +/// +/// `header_text` and `footer_text` are emitted before the first +/// group and after the last respectively — the caller supplies +/// them so main.zig can keep its bespoke "Usage" line, the +/// global-options block, and the env-vars list while letting the +/// command list itself be derived from the registry. +pub fn printGroupedUsage( + out: *std.Io.Writer, + comptime Modules: type, + modules: Modules, + header_text: []const u8, + footer_text: []const u8, +) !void { + try out.writeAll(header_text); + + inline for (std.meta.fields(Group)) |gf| { + const group_value: Group = @enumFromInt(gf.value); + var first_in_group = true; + inline for (std.meta.fields(Modules)) |mf| { + const Module = @field(modules, mf.name); + if (Module.meta.group == group_value) { + if (first_in_group) { + try out.writeAll("\n"); + try out.writeAll(group_value.label()); + try out.writeAll(":\n"); + first_in_group = false; + } + try out.writeAll(" "); + try out.writeAll(mf.name); + // Pad name to 16 cols so synopses align. + if (mf.name.len < 16) { + var pad: usize = 16 - mf.name.len; + while (pad > 0) : (pad -= 1) try out.writeAll(" "); + } + try out.writeAll(" "); + try out.writeAll(Module.meta.synopsis); + try out.writeAll("\n"); + } + } + } + + try out.writeAll(footer_text); +} + // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; @@ -477,3 +525,86 @@ test "normalizeFirstArg: empty first arg is left untouched" { const out = try normalizeFirstArg(testing.allocator, &args); try testing.expectEqualStrings("", out[0]); } + +test "printGroupedUsage: groups in canonical order with headers + synopses" { + const FakeRegistry = struct { + const ProbeA = struct { + pub const ParsedArgs = struct {}; + pub const meta = struct { + pub const name: []const u8 = "alpha"; + pub const group: Group = .symbol_lookup; + pub const synopsis: []const u8 = "alpha synopsis"; + pub const help: []const u8 = "alpha help\n"; + }; + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} + }; + const ProbeB = struct { + pub const ParsedArgs = struct {}; + pub const meta = struct { + pub const name: []const u8 = "beta"; + pub const group: Group = .infra; + pub const synopsis: []const u8 = "beta synopsis"; + pub const help: []const u8 = "beta help\n"; + }; + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} + }; + }; + const reg = .{ + .alpha = FakeRegistry.ProbeA, + .beta = FakeRegistry.ProbeB, + }; + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try printGroupedUsage(&w, @TypeOf(reg), reg, "HEAD\n", "FOOT\n"); + const out = w.buffered(); + + // Header / footer present. + try testing.expect(std.mem.startsWith(u8, out, "HEAD\n")); + try testing.expect(std.mem.endsWith(u8, out, "FOOT\n")); + + // Group labels in canonical order. + const sym_idx = std.mem.indexOf(u8, out, "Per-symbol lookups:") orelse return error.MissingSymbolLookups; + const infra_idx = std.mem.indexOf(u8, out, "Infrastructure:") orelse return error.MissingInfra; + try testing.expect(sym_idx < infra_idx); + + // Each command's name + synopsis present. + try testing.expect(std.mem.indexOf(u8, out, "alpha") != null); + try testing.expect(std.mem.indexOf(u8, out, "alpha synopsis") != null); + try testing.expect(std.mem.indexOf(u8, out, "beta") != null); + try testing.expect(std.mem.indexOf(u8, out, "beta synopsis") != null); +} + +test "printGroupedUsage: omits empty groups" { + const FakeRegistry = struct { + const Probe = struct { + pub const ParsedArgs = struct {}; + pub const meta = struct { + pub const name: []const u8 = "only"; + pub const group: Group = .infra; + pub const synopsis: []const u8 = "only synopsis"; + pub const help: []const u8 = "h\n"; + }; + pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { + return .{}; + } + pub fn run(_: *RunCtx, _: ParsedArgs) !void {} + }; + }; + const reg = .{ .only = FakeRegistry.Probe }; + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try printGroupedUsage(&w, @TypeOf(reg), reg, "", ""); + const out = w.buffered(); + + // The Infrastructure section appears... + try testing.expect(std.mem.indexOf(u8, out, "Infrastructure:") != null); + // ...but Per-symbol lookups (which has no commands in this fake + // registry) does NOT. + try testing.expect(std.mem.indexOf(u8, out, "Per-symbol lookups:") == null); +} diff --git a/src/main.zig b/src/main.zig index 036654a..d519caa 100644 --- a/src/main.zig +++ b/src/main.zig @@ -49,149 +49,33 @@ comptime { } } -const usage = +const usage_header = \\Usage: zfin [global options] [command options] \\ - \\Commands: - \\ interactive [opts] Launch interactive TUI - \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) - \\ quote Show latest quote with chart and history - \\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline - \\ divs Show dividend history - \\ splits Show split history - \\ options Show options chain (all expirations) - \\ earnings Show earnings history and upcoming - \\ etf Show ETF profile (holdings, sectors, expense ratio) - \\ portfolio Load and analyze the portfolio - \\ analysis Show portfolio analysis - \\ contributions [opts] Show money added since last commit (git-based diff) - \\ snapshot [opts] Write a daily portfolio snapshot to history/ - \\ compare [] Compare portfolio against snapshot (one date = vs today) - \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) - \\ lookup Look up CUSIP to ticker via OpenFIGI - \\ audit [opts] Reconcile portfolio against brokerage export - \\ projections [opts] Retirement projections and benchmark comparison - \\ milestones [opts] Show portfolio threshold crossings (e.g. each $1M, doublings) - \\ cache stats Show cache statistics - \\ cache clear Clear all cached data - \\ version [-v] Show zfin version and build info + \\Per-command help: zfin --help + \\ +; + +const usage_footer = + \\ + \\Other commands: + \\ interactive [opts] Launch interactive TUI + \\ help / --help Show this message \\ \\Global options (must appear before the subcommand): \\ --no-color Disable colored output - \\ -p, --portfolio Portfolio file (default: portfolio.srf; cwd then ZFIN_HOME) - \\ metadata.srf and accounts.srf are loaded from the - \\ same directory as the resolved portfolio. + \\ -p, --portfolio Portfolio file (default: portfolio.srf; + \\ cwd → ZFIN_HOME). metadata.srf and + \\ accounts.srf are loaded from the same + \\ directory as the resolved portfolio. \\ -w, --watchlist Watchlist file (default: watchlist.srf) \\ \\Interactive command options: \\ -s, --symbol Initial symbol (default: VTI) - \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) + \\ --chart Chart graphics: auto, braille, or WxH \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ - \\Options command options: - \\ --ntm Show +/- N strikes near the money (default: 8) - \\ - \\Portfolio command options: - \\ --refresh Force refresh (ignore cache, re-fetch all prices) - \\ - \\History command options (portfolio mode; omit SYMBOL): - \\ --since Earliest as_of_date (inclusive) - \\ --until Latest as_of_date (inclusive) - \\ --metric liquid (default), illiquid, or net_worth - \\ --resolution daily, weekly, monthly, or auto (default: auto) - \\ auto: daily ≤90d, weekly ≤730d, else monthly - \\ --limit Max rows in the recent-snapshots table (default: 40) - \\ --rebuild-rollup (Re)write history/rollup.srf and exit - \\ - \\Audit command options: - \\ (no flags) Portfolio hygiene check + auto-reconcile discovered files - \\ --verbose Show full reconciliation output even when clean - \\ --stale-days Manual price staleness threshold (default: 3) - \\ --fidelity Fidelity positions CSV export (download from "All accounts" positions tab) - \\ --schwab Schwab per-account positions CSV export - \\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin) - \\ - \\Analysis & Contributions commands: - \\ Both operate on the globally-specified portfolio. They also read - \\ metadata.srf and accounts.srf from the same directory. - \\ Contributions additionally requires the portfolio file to be tracked - \\ in a git repo; `git` must be on PATH. - \\ - \\Contributions command options: - \\ --since Compare against the portfolio at-or-before DATE - \\ (accepts YYYY-MM-DD or relative like 1M, 3Q, 1Y). - \\ Without --until, the "after" side is HEAD (or - \\ working copy when dirty). Default: HEAD~1..HEAD. - \\ --until Upper bound. Pair with --since to diff two - \\ commits within a date window. - \\ --commit-before Pin the before commit directly. Takes precedence - \\ over --since's date-based resolution. SPEC accepts - \\ YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD, HEAD~N, - \\ or a 7+ hex SHA. Useful when you committed after - \\ your review date and --since 1W lands on the - \\ wrong commit (try --commit-before HEAD). - \\ --commit-after Pin the after commit. Same grammar as - \\ --commit-before, plus `working` / `WORKING` for - \\ the filesystem working copy. Mutually exclusive - \\ with --until. - \\ - \\Projections command options: - \\ --no-events Exclude life events from simulation (baseline view) - \\ --as-of Compute against a historical snapshot instead of - \\ the live portfolio. Accepts YYYY-MM-DD, relative - \\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'. - \\ Auto-snaps to nearest-earlier snapshot if the - \\ exact date has no snapshot file. - \\ --overlay-actuals Plot the realized portfolio trajectory from --as-of - \\ up to today on top of the projected percentile - \\ bands. Requires --as-of. The TUI projections tab - \\ is the higher-fidelity surface (press `o` after - \\ setting an as-of date with `d`). Caveat: this shows - \\ whether the model was directionally honest, NOT - \\ whether the SWR claim was accurate. - \\ --vs Compact side-by-side comparison: projected return - \\ and safe-withdrawal @99% for live vs DATE, with - \\ deltas. Combine with --as-of to compare two - \\ historical dates (--vs = then, --as-of = now). - \\ --convergence Plot the spreadsheet's predicted retirement date - \\ over time as data accumulated. Sources data from - \\ imported_values.srf. Caveat: this evaluates the - \\ model's directional honesty, not its SWR claim. - \\ --return-backtest Plot the spreadsheet's expected_return claim over - \\ time alongside realized 1y/3y/5y forward CAGR. - \\ Sources data from imported_values.srf. Pair with - \\ --real to compare in inflation-adjusted dollars. - \\ - \\Milestones command options: - \\ --step Threshold step. Required. - \\ Absolute: 1M, 1m, 1500000, 1.5M, 500K, 500k - \\ Relative: 2x, 2X, 1.5x (must be > 1.0) - \\ --real Deflate the series to the last full Shiller year - \\ before detecting crossings (CPI-adjusted). - \\ - \\Compare command options: - \\ --projections Include projected return + safe-withdrawal @99% - \\ deltas between the attribution rows and the - \\ per-symbol table. Opt-in because projections cost - \\ ~1-2s per endpoint (Monte Carlo SWR search). - \\ --no-events (with --projections) Exclude life events from the - \\ underlying projection simulation. Matches the - \\ `projections --no-events` flag. - \\ --snapshot-before Override the before-snapshot independently of - \\ the positional date. DATE accepts YYYY-MM-DD or - \\ relative (1W/1M/1Q/1Y). Defaults from positional. - \\ --snapshot-after Override the after-snapshot. Accepts the same - \\ plus `live` for the current portfolio. Defaults - \\ from positional arg 2, else live. - \\ --commit-before Pin the attribution's before commit. Same SPEC - \\ grammar as the contributions flag. When the - \\ positional date's commit-at-or-before lands on - \\ the wrong commit (committed after review day), - \\ pass `HEAD` or an explicit SHA. - \\ --commit-after Pin the attribution's after commit. Accepts - \\ `working` / `WORKING` for the working copy. - \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) @@ -204,6 +88,10 @@ const usage = \\ ; +fn writeUsage(out: *std.Io.Writer) !void { + try cmd_framework.printGroupedUsage(out, @TypeOf(command_modules), command_modules, usage_header, usage_footer); +} + /// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME). const Globals = struct { no_color: bool = false, @@ -309,7 +197,10 @@ fn runCli(init: std.process.Init) !u8 { const out: *std.Io.Writer = &stdout_writer.interface; if (args.len < 2) { - try cli.stderrPrint(io, usage); + var sb: [4096]u8 = undefined; + var sw = std.Io.File.stderr().writer(io, &sb); + try writeUsage(&sw.interface); + try sw.interface.flush(); return 1; } @@ -318,7 +209,7 @@ fn runCli(init: std.process.Init) !u8 { std.mem.eql(u8, args[1], "--help") or std.mem.eql(u8, args[1], "-h")) { - try out.writeAll(usage); + try writeUsage(out); try out.flush(); return 0; } @@ -372,7 +263,7 @@ fn runCli(init: std.process.Init) !u8 { const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color); const command = args[globals.cursor]; - var cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]); + const cmd_args: []const []const u8 = @ptrCast(args[globals.cursor + 1 ..]); // Interactive TUI: long-lived, per-frame allocations benefit from a // real (non-arena) allocator. Runs against `gpa` directly. @@ -410,13 +301,19 @@ fn runCli(init: std.process.Init) !u8 { // // 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. + // — both take `*RunCtx`. Per-command help (`zfin --help`) + // intercepts before parseArgs runs. inline for (std.meta.fields(@TypeOf(command_modules))) |f| { if (std.mem.eql(u8, command, f.name)) { const Module = @field(command_modules, f.name); + + // Per-command --help / -h: print meta.help and exit 0. + if (cmd_args.len > 0 and (std.mem.eql(u8, cmd_args[0], "--help") or std.mem.eql(u8, cmd_args[0], "-h"))) { + try cmd_framework.printCommandHelp(out, Module); + try out.flush(); + return 0; + } + var ctx: cmd_framework.RunCtx = .{ .io = io, .allocator = allocator, @@ -445,75 +342,8 @@ fn runCli(init: std.process.Init) !u8 { } } - // 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. - const symbol_cmd = - !std.mem.eql(u8, command, "cache") and - !std.mem.eql(u8, command, "enrich") and - !std.mem.eql(u8, command, "audit") and - !std.mem.eql(u8, command, "analysis") and - !std.mem.eql(u8, command, "contributions") and - !std.mem.eql(u8, command, "portfolio") and - !std.mem.eql(u8, command, "projections") and - !std.mem.eql(u8, command, "snapshot") and - !std.mem.eql(u8, command, "compare") and - !std.mem.eql(u8, command, "version"); - // Upper-case the first arg for symbol-taking commands, but skip when - // the arg is a flag (starts with '-'). This lets commands like - // `history` have both symbol mode (`zfin history VTI`) and - // flag-driven mode (`zfin history --since 2026-01-01`). - // - // Args returned by `init.minimal.args.toSlice` are `[]const [:0]const u8` - // — we can't mutate the slice. Build an owned mutable copy when the - // symbol upper-cased form differs from the raw arg. - var cmd_args_owned: ?[][]const u8 = null; - defer if (cmd_args_owned) |c| allocator.free(c); - if (symbol_cmd and cmd_args.len >= 1 and - (cmd_args[0].len == 0 or cmd_args[0][0] != '-')) - { - const upper = try allocator.dupe(u8, cmd_args[0]); - 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; - cmd_args_owned = owned; - cmd_args = owned; - } - - if (std.mem.eql(u8, command, "portfolio")) { - // Parse --refresh flag; reject any other token (including old - // positional FILE, which is now a global -p). - } else { - try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); - return 1; - } - - // Single flush for all stdout output - try out.flush(); - return 0; -} - -/// Emit a consistent "unexpected argument" error, with a hint pointing at -/// the global-flag migration. Called when a command finds an arg it doesn't -/// understand (typically a stale positional file path or a misplaced global -/// flag like `--no-color` after the subcommand). -fn reportUnexpectedArg(io: std.Io, command: []const u8, arg: []const u8) !void { - try cli.stderrPrint(io, "Error: unexpected argument to '"); - try cli.stderrPrint(io, command); - try cli.stderrPrint(io, "': "); - try cli.stderrPrint(io, arg); - try cli.stderrPrint(io, "\n"); - if (std.mem.eql(u8, arg, "--no-color") or - std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--portfolio") or - std.mem.eql(u8, arg, "-w") or std.mem.eql(u8, arg, "--watchlist")) - { - try cli.stderrPrint(io, "Hint: global flags must appear before the subcommand.\n"); - } else { - try cli.stderrPrint(io, "Hint: the portfolio file is now a global option; use `zfin -p "); - try cli.stderrPrint(io, command); - try cli.stderrPrint(io, "`.\n"); - } + try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); + return 1; } /// Return the first args[1..] token that looks like a flag we didn't handle. @@ -538,21 +368,6 @@ fn globalOffender(args: []const []const u8) ?[]const u8 { return null; } -const commands = struct { - const history = @import("commands/history.zig"); - const portfolio = @import("commands/portfolio.zig"); - const cache = @import("commands/cache.zig"); - const analysis = @import("commands/analysis.zig"); - const audit = @import("commands/audit.zig"); - const enrich = @import("commands/enrich.zig"); - const contributions = @import("commands/contributions.zig"); - const snapshot = @import("commands/snapshot.zig"); - const compare = @import("commands/compare.zig"); - const version = @import("commands/version.zig"); - const projections = @import("commands/projections.zig"); - const milestones = @import("commands/milestones.zig"); -}; - // ── Tests ──────────────────────────────────────────────────── test "parseGlobals: no flags, subcommand only" { @@ -623,14 +438,4 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" { test { std.testing.refAllDecls(@This()); - // TEMPORARY: force test discovery for command-framework support - // modules. Nothing in main.zig sema-touches them yet, so their - // `test` blocks would otherwise be skipped. Remove these lines - // once `runCli` references the framework directly (via the - // comptime command-registry walk that replaces the legacy - // if-else dispatch chain) — at that point both TimeRange and - // framework.zig are reachable through the registry's command - // imports. - _ = @import("commands/framework.zig"); - _ = @import("commands/TimeRange.zig"); }