finish CLI framework refactor: per-command --help, delegate help to modules, cleanup

This commit is contained in:
Emil Lerch 2026-05-18 17:15:47 -07:00
parent a690d55c2b
commit 2f13b16021
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 168 additions and 232 deletions

View file

@ -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);
}

View file

@ -49,149 +49,33 @@ comptime {
}
}
const usage =
const usage_header =
\\Usage: zfin [global options] <command> [command options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> 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 <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> 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 <command> --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 <FILE> 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 <FILE> 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 <FILE> Watchlist file (default: watchlist.srf)
\\
\\Interactive command options:
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> 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 <YYYY-MM-DD> Earliest as_of_date (inclusive)
\\ --until <YYYY-MM-DD> Latest as_of_date (inclusive)
\\ --metric <name> liquid (default), illiquid, or net_worth
\\ --resolution <name> daily, weekly, monthly, or auto (default: auto)
\\ auto: daily ≤90d, weekly ≤730d, else monthly
\\ --limit <N> 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 <N> Manual price staleness threshold (default: 3)
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ --schwab <CSV> 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 <DATE> 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 <DATE> Upper bound. Pair with --since to diff two
\\ commits within a date window.
\\ --commit-before <SPEC> 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 <SPEC> 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 <DATE|N[WMQY]> 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 <DATE|N[WMQY]> 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 <expr> 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 <DATE> 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 <DATE> Override the after-snapshot. Accepts the same
\\ plus `live` for the current portfolio. Defaults
\\ from positional arg 2, else live.
\\ --commit-before <SPEC> 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 <SPEC> 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 <cmd> --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 <FILE> ");
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");
}