544 lines
24 KiB
Zig
544 lines
24 KiB
Zig
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> [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 Show money added since last commit (git-based diff)
|
|
\\ snapshot [opts] Write a daily portfolio snapshot to history/
|
|
\\ 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
|
|
\\ 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:
|
|
\\ --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.
|
|
\\
|
|
\\Environment Variables:
|
|
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
|
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
|
|
\\ FINNHUB_API_KEY Finnhub 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 {
|
|
// 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 <subcommand>`.
|
|
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, "snapshot") 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 <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(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(allocator, &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(allocator, &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(allocator, &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(allocator, &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(allocator, &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, "contributions")) {
|
|
for (cmd_args) |a| {
|
|
try reportUnexpectedArg("contributions", 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.contributions.run(allocator, &svc, pf.path, 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 {
|
|
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 <FILE> ");
|
|
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 version = @import("commands/version.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.
|
|
test {
|
|
std.testing.refAllDeclsRecursive(@This());
|
|
}
|