const std = @import("std"); const zfin = @import("root.zig"); const tui = @import("tui.zig"); const cli = @import("commands/common.zig"); const usage = \\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 \\ 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 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 Watchlist file (default: watchlist.srf) \\ \\Interactive command options: \\ -s, --symbol Initial symbol (default: VTI) \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) \\ --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. \\ --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). \\ \\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) \\ 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( 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(allocator, p)) |r| { return .{ .path = r.path, .resolved = r }; } return .{ .path = p, .resolved = null }; } if (config.resolveUserFile(allocator, default_name)) |r| { return .{ .path = r.path, .resolved = r }; } return .{ .path = default_name, .resolved = null }; } pub fn main() !u8 { return runCli() 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() !u8 { // Long-lived allocator for things that span the whole process. Only // actually used for the early argsAlloc and the TUI path — CLI // commands run under a per-invocation arena (see below). var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const gpa_alloc = gpa.allocator(); const args = try std.process.argsAlloc(gpa_alloc); defer std.process.argsFree(gpa_alloc, args); // Single buffered writer for all stdout output var stdout_buf: [4096]u8 = undefined; var stdout_writer = std.fs.File.stdout().writer(&stdout_buf); const out: *std.Io.Writer = &stdout_writer.interface; if (args.len < 2) { try cli.stderrPrint(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("Error: global flag is missing its value\n"), error.UnknownGlobalFlag => { try cli.stderrPrint("Error: unknown global flag: "); if (globalOffender(args)) |bad| { try cli.stderrPrint(bad); } try cli.stderrPrint("\nRun 'zfin help' for usage.\n"); }, } return 1; }; if (globals.cursor >= args.len) { try cli.stderrPrint("Error: missing command.\nRun 'zfin help' for usage.\n"); return 1; } const color = @import("format.zig").shouldUseColor(globals.no_color); const command = args[globals.cursor]; const cmd_args = 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(gpa_alloc); defer tui_config.deinit(); try out.flush(); try tui.run(gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args); 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 `. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); var config = zfin.Config.fromEnv(allocator); defer config.deinit(); // Version: doesn't need DataService; uses build_info + Config paths. if (std.mem.eql(u8, command, "version")) { commands.version.run(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(allocator, config); defer svc.deinit(); // 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`). if (symbol_cmd and cmd_args.len >= 1 and (cmd_args[0].len == 0 or cmd_args[0][0] != '-')) { for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); } if (std.mem.eql(u8, command, "perf")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'perf' requires a symbol argument\n"); return 1; } try commands.perf.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "quote")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'quote' requires a symbol argument\n"); return 1; } try commands.quote.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "history")) { // Two modes in one command: // zfin history → candle history for a symbol (legacy) // zfin history [flags] → portfolio timeline from history/*.srf // // Only portfolio mode needs portfolio.srf; symbol mode must keep // working in directories without a configured portfolio. Dispatch // at this level so that constraint is visible here, not buried // inside the command. const is_symbol_mode = cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-'; if (is_symbol_mode) { commands.history.run(allocator, &svc, "", cmd_args, color, out) catch |err| switch (err) { error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1, else => return err, }; } else { const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); commands.history.run(allocator, &svc, pf.path, cmd_args, 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, "divs")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); return 1; } try commands.divs.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "splits")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); return 1; } try commands.splits.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "options")) { if (cmd_args.len < 1) { try cli.stderrPrint("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(&svc, cmd_args[0], ntm, color, out); } else if (std.mem.eql(u8, command, "earnings")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); return 1; } try commands.earnings.run(&svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "etf")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); return 1; } try commands.etf.run(&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). var force_refresh = false; for (cmd_args) |a| { if (std.mem.eql(u8, a, "--refresh")) { force_refresh = true; } else { try reportUnexpectedArg("portfolio", a); return 1; } } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); const wl = resolveUserPath(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(allocator, &svc, pf.path, wl_path, force_refresh, color, out); } else if (std.mem.eql(u8, command, "lookup")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); return 1; } try commands.lookup.run(allocator, &svc, cmd_args[0], color, out); } else if (std.mem.eql(u8, command, "cache")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n"); return 1; } try commands.cache.run(allocator, config, cmd_args[0], out); } else if (std.mem.eql(u8, command, "enrich")) { if (cmd_args.len < 1) { try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n"); return 1; } try commands.enrich.run(allocator, &svc, cmd_args[0], out); } else if (std.mem.eql(u8, command, "audit")) { const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.audit.run(allocator, &svc, pf.path, cmd_args, color, out); } else if (std.mem.eql(u8, command, "analysis")) { for (cmd_args) |a| { try reportUnexpectedArg("analysis", a); return 1; } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.analysis.run(allocator, &svc, pf.path, 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 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, "--as-of") or std.mem.eql(u8, a, "--vs")) { if (i + 1 >= cmd_args.len) { try cli.stderrPrint("Error: "); try cli.stderrPrint(a); try cli.stderrPrint(" requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); return 1; } const value = cmd_args[i + 1]; const today = cli.fmt.todayDate(); const parsed = cli.parseAsOfDate(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, value, err); try cli.stderrPrint(msg); try cli.stderrPrint("\n"); return 1; }; if (parsed) |d| { if (d.days > today.days) { try cli.stderrPrint("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("projections", a); return 1; } } if (as_of != null and vs_date == null) { // Single-date mode: view that snapshot only. } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); 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. try commands.projections.runCompare(allocator, &svc, pf.path, events_enabled, d, as_of, color, out); } else { try commands.projections.run(allocator, &svc, pf.path, events_enabled, as_of, 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("Error: "); try cli.stderrPrint(a); try cli.stderrPrint(" requires a value (YYYY-MM-DD or N[WMQY]).\n"); return 1; } const value = cmd_args[i + 1]; const today = cli.fmt.todayDate(); const parsed = cli.parseAsOfDate(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, value, err); try cli.stderrPrint(msg); try cli.stderrPrint("\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("Error: "); try cli.stderrPrint(a); try cli.stderrPrint(" 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("Error: "); try cli.stderrPrint(a); try cli.stderrPrint(" 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 today = cli.fmt.todayDate(); const spec = cli.parseCommitSpec(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtCommitSpecError(&buf, value, err); try cli.stderrPrint(msg); try cli.stderrPrint("\n"); return 1; }; if (std.mem.eql(u8, a, "--commit-before")) { if (spec == .working_copy) { try cli.stderrPrint("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("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("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("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("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(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); try commands.contributions.run(allocator, &svc, pf.path, before_final, after_final, color, out); } else if (std.mem.eql(u8, command, "snapshot")) { const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); commands.snapshot.run(allocator, &svc, pf.path, cmd_args, 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(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); commands.compare.run(allocator, &svc, pf.path, cmd_args, 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 { try cli.stderrPrint("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(command: []const u8, arg: []const u8) !void { try cli.stderrPrint("Error: unexpected argument to '"); try cli.stderrPrint(command); try cli.stderrPrint("': "); try cli.stderrPrint(arg); try cli.stderrPrint("\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("Hint: global flags must appear before the subcommand.\n"); } else { try cli.stderrPrint("Hint: the portfolio file is now a global option; use `zfin -p "); try cli.stderrPrint(command); try cli.stderrPrint("`.\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; } // ── Command modules ────────────────────────────────────────── 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"); 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"); }; // ── 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), so refAllDeclsRecursive discovers every test in the tree. // // IMPORTANT: refAllDeclsRecursive only walks files reachable from main.zig's // public decl graph. Files that are only reached indirectly (e.g. a module // re-exported from root.zig as `pub const foo = @import("foo.zig")` where // main.zig imports root.zig via a *non-pub* `const zfin = @import("root.zig")`) // are compiled (because their types are referenced) but their `test` blocks // are NOT collected. Add explicit `_ = @import("path/to/file.zig");` lines // in the test block below for any such orphaned test files. // See AGENTS.md → "Adding tests" for details. test { std.testing.refAllDeclsRecursive(@This()); _ = @import("models/transaction_log.zig"); }