zfin/src/main.zig

920 lines
44 KiB
Zig

const std = @import("std");
const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig");
/// Comptime registry of CLI commands. Field name is the user-facing
/// subcommand name; value is the imported module struct. Order
/// follows the canonical group taxonomy in `framework.Group`
/// (symbol-lookup → portfolio → time-series → hygiene → infra) so
/// `zfin help` reads in workflow order. Adding a new command is one
/// edit here (after authoring the module). Validation runs at
/// comptime in the block below.
const command_modules = .{
// Per-symbol lookups
.perf = @import("commands/perf.zig"),
.quote = @import("commands/quote.zig"),
.divs = @import("commands/divs.zig"),
.splits = @import("commands/splits.zig"),
.options = @import("commands/options.zig"),
.earnings = @import("commands/earnings.zig"),
.etf = @import("commands/etf.zig"),
// Data hygiene
.lookup = @import("commands/lookup.zig"),
// Infrastructure
.cache = @import("commands/cache.zig"),
};
comptime {
for (std.meta.fields(@TypeOf(command_modules))) |f| {
cmd_framework.validateCommandModule(@field(command_modules, f.name));
}
}
const usage =
\\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
\\
\\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.
\\ -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)
\\ --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)
\\ FMP_API_KEY Financial Modeling Prep API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ ZFIN_HOME User file directory (portfolio, watchlist, .env)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
/// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME).
const Globals = struct {
no_color: bool = false,
/// Explicit portfolio path from -p/--portfolio (raw, null if not set).
portfolio_path: ?[]const u8 = null,
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
const GlobalParseError = error{
MissingValue,
UnknownGlobalFlag,
};
/// Parse global flags from args[1..] up to the first non-flag (subcommand)
/// token. Errors if a pre-subcommand token starts with '-' but isn't a
/// recognized global, or if a value-taking flag is missing its value.
fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
var g: Globals = .{ .cursor = 1 };
var i: usize = 1;
while (i < args.len) {
const a = args[i];
if (a.len == 0 or a[0] != '-') break;
if (std.mem.eql(u8, a, "--no-color")) {
g.no_color = true;
i += 1;
continue;
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
if (i + 1 >= args.len) return error.MissingValue;
g.portfolio_path = args[i + 1];
i += 2;
continue;
}
if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) {
if (i + 1 >= args.len) return error.MissingValue;
g.watchlist_path = args[i + 1];
i += 2;
continue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
return error.UnknownGlobalFlag;
}
g.cursor = i;
return g;
}
/// Resolve a portfolio-like path. If `explicit` is non-null the user supplied
/// it explicitly; we still run resolveUserFile to allow bare filenames to
/// resolve through cwd → ZFIN_HOME. If null, use the given default filename
/// and run through resolveUserFile.
fn resolveUserPath(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
explicit: ?[]const u8,
default_name: []const u8,
) struct { path: []const u8, resolved: ?zfin.Config.ResolvedPath } {
if (explicit) |p| {
// Try resolveUserFile so bare names like "foo.srf" fall back to ZFIN_HOME.
if (config.resolveUserFile(io, allocator, p)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = p, .resolved = null };
}
if (config.resolveUserFile(io, allocator, default_name)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = default_name, .resolved = null };
}
pub fn main(init: std.process.Init) !u8 {
return runCli(init) catch |err| switch (err) {
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
// file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
// — the consumer got what it needed and closed the pipe; further
// output isn't an error from our perspective. Matches `ls | head`,
// `git log | head`, etc.
error.WriteFailed, error.BrokenPipe => 0,
else => err,
};
}
fn runCli(init: std.process.Init) !u8 {
// Juicy Main provides two allocators: `init.gpa` (debug-mode leak-checked
// heap) and `init.arena` (process-lifetime arena). We use gpa for the
// argv copy and long-lived TUI state; per-command work runs under a
// fresh ArenaAllocator below.
const gpa_alloc = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(gpa_alloc);
defer gpa_alloc.free(args);
// Single buffered writer for all stdout output
var stdout_buf: [4096]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf);
const out: *std.Io.Writer = &stdout_writer.interface;
if (args.len < 2) {
try cli.stderrPrint(io, usage);
return 1;
}
// Early help handling (before global parsing so `zfin --help` works).
if (std.mem.eql(u8, args[1], "help") or
std.mem.eql(u8, args[1], "--help") or
std.mem.eql(u8, args[1], "-h"))
{
try out.writeAll(usage);
try out.flush();
return 0;
}
// Parse global flags.
const globals = parseGlobals(args) catch |err| {
switch (err) {
error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"),
error.UnknownGlobalFlag => {
try cli.stderrPrint(io, "Error: unknown global flag: ");
if (globalOffender(args)) |bad| {
try cli.stderrPrint(io, bad);
}
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
}
return 1;
};
if (globals.cursor >= args.len) {
try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
return 1;
}
// Single wall-clock capture for the rest of this invocation. `now_s`
// is threaded into commands that record "when did this happen"
// (snapshot metadata, audit staleness, rollup header timestamps).
// `today` derives from the same read, so every dated computation in
// this process sees a consistent date even if the wall clock ticks
// over mid-run.
//
// wall-clock required: the one legitimate Timestamp.now() call in
// main dispatch — everything downstream takes now_s / today.
const Date = @import("Date.zig");
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
const today = Date.fromEpoch(now_s);
// Nag on stderr when hand-maintained data sources are overdue for
// refresh (T-bill rates, Shiller ie_data.csv). See
// src/data/staleness.zig for the registry and rules. Runs here —
// after globals parse, before command dispatch — so the warning
// lands above command output on every CLI and TUI invocation.
{
const staleness = @import("data/staleness.zig");
var stale_buf: [2048]u8 = undefined;
var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
staleness.check(&stale_writer.interface, today, &staleness.entries) catch {};
stale_writer.interface.flush() catch {};
}
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 ..]);
// Interactive TUI: long-lived, per-frame allocations benefit from a
// real (non-arena) allocator. Runs against `gpa` directly.
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
var tui_config = zfin.Config.fromEnv(io, gpa_alloc, init.environ_map);
defer tui_config.deinit();
try out.flush();
try tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today);
return 0;
}
// ── Per-invocation arena ─────────────────────────────────────
//
// CLI commands do a batch of work then exit. Almost every allocation
// they make has the same lifetime (the invocation). An arena matched
// to that unit gives us three wins: skip per-allocation bookkeeping,
// ignore all the per-object `defer X.deinit()` calls (they become
// no-ops but remain correct code if the function is ever called from
// a non-arena context), and avoid gpa's leak-checking overhead for
// ephemeral state we're about to discard anyway.
//
// See models/portfolio.zig for the "match the arena to the unit of
// work" principle. Here the unit is one `zfin <subcommand>`.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var config = zfin.Config.fromEnv(io, allocator, init.environ_map);
defer config.deinit();
// Version: doesn't need DataService; uses build_info + Config paths.
if (std.mem.eql(u8, command, "version")) {
commands.version.run(io, config, cmd_args, out) catch |err| switch (err) {
error.UnexpectedArg => return 1,
else => return err,
};
try out.flush();
return 0;
}
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.
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, "history")) {
// Two modes in one command:
// zfin history <SYMBOL> → 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")) {
// Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p).
var force_refresh = false;
for (cmd_args) |a| {
if (std.mem.eql(u8, a, "--refresh")) {
force_refresh = true;
} else {
try reportUnexpectedArg(io, "portfolio", a);
return 1;
}
}
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
const wl = resolveUserPath(io, allocator, config, globals.watchlist_path, zfin.Config.default_watchlist_filename);
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, "enrich")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'enrich' requires a portfolio file path or symbol\n");
return 1;
}
try commands.enrich.run(io, allocator, &svc, cmd_args[0], today, out);
} else if (std.mem.eql(u8, command, "audit")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.audit.run(io, allocator, &svc, pf.path, cmd_args, today, now_s, color, out);
} else if (std.mem.eql(u8, command, "analysis")) {
for (cmd_args) |a| {
try reportUnexpectedArg(io, "analysis", a);
return 1;
}
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.analysis.run(io, allocator, &svc, pf.path, today, color, out);
} else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true;
var as_of: ?zfin.Date = null;
var vs_date: ?zfin.Date = null;
var overlay_actuals = false;
var convergence = false;
var return_backtest = false;
var real_mode = false;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--no-events")) {
events_enabled = false;
} else if (std.mem.eql(u8, a, "--overlay-actuals")) {
overlay_actuals = true;
} else if (std.mem.eql(u8, a, "--convergence")) {
convergence = true;
} else if (std.mem.eql(u8, a, "--return-backtest")) {
return_backtest = true;
} else if (std.mem.eql(u8, a, "--real")) {
real_mode = true;
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
return 1;
}
const value = cmd_args[i + 1];
const parsed = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, "\n");
return 1;
};
if (parsed) |d| {
if (d.days > today.days) {
try cli.stderrPrint(io, "Error: date is in the future.\n");
return 1;
}
if (std.mem.eql(u8, a, "--as-of")) {
as_of = d;
} else {
vs_date = d;
}
}
// null (= "live") is ignored — leaves flag unset, same
// as not passing the flag at all.
i += 1; // consume the value
} else {
try reportUnexpectedArg(io, "projections", a);
return 1;
}
}
// Mutually-exclusive view flags. Forecast-evaluation flags
// (`--convergence`, `--return-backtest`) replace the default
// bands view entirely; combining them with each other,
// `--vs`, or `--overlay-actuals` is rejected.
if (convergence and return_backtest) {
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
return 1;
}
if ((convergence or return_backtest) and vs_date != null) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
return 1;
}
if ((convergence or return_backtest) and overlay_actuals) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
return 1;
}
if (real_mode and !return_backtest) {
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return 1;
}
if (as_of != null and vs_date == null) {
// Single-date mode: view that snapshot only.
}
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
if (convergence) {
try commands.projections.runConvergence(io, allocator, pf.path, color, out);
} else if (return_backtest) {
try commands.projections.runReturnBacktest(io, allocator, pf.path, real_mode, color, out);
} else if (vs_date) |d| {
// Compare mode. `as_of` (if set) designates the "now"
// side — otherwise now is live. `--vs` alone compares
// live against a historical date; `--vs X --as-of Y`
// compares two historical dates with Y being the later
// one.
if (overlay_actuals) {
try cli.stderrPrint(io, "Note: --overlay-actuals is ignored in --vs compare mode.\n");
}
try commands.projections.runCompare(io, allocator, &svc, pf.path, events_enabled, d, as_of orelse today, as_of != null, color, out);
} else {
try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out);
}
} else if (std.mem.eql(u8, command, "contributions")) {
var since: ?zfin.Date = null;
var until: ?zfin.Date = null;
var before_spec: ?cli.CommitSpec = null;
var after_spec: ?cli.CommitSpec = null;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--since") or std.mem.eql(u8, a, "--until")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD or N[WMQY]).\n");
return 1;
}
const value = cmd_args[i + 1];
const parsed = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, "\n");
return 1;
};
// `parsed == null` means the user typed "live" or an
// empty string — meaningless for --since/--until, which
// require concrete dates.
const resolved = parsed orelse {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n");
return 1;
};
if (std.mem.eql(u8, a, "--since")) {
since = resolved;
} else {
until = resolved;
}
i += 1; // consume the value
} else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " requires a value (working, YYYY-MM-DD, 1W/1M/1Q/1Y, HEAD, HEAD~N, or SHA).\n");
return 1;
}
const value = cmd_args[i + 1];
const spec = cli.parseCommitSpec(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtCommitSpecError(&buf, value, err);
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, "\n");
return 1;
};
if (std.mem.eql(u8, a, "--commit-before")) {
if (spec == .working_copy) {
try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
return 1;
}
before_spec = spec;
} else {
after_spec = spec;
}
i += 1; // consume the value
} else {
try reportUnexpectedArg(io, "contributions", a);
return 1;
}
}
// Conflict detection: --since and --commit-before describe the
// same axis, same for --until and --commit-after. Taking both
// would be ambiguous about which wins.
if (since != null and before_spec != null) {
try cli.stderrPrint(io, "Error: --since and --commit-before both specify the before side. Pick one.\n");
return 1;
}
if (until != null and after_spec != null) {
try cli.stderrPrint(io, "Error: --until and --commit-after both specify the after side. Pick one.\n");
return 1;
}
if (since != null and until != null and since.?.days > until.?.days) {
try cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
return 1;
}
// Resolve to CommitSpec for the command. Date flags become
// `.date_at_or_before`, commit flags pass through.
const before_final: ?cli.CommitSpec = if (before_spec) |s|
s
else if (since) |d|
.{ .date_at_or_before = d }
else
null;
const after_final: ?cli.CommitSpec = if (after_spec) |s|
s
else if (until) |d|
.{ .date_at_or_before = d }
else
null;
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.contributions.run(io, allocator, &svc, pf.path, before_final, after_final, today, color, out);
} else if (std.mem.eql(u8, command, "snapshot")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.snapshot.run(io, allocator, &svc, pf.path, cmd_args, now_s, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
else => return err,
};
} else if (std.mem.eql(u8, command, "compare")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.compare.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
// All user-level validation errors return 1 silently — the
// command already printed a message to stderr.
error.UnexpectedArg,
error.MissingDateArg,
error.InvalidDate,
error.SameDate,
error.SnapshotNotFound,
error.PortfolioLoadFailed,
=> return 1,
else => return err,
};
} else if (std.mem.eql(u8, command, "milestones")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.milestones.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
error.UnexpectedArg,
error.MissingStep,
error.InvalidStep,
error.NoData,
=> return 1,
else => return err,
};
} 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");
}
}
/// Return the first args[1..] token that looks like a flag we didn't handle.
/// Used only to craft an error message; best-effort.
fn globalOffender(args: []const []const u8) ?[]const u8 {
var i: usize = 1;
while (i < args.len) {
const a = args[i];
if (a.len == 0 or a[0] != '-') return null;
if (std.mem.eql(u8, a, "--no-color")) {
i += 1;
continue;
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio") or
std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist"))
{
i += 2;
continue;
}
return a;
}
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" {
const argv = [_][]const u8{ "zfin", "portfolio" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
try std.testing.expectEqual(false, g.no_color);
try std.testing.expect(g.portfolio_path == null);
try std.testing.expect(g.watchlist_path == null);
}
test "parseGlobals: --no-color, -p, -w then subcommand" {
const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 6), g.cursor);
try std.testing.expectEqual(true, g.no_color);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: long forms" {
const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 5), g.cursor);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: unknown flag errors" {
const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
}
test "parseGlobals: flag missing value errors" {
const argv = [_][]const u8{ "zfin", "-p" };
try std.testing.expectError(error.MissingValue, parseGlobals(&argv));
}
test "parseGlobals: --help stops scanning" {
const argv = [_][]const u8{ "zfin", "--help" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
}
test "parseGlobals: subcommand-local flag NOT consumed as global" {
// `--refresh` is a portfolio-command flag; should stop global scanning
// when it appears before the subcommand (even though that's not the
// intended usage, make sure the error is "unknown global").
const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
}
// Single test binary: all source is in one module (file imports, no module
// boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level
// decls, which transitively pulls in every file imported (directly or
// indirectly) via a `const x = @import(...)` form. As long as a file is
// reachable that way through the import graph, its `test` blocks are
// collected by the test runner — no explicit `_ = @import(...)` lines
// required here.
//
// If a new `.zig` file's tests aren't being discovered (test count doesn't
// rise after adding a file with tests), the cause is almost always that
// the file is only referenced via a *type extraction* like
// `const T = @import("foo.zig").T;` — that form pulls in the type but
// doesn't sema-touch the file struct, so its tests are skipped. Fix the
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
// "Test discovery" for the canary procedure.
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");
}