add contributions command

This commit is contained in:
Emil Lerch 2026-04-20 05:17:41 -07:00
parent acd5b20dbe
commit 04ea95f21f
4 changed files with 1501 additions and 151 deletions

View file

@ -1168,12 +1168,10 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
// CLI entry point // CLI entry point
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
var fidelity_csv: ?[]const u8 = null; var fidelity_csv: ?[]const u8 = null;
var schwab_csv: ?[]const u8 = null; var schwab_csv: ?[]const u8 = null;
var schwab_summary = false; var schwab_summary = false;
var portfolio_path: []const u8 = "portfolio.srf";
var explicit_portfolio = false;
var i: usize = 0; var i: usize = 0;
while (i < args.len) : (i += 1) { while (i < args.len) : (i += 1) {
@ -1185,32 +1183,16 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
schwab_csv = args[i]; schwab_csv = args[i];
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) { } else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
schwab_summary = true; schwab_summary = true;
} else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) {
i += 1;
portfolio_path = args[i];
explicit_portfolio = true;
} else if (std.mem.eql(u8, args[i], "--no-color")) {
// handled globally
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (!explicit_portfolio) {
if (svc.config.resolveUserFile(allocator, portfolio_path)) |r| {
resolved_pf = r;
portfolio_path = r.path;
} }
} }
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
try cli.stderrPrint( try cli.stderrPrint(
\\Usage: zfin audit [options] [-p <portfolio.srf>] \\Usage: zfin [-p <portfolio.srf>] audit [options]
\\ \\
\\ --fidelity <csv> Fidelity positions CSV export \\ --fidelity <csv> Fidelity positions CSV export
\\ --schwab <csv> Schwab per-account positions CSV export \\ --schwab <csv> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end) \\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
\\ -p, --portfolio Portfolio file (default: portfolio.srf)
\\ \\
); );
return; return;

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ const tui = @import("tui.zig");
const cli = @import("commands/common.zig"); const cli = @import("commands/common.zig");
const usage = const usage =
\\Usage: zfin <command> [options] \\Usage: zfin [global options] <command> [command options]
\\ \\
\\Commands: \\Commands:
\\ interactive [opts] Launch interactive TUI \\ interactive [opts] Launch interactive TUI
@ -16,43 +16,44 @@ const usage =
\\ options <SYMBOL> Show options chain (all expirations) \\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming \\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio) \\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf) \\ portfolio Load and analyze the portfolio
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) \\ analysis Show portfolio analysis
\\ contributions Show money added since last commit (git-based diff)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export \\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ \\
\\Global options: \\Global options (must appear before the subcommand):
\\ --no-color Disable colored output \\ --no-color Disable colored output
\\ \\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf; cwd then ZFIN_HOME)
\\Interactive mode options: \\ metadata.srf and accounts.srf are loaded from the
\\ -p, --portfolio <FILE> Portfolio file (.srf) \\ same directory as the resolved portfolio.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf) \\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\
\\Interactive command options:
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI) \\ -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 (e.g. 1920x1080)
\\ --default-keys Print default keybindings \\ --default-keys Print default keybindings
\\ --default-theme Print default theme \\ --default-theme Print default theme
\\ \\
\\Options command options: \\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8) \\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\ \\
\\Portfolio command options: \\Portfolio command options:
\\ If no file is given, searches current directory then ZFIN_HOME.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\ \\
\\Audit command options: \\Audit command options:
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab) \\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ --schwab <CSV> Schwab per-account positions CSV export \\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin) \\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
\\ \\
\\Analysis command: \\Analysis & Contributions commands:
\\ Reads metadata.srf (classification) and accounts.srf (tax types) \\ Both operate on the globally-specified portfolio. They also read
\\ from the same directory as the portfolio file. \\ metadata.srf and accounts.srf from the same directory.
\\ If no file is given, searches current directory then ZFIN_HOME. \\ Contributions additionally requires the portfolio file to be tracked
\\ in a git repo; `git` must be on PATH.
\\ \\
\\Environment Variables: \\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
@ -66,6 +67,81 @@ const usage =
\\ \\
; ;
/// 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 { pub fn main() !u8 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); defer _ = gpa.deinit();
@ -84,182 +160,181 @@ pub fn main() !u8 {
return 1; return 1;
} }
// Scan for global --no-color flag // Early help handling (before global parsing so `zfin --help` works).
var no_color_flag = false; if (std.mem.eql(u8, args[1], "help") or
for (args[1..]) |arg| { std.mem.eql(u8, args[1], "--help") or
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true; std.mem.eql(u8, args[1], "-h"))
} {
const color = @import("format.zig").shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try out.writeAll(usage); try out.writeAll(usage);
try out.flush(); try out.flush();
return 0; return 0;
} }
// Interactive TUI -- delegates to the TUI module (owns its own DataService) // 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);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[globals.cursor];
const cmd_args = args[globals.cursor + 1 ..];
// Interactive TUI -- delegates to the TUI module (owns its own DataService).
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) { if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush(); try out.flush();
try tui.run(allocator, config, args); try tui.run(allocator, config, globals.portfolio_path, globals.watchlist_path, cmd_args);
return 0; return 0;
} }
var svc = zfin.DataService.init(allocator, config); var svc = zfin.DataService.init(allocator, config);
defer svc.deinit(); defer svc.deinit();
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol. // Normalize symbol argument (cmd_args[0]) to uppercase for commands
// Skip normalization for commands where args[2] is a subcommand or file path. // that take a symbol. Skip for commands whose first arg is a subcommand
if (args.len >= 3 and // or operand of a different kind.
const symbol_cmd =
!std.mem.eql(u8, command, "cache") and !std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") and !std.mem.eql(u8, command, "enrich") and
!std.mem.eql(u8, command, "audit") and !std.mem.eql(u8, command, "audit") and
!std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "portfolio")) !std.mem.eql(u8, command, "contributions") and
{ !std.mem.eql(u8, command, "portfolio");
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*); if (symbol_cmd and cmd_args.len >= 1) {
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
} }
if (std.mem.eql(u8, command, "perf")) { if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n"); try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
return 1; return 1;
} }
try commands.perf.run(allocator, &svc, args[2], color, out); try commands.perf.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "quote")) { } else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n"); try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
return 1; return 1;
} }
try commands.quote.run(allocator, &svc, args[2], color, out); try commands.quote.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "history")) { } else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'history' requires a symbol argument\n"); try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
return 1; return 1;
} }
try commands.history.run(allocator, &svc, args[2], color, out); try commands.history.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "divs")) { } else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n"); try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
return 1; return 1;
} }
try commands.divs.run(allocator, &svc, args[2], color, out); try commands.divs.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "splits")) { } else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n"); try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
return 1; return 1;
} }
try commands.splits.run(allocator, &svc, args[2], color, out); try commands.splits.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "options")) { } else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'options' requires a symbol argument\n"); try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
return 1; return 1;
} }
// Parse --ntm flag // Parse --ntm flag.
var ntm: usize = 8; var ntm: usize = 8;
var ai: usize = 3; var ai: usize = 1;
while (ai < args.len) : (ai += 1) { while (ai < cmd_args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) { if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) {
ai += 1; ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8;
} }
} }
try commands.options.run(allocator, &svc, args[2], ntm, color, out); try commands.options.run(allocator, &svc, cmd_args[0], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) { } else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n"); try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
return 1; return 1;
} }
try commands.earnings.run(allocator, &svc, args[2], color, out); try commands.earnings.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "etf")) { } else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n"); try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
return 1; return 1;
} }
try commands.etf.run(allocator, &svc, args[2], color, out); try commands.etf.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) { } else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf) // Parse --refresh flag; reject any other token (including old
var watchlist_path: ?[]const u8 = null; // positional FILE, which is now a global -p).
var explicit_watchlist = false;
var force_refresh = false; var force_refresh = false;
var file_path: []const u8 = "portfolio.srf"; for (cmd_args) |a| {
var explicit_file = false; if (std.mem.eql(u8, a, "--refresh")) {
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
explicit_watchlist = true;
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true; force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else { } else {
file_path = args[pi]; try reportUnexpectedArg("portfolio", a);
explicit_file = true; return 1;
} }
} }
// Resolve default file paths via ZFIN_HOME when not explicitly provided const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
var resolved_pf: ?zfin.Config.ResolvedPath = null; defer if (pf.resolved) |r| r.deinit(allocator);
defer if (resolved_pf) |r| r.deinit(allocator); const wl = resolveUserPath(allocator, config, globals.watchlist_path, "watchlist.srf");
if (!explicit_file) { defer if (wl.resolved) |r| r.deinit(allocator);
if (config.resolveUserFile(allocator, file_path)) |r| { const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null;
resolved_pf = r; try commands.portfolio.run(allocator, &svc, pf.path, wl_path, force_refresh, color, out);
file_path = r.path;
}
}
var resolved_wl: ?zfin.Config.ResolvedPath = null;
defer if (resolved_wl) |r| r.deinit(allocator);
if (!explicit_watchlist and watchlist_path == null) {
if (config.resolveUserFile(allocator, "watchlist.srf")) |r| {
resolved_wl = r;
watchlist_path = r.path;
}
}
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) { } else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
return 1; return 1;
} }
try commands.lookup.run(allocator, &svc, args[2], color, out); try commands.lookup.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "cache")) { } else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n"); try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
return 1; return 1;
} }
try commands.cache.run(allocator, config, args[2], out); try commands.cache.run(allocator, config, cmd_args[0], out);
} else if (std.mem.eql(u8, command, "enrich")) { } else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) { if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n"); try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
return 1; return 1;
} }
try commands.enrich.run(allocator, &svc, args[2], out); try commands.enrich.run(allocator, &svc, cmd_args[0], out);
} else if (std.mem.eql(u8, command, "audit")) { } else if (std.mem.eql(u8, command, "audit")) {
try commands.audit.run(allocator, &svc, args[2..], color, out); const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
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")) { } else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf) for (cmd_args) |a| {
var analysis_file: []const u8 = "portfolio.srf"; try reportUnexpectedArg("analysis", a);
var explicit_analysis = false; return 1;
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
explicit_analysis = true;
break;
}
} }
var resolved_af: ?zfin.Config.ResolvedPath = null; const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
defer if (resolved_af) |r| r.deinit(allocator); defer if (pf.resolved) |r| r.deinit(allocator);
if (!explicit_analysis) { try commands.analysis.run(allocator, &svc, pf.path, color, out);
if (config.resolveUserFile(allocator, analysis_file)) |r| { } else if (std.mem.eql(u8, command, "contributions")) {
resolved_af = r; for (cmd_args) |a| {
analysis_file = r.path; try reportUnexpectedArg("contributions", a);
} return 1;
} }
try commands.analysis.run(allocator, &svc, analysis_file, color, out); const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.contributions.run(allocator, &svc, pf.path, color, out);
} else { } else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1; return 1;
@ -270,6 +345,50 @@ pub fn main() !u8 {
return 0; 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 // Command modules
const commands = struct { const commands = struct {
const perf = @import("commands/perf.zig"); const perf = @import("commands/perf.zig");
@ -286,8 +405,61 @@ const commands = struct {
const analysis = @import("commands/analysis.zig"); const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig"); const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig"); const enrich = @import("commands/enrich.zig");
const contributions = @import("commands/contributions.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 // Single test binary: all source is in one module (file imports, no module
// boundaries), so refAllDeclsRecursive discovers every test in the tree. // boundaries), so refAllDeclsRecursive discovers every test in the tree.
test { test {

View file

@ -1991,15 +1991,22 @@ comptime {
} }
/// Entry point for the interactive TUI. /// Entry point for the interactive TUI.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void { /// `args` contains only command-local tokens (everything after `interactive`).
var portfolio_path: ?[]const u8 = null; pub fn run(
var watchlist_path: ?[]const u8 = null; allocator: std.mem.Allocator,
config: zfin.Config,
global_portfolio_path: ?[]const u8,
global_watchlist_path: ?[]const u8,
args: []const []const u8,
) !void {
var portfolio_path: ?[]const u8 = global_portfolio_path;
const watchlist_path: ?[]const u8 = global_watchlist_path;
var symbol: []const u8 = ""; var symbol: []const u8 = "";
var symbol_upper_buf: [32]u8 = undefined; var symbol_upper_buf: [32]u8 = undefined;
var has_explicit_symbol = false; var has_explicit_symbol = false;
var skip_watchlist = false; var skip_watchlist = false;
var chart_config: chart_mod.ChartConfig = .{}; var chart_config: chart_mod.ChartConfig = .{};
var i: usize = 2; var i: usize = 0;
while (i < args.len) : (i += 1) { while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) { if (std.mem.eql(u8, args[i], "--default-keys")) {
try keybinds.printDefaults(); try keybinds.printDefaults();
@ -2007,18 +2014,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
} else if (std.mem.eql(u8, args[i], "--default-theme")) { } else if (std.mem.eql(u8, args[i], "--default-theme")) {
try theme_mod.printDefaults(); try theme_mod.printDefaults();
return; return;
} else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) {
if (i + 1 < args.len) {
i += 1;
portfolio_path = args[i];
}
} else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) {
if (i + 1 < args.len) {
i += 1;
watchlist_path = args[i];
} else {
watchlist_path = "watchlist.srf";
}
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) { } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
if (i + 1 < args.len) { if (i + 1 < args.len) {
i += 1; i += 1;