add contributions command
This commit is contained in:
parent
58d1d8ea0a
commit
6493a3745b
4 changed files with 1501 additions and 151 deletions
|
|
@ -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;
|
||||
|
|
|
|||
1201
src/commands/contributions.zig
Normal file
1201
src/commands/contributions.zig
Normal file
File diff suppressed because it is too large
Load diff
402
src/main.zig
402
src/main.zig
|
|
@ -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 {
|
||||
|
|
|
|||
27
src/tui.zig
27
src/tui.zig
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue