add contributions command
All checks were successful
Generic zig build / build (push) Successful in 1m42s
Generic zig build / deploy (push) Successful in 14s

This commit is contained in:
Emil Lerch 2026-04-20 05:17:41 -07:00
parent 58d1d8ea0a
commit 6493a3745b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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
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 schwab_csv: ?[]const u8 = null;
var schwab_summary = false;
var portfolio_path: []const u8 = "portfolio.srf";
var explicit_portfolio = false;
var i: usize = 0;
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];
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
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) {
try cli.stderrPrint(
\\Usage: zfin audit [options] [-p <portfolio.srf>]
\\Usage: zfin [-p <portfolio.srf>] audit [options]
\\
\\ --fidelity <csv> Fidelity positions CSV export
\\ --schwab <csv> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
\\ -p, --portfolio Portfolio file (default: portfolio.srf)
\\
);
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 usage =
\\Usage: zfin <command> [options]
\\Usage: zfin [global options] <command> [command options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
@ -16,43 +16,44 @@ const usage =
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ portfolio Load and analyze the portfolio
\\ 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)
\\ 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
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\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)
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\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)
\\
\\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)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, searches current directory then ZFIN_HOME.
\\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)
@ -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 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
@ -84,182 +160,181 @@ pub fn main() !u8 {
return 1;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
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")) {
// 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;
}
// 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")) {
try out.flush();
try tui.run(allocator, config, args);
try tui.run(allocator, config, globals.portfolio_path, globals.watchlist_path, cmd_args);
return 0;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol.
// Skip normalization for commands where args[2] is a subcommand or file path.
if (args.len >= 3 and
// 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, "portfolio"))
{
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*);
!std.mem.eql(u8, command, "contributions") and
!std.mem.eql(u8, command, "portfolio");
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 (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
return 1;
}
// Parse --ntm flag
// Parse --ntm flag.
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
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, 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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
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")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var explicit_watchlist = false;
// Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p).
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var explicit_file = false;
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")) {
for (cmd_args) |a| {
if (std.mem.eql(u8, a, "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
explicit_file = true;
try reportUnexpectedArg("portfolio", a);
return 1;
}
}
// Resolve default file paths via ZFIN_HOME when not explicitly provided
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (!explicit_file) {
if (config.resolveUserFile(allocator, file_path)) |r| {
resolved_pf = r;
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);
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
defer if (pf.resolved) |r| r.deinit(allocator);
const wl = resolveUserPath(allocator, config, globals.watchlist_path, "watchlist.srf");
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 (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
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")) {
if (args.len < 3) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
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")) {
if (args.len < 3) {
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, args[2], out);
try commands.enrich.run(allocator, &svc, cmd_args[0], out);
} 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")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
var explicit_analysis = false;
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
explicit_analysis = true;
break;
}
for (cmd_args) |a| {
try reportUnexpectedArg("analysis", a);
return 1;
}
var resolved_af: ?zfin.Config.ResolvedPath = null;
defer if (resolved_af) |r| r.deinit(allocator);
if (!explicit_analysis) {
if (config.resolveUserFile(allocator, analysis_file)) |r| {
resolved_af = r;
analysis_file = r.path;
}
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
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;
}
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 {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1;
@ -270,6 +345,50 @@ pub fn main() !u8 {
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");
@ -286,8 +405,61 @@ const commands = struct {
const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.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
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
test {

View file

@ -1991,15 +1991,22 @@ comptime {
}
/// Entry point for the interactive TUI.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void {
var portfolio_path: ?[]const u8 = null;
var watchlist_path: ?[]const u8 = null;
/// `args` contains only command-local tokens (everything after `interactive`).
pub fn run(
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_upper_buf: [32]u8 = undefined;
var has_explicit_symbol = false;
var skip_watchlist = false;
var chart_config: chart_mod.ChartConfig = .{};
var i: usize = 2;
var i: usize = 0;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--default-keys")) {
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")) {
try theme_mod.printDefaults();
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")) {
if (i + 1 < args.len) {
i += 1;