const std = @import("std"); const zfin = @import("root.zig"); const tui = @import("tui.zig"); const cli = @import("commands/common.zig"); const cmd_framework = @import("commands/framework.zig"); /// Comptime registry of CLI commands. Field name is the user-facing /// subcommand name; value is the imported module struct. Order /// follows the canonical group taxonomy in `framework.Group` /// (symbol-lookup → portfolio → time-series → hygiene → infra) so /// `zfin help` reads in workflow order. Adding a new command is one /// edit here (after authoring the module). Validation runs at /// comptime in the block below. const command_modules = .{ // Per-symbol lookups .perf = @import("commands/perf.zig"), .quote = @import("commands/quote.zig"), .history = @import("commands/history.zig"), .divs = @import("commands/divs.zig"), .splits = @import("commands/splits.zig"), .options = @import("commands/options.zig"), .earnings = @import("commands/earnings.zig"), .etf = @import("commands/etf.zig"), // Portfolio analysis .portfolio = @import("commands/portfolio.zig"), .analysis = @import("commands/analysis.zig"), .projections = @import("commands/projections.zig"), .milestones = @import("commands/milestones.zig"), // Time-series & journaling .snapshot = @import("commands/snapshot.zig"), .compare = @import("commands/compare.zig"), .contributions = @import("commands/contributions.zig"), // Data hygiene .audit = @import("commands/audit.zig"), .enrich = @import("commands/enrich.zig"), .import = @import("commands/import.zig"), .lookup = @import("commands/lookup.zig"), // Infrastructure .cache = @import("commands/cache.zig"), .version = @import("commands/version.zig"), }; comptime { for (std.meta.fields(@TypeOf(command_modules))) |f| { cmd_framework.validateCommandModule(@field(command_modules, f.name)); } } const usage_header = \\Usage: zfin [global options] [command options] \\ \\Per-command help: zfin --help \\ ; const usage_footer = \\ \\Other commands: \\ interactive [opts] Launch interactive TUI \\ help / --help Show this message \\ \\Global options (must appear before the subcommand): \\ --no-color Disable colored output \\ --refresh-data= Cache freshness policy (default: auto): \\ auto respect cache TTLs (default) \\ force re-fetch every symbol regardless \\ of TTL freshness \\ never serve cache contents only; \\ no provider calls (offline mode) \\ -p, --portfolio Portfolio file or glob pattern (repeatable; \\ default: portfolio*.srf). Resolved against \\ ZFIN_HOME when set (exclusive — cwd is NOT \\ consulted), else cwd. Quote globs to \\ prevent shell expansion: \\ -p 'portfolio_*.srf' \\ Or repeat the flag for multiple files: \\ -p portfolio.srf -p portfolio_other.srf \\ metadata.srf and accounts.srf are loaded from \\ the same directory as the first resolved \\ portfolio file. \\ -w, --watchlist Watchlist file (default: watchlist.srf) \\ \\Interactive command options: \\ -s, --symbol Pre-load a symbol and open on the \\ Quote tab. Without this flag, the TUI \\ opens on the Portfolio tab. \\ --chart Chart graphics: auto, braille, or WxH \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ FMP_API_KEY Financial Modeling Prep API key (earnings) \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) \\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) \\ ZFIN_HOME User file directory (portfolio, watchlist, .env) \\ NO_COLOR Disable colored output (https://no-color.org) \\ ; /// Help text for `zfin interactive --help` / `zfin i --help`. /// `interactive` is hand-dispatched (not framework-registered) so its /// help isn't auto-derived from a `Meta.help` field. Kept in sync /// with the "Interactive command options:" block in `usage_footer`. const interactive_help = \\Usage: zfin interactive [options] \\Alias: zfin i [options] \\ \\Launch the interactive TUI: a vaxis-rendered, multi-tab terminal \\interface for browsing your portfolio, per-symbol data, options \\chains, earnings, and projections. Press `?` inside the TUI to \\see all keybindings; `q` or Ctrl-C to exit. \\ \\Options: \\ -s, --symbol Pre-load a symbol and open on the \\ Quote tab. Without this flag, the TUI \\ opens on the Portfolio tab. \\ --chart Chart graphics: auto, braille, or WxH \\ (e.g. 80x24); `auto` picks Kitty graphics \\ if the terminal supports it, otherwise \\ braille \\ --default-keys Print default keybindings as a `keybinds.srf` \\ template and exit (no TUI launched). \\ Pipe to `~/.config/zfin/keybinds.srf` to \\ customize. \\ --default-theme Print default theme as a `theme.srf` \\ template and exit (no TUI launched). \\ \\Global flags (`--no-color`, `-p`, `-w`, `--refresh-data=`) \\are honored; see `zfin help` for the full list. \\ ; fn writeUsage(out: *std.Io.Writer) !void { try cmd_framework.printGroupedUsage(out, @TypeOf(command_modules), command_modules, usage_header, usage_footer); } /// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME). const Globals = struct { no_color: bool = false, /// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may /// contain glob metacharacters). Each `-p VALUE` appends one entry; /// resolution and union-merge happens later in `RunCtx`. Empty /// (len == 0) means "use the default pattern". portfolio_patterns: []const []const u8 = &.{}, /// Explicit watchlist path from -w/--watchlist (raw, null if not set). watchlist_path: ?[]const u8 = null, /// Cache freshness policy from `--refresh-data=`. /// Default: `.auto` (TTL-respecting). Other values: `.force` /// (re-fetch regardless of TTL) and `.never` (offline mode). refresh_policy: cmd_framework.RefreshPolicy = .auto, /// Index into args of the first post-global token (the subcommand). cursor: usize, }; const GlobalParseError = error{ MissingValue, UnknownGlobalFlag, /// `--refresh-data=` got something other than auto/force/never. InvalidRefreshDataValue, /// Multiple `.srf` files appeared as a single -p argument, almost /// certainly because the shell expanded an unquoted glob. We /// surface this as a dedicated error so the user gets a friendly /// "quote your glob OR repeat -p" message. UnquotedGlobLikely, OutOfMemory, }; /// Heuristic: returns true if `cursor` in args points at one or more /// contiguous `.srf` files that are NOT prefixed by a `-p`/`--portfolio` /// flag, suggesting the user typed `-p portfolio_*.srf` and the shell /// expanded the glob into space-separated args. /// /// Conservative: only fires when at least one extra `.srf` file /// appears, AND no flags or non-srf tokens are interleaved before /// the next subcommand-shaped token. False positives here would be /// more annoying than the original "unknown command: portfolio_2.srf" /// error, so the bar is high. fn looksLikeUnquotedGlob(args: []const []const u8, cursor: usize) bool { var i = cursor; var srf_count: usize = 0; while (i < args.len) : (i += 1) { const a = args[i]; if (a.len == 0) return false; // A flag-shaped token (starts with -) means we've left the // suspicious run; only "all srf files until the end (or another // -p)" counts as the unquoted-glob shape. if (a[0] == '-') return srf_count > 0; if (std.mem.endsWith(u8, a, ".srf")) { srf_count += 1; continue; } // Non-srf positional token (probably a subcommand). Stop the // run; if we already saw an srf file, that's the glob shape. return srf_count > 0; } return srf_count > 0; } /// 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. /// /// Allocator is used only for the `portfolio_patterns` slice. Caller /// owns the slice on success and must free it with `freeGlobals` (or /// rely on arena cleanup). fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalParseError!Globals { var g: Globals = .{ .cursor = 1 }; var patterns: std.ArrayList([]const u8) = .empty; errdefer patterns.deinit(allocator); 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; try patterns.append(allocator, args[i + 1]); // Detect the unquoted-glob shape: we just consumed `-p VALUE`, // and the next args are more `.srf` files with no flag in // between. That's almost always the shell expanding `-p // portfolio_*.srf` into multiple args. if (looksLikeUnquotedGlob(args, i + 2)) { return error.UnquotedGlobLikely; } 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; } // `--refresh-data=` is a single token: the flag name, // an `=`, and the value (one of auto / force / never). The // single-flag tri-state shape is more honest than the // earlier two-flag (`--refresh` / `--no-refresh`) design // because the user-facing values map 1:1 to the // `RefreshPolicy` enum and impossible-state combinations // are unrepresentable. if (std.mem.startsWith(u8, a, "--refresh-data=")) { const value = a["--refresh-data=".len..]; if (std.mem.eql(u8, value, "auto")) { g.refresh_policy = .auto; } else if (std.mem.eql(u8, value, "force")) { g.refresh_policy = .force; } else if (std.mem.eql(u8, value, "never")) { g.refresh_policy = .never; } else { return error.InvalidRefreshDataValue; } i += 1; continue; } if (std.mem.eql(u8, a, "--refresh-data")) { // Bare `--refresh-data` without `=value` is a user // mistake (probably tried `--refresh-data force` with a // space). Surface the shape mismatch explicitly. return error.MissingValue; } // 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; g.portfolio_patterns = try patterns.toOwnedSlice(allocator); return g; } pub fn main(init: std.process.Init) !u8 { return runCli(init) catch |err| switch (err) { // Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's // file writer surfaces EPIPE as WriteFailed. Treat as a clean exit // — the consumer got what it needed and closed the pipe; further // output isn't an error from our perspective. Matches `ls | head`, // `git log | head`, etc. error.WriteFailed, error.BrokenPipe => 0, else => err, }; } fn runCli(init: std.process.Init) !u8 { // Juicy Main provides two allocators: `init.gpa` (debug-mode leak-checked // heap) and `init.arena` (process-lifetime arena). We use gpa for the // argv copy and long-lived TUI state; per-command work runs under a // fresh ArenaAllocator below. const gpa_alloc = init.gpa; const io = init.io; const args = try init.minimal.args.toSlice(gpa_alloc); defer gpa_alloc.free(args); // Single buffered writer for all stdout output var stdout_buf: [4096]u8 = undefined; var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf); const out: *std.Io.Writer = &stdout_writer.interface; if (args.len < 2) { var sb: [4096]u8 = undefined; var sw = std.Io.File.stderr().writer(io, &sb); try writeUsage(&sw.interface); try sw.interface.flush(); 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 writeUsage(out); try out.flush(); return 0; } // Parse global flags. We allocate the patterns slice up-front // (before the per-command arena exists) using the gpa, since // parseGlobals needs to handle errors before we'd want to spin // up an arena. Freed at the bottom of runCli. const globals = parseGlobals(gpa_alloc, args) catch |err| { switch (err) { error.MissingValue => cli.stderrPrint(io, "Error: global flag is missing its value\n"), error.UnknownGlobalFlag => { cli.stderrPrint(io, "Error: unknown global flag: "); if (globalOffender(args)) |bad| { cli.stderrPrint(io, bad); } cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n"); }, error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data= requires one of: auto, force, never.\n"), error.UnquotedGlobLikely => { cli.stderrPrint(io, \\Error: -p was given a single value followed by additional .srf files. \\This usually means your shell expanded a glob before zfin saw it. \\ \\Try one of: \\ -p 'portfolio_*.srf' (quote to prevent shell expansion) \\ -p portfolio_1.srf -p portfolio_2.srf (repeat the flag) \\ ); }, error.OutOfMemory => return err, } return 1; }; defer gpa_alloc.free(globals.portfolio_patterns); if (globals.cursor >= args.len) { cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n"); return 1; } // Single wall-clock capture for the rest of this invocation. `now_s` // is threaded into commands that record "when did this happen" // (snapshot metadata, audit staleness, rollup header timestamps). // `today` derives from the same read, so every dated computation in // this process sees a consistent date even if the wall clock ticks // over mid-run. // // wall-clock required: the one legitimate Timestamp.now() call in // main dispatch — everything downstream takes now_s / today. const Date = @import("Date.zig"); const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); const today = Date.fromEpoch(now_s); // Nag on stderr when hand-maintained data sources are overdue for // refresh (T-bill rates, Shiller ie_data.csv). See // src/data/staleness.zig for the registry and rules. Runs here — // after globals parse, before command dispatch — so the warning // lands above command output on every CLI and TUI invocation. // // Best-effort: a stderr-write failure here would mean the user // can't even see staleness warnings, but their actual command // should still proceed. Log the secondary error at debug level // so it's visible if anyone goes looking. { const staleness = @import("data/staleness.zig"); var stale_buf: [2048]u8 = undefined; var stale_writer = std.Io.File.stderr().writer(io, &stale_buf); staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| { std.log.debug("staleness check failed: {t}", .{err}); }; stale_writer.interface.flush() catch |err| { std.log.debug("staleness flush failed: {t}", .{err}); }; } const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color); const command = args[globals.cursor]; const cmd_args: []const []const u8 = @ptrCast(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")) { // Per-command --help / -h, mirroring the framework dispatch's // behavior. `interactive` is hand-dispatched (not part of the // framework registry because it uses gpa, not arena, and bypasses // DataService construction), so it needs its own --help check. if (cmd_args.len > 0 and (std.mem.eql(u8, cmd_args[0], "--help") or std.mem.eql(u8, cmd_args[0], "-h"))) { try out.writeAll(interactive_help); try out.flush(); return 0; } var tui_config = zfin.Config.fromEnv(io, gpa_alloc, init.environ_map); defer tui_config.deinit(); try out.flush(); // TUI today is single-portfolio. Pass the first explicit pattern // Multi-portfolio is now wired all the way through to the // TUI: pass the raw `-p` pattern slice and let the TUI's // loader resolve + union-merge the same way the CLI does. // This is the load-bearing fix for "CLI and TUI report // different totals" — there's exactly one code path now. tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) { // tui.run already printed an actionable stderr message // for invalid CLI args; surface as exit 1 without a // panic / stack trace. error.InvalidArgs => return 1, else => return err, }; return 0; } // ── Per-invocation arena ───────────────────────────────────── // // CLI commands do a batch of work then exit. Almost every allocation // they make has the same lifetime (the invocation). An arena matched // to that unit gives us three wins: skip per-allocation bookkeeping, // ignore all the per-object `defer X.deinit()` calls (they become // no-ops but remain correct code if the function is ever called from // a non-arena context), and avoid gpa's leak-checking overhead for // ephemeral state we're about to discard anyway. // // See models/portfolio.zig for the "match the arena to the unit of // work" principle. Here the unit is one `zfin `. var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); var config = zfin.Config.fromEnv(io, allocator, init.environ_map); defer config.deinit(); var svc = zfin.DataService.init(io, allocator, config); defer svc.deinit(); // ── Framework dispatch ─────────────────────────────────────── // // Comptime walk over `command_modules`. Each registered command // owns its own flag parsing (`parseArgs`) and execution (`run`) // — both take `*RunCtx`. Per-command help (`zfin --help`) // intercepts before parseArgs runs. inline for (std.meta.fields(@TypeOf(command_modules))) |f| { if (std.mem.eql(u8, command, f.name)) { const Module = @field(command_modules, f.name); // Per-command --help / -h: print meta.help and exit 0. if (cmd_args.len > 0 and (std.mem.eql(u8, cmd_args[0], "--help") or std.mem.eql(u8, cmd_args[0], "-h"))) { try cmd_framework.printCommandHelp(out, Module); try out.flush(); return 0; } var ctx: cmd_framework.RunCtx = .{ .io = io, .allocator = allocator, .gpa = gpa_alloc, .environ_map = init.environ_map, .config = config, .svc = &svc, .globals = .{ .no_color = globals.no_color, .portfolio_patterns = globals.portfolio_patterns, .watchlist_path = globals.watchlist_path, .refresh_policy = globals.refresh_policy, }, .today = today, .now_s = now_s, .color = color, .out = out, }; const dispatched_args = if (comptime Module.meta.uppercase_first_arg) try cmd_framework.normalizeFirstArg(allocator, cmd_args) else cmd_args; const parsed = Module.parseArgs(&ctx, dispatched_args) catch |err| { // parseArgs errors: if the command declared this // error as user-level, exit 1 silently (the // command already printed a stderr message). // Otherwise propagate so genuine bugs (OOM, etc.) // surface with a stack trace. if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1; return err; }; Module.run(&ctx, parsed) catch |err| { // Same treatment for run errors: user-level errors // become exit 1; everything else propagates. if (cmd_framework.isUserError(Module.meta.user_errors, err)) return 1; return err; }; try out.flush(); return 0; } } cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1; } /// 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.startsWith(u8, a, "--refresh-data=") or std.mem.eql(u8, a, "--refresh-data")) { 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; } // ── Tests ──────────────────────────────────────────────────── test "parseGlobals: no flags, subcommand only" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "portfolio" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 1), g.cursor); try std.testing.expectEqual(false, g.no_color); try std.testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len); try std.testing.expect(g.watchlist_path == null); } test "parseGlobals: --no-color, -p, -w then subcommand" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 6), g.cursor); try std.testing.expectEqual(true, g.no_color); try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len); try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]); try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?); } test "parseGlobals: long forms" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 5), g.cursor); try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len); try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]); try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?); } test "parseGlobals: unknown flag errors" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" }; try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv)); } test "parseGlobals: flag missing value errors" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p" }; try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv)); } test "parseGlobals: --help stops scanning" { const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "--help" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); 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 allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" }; try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv)); } test "parseGlobals: -p repeated builds slice in argument order" { // Multi-portfolio support: each `-p VALUE` appends. Order is // preserved so users can reason about precedence (though Feature // A union-merges the resolved set, the patterns themselves are // ordered). const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "main.srf", "-p", "mom.srf", "portfolio" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 2), g.portfolio_patterns.len); try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]); try std.testing.expectEqualStrings("mom.srf", g.portfolio_patterns[1]); } test "parseGlobals: -p with glob pattern (single value, quoted by shell)" { // The user typed -p 'portfolio_*.srf' and the shell preserved the // quotes; we get a single pattern arg with literal '*' in it. const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "portfolio_*.srf", "portfolio" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len); try std.testing.expectEqualStrings("portfolio_*.srf", g.portfolio_patterns[0]); } test "parseGlobals: unquoted-glob detector fires on multiple .srf args" { // The user typed `-p portfolio_*.srf` without quotes; zsh expanded // the glob into multiple bareword args. We surface a dedicated // error so the user gets a friendly fix-it message. const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "portfolio_1.srf", "portfolio_2.srf", "portfolio" }; try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv)); } test "parseGlobals: unquoted-glob detector ignores legitimate -p + subcommand" { // `-p main.srf snapshot` is fine: one .srf file, then a // recognized-shape subcommand. Detector must NOT fire. const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "main.srf", "snapshot" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len); try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]); } test "parseGlobals: unquoted-glob detector handles trailing args ending the argv" { // `-p a.srf b.srf c.srf` (with no subcommand following). The // looksLikeUnquotedGlob loop hits end-of-argv with srf_count > 0 // and reports the suspicious shape. const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "c.srf" }; try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv)); } test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" { // Just `-p something.srf` then a subcommand — single-srf shape, // no detection. Critical: future maintainers might tighten the // heuristic and accidentally start firing here. const allocator = std.testing.allocator; const argv = [_][]const u8{ "zfin", "-p", "main.srf", "compare" }; const g = try parseGlobals(allocator, &argv); defer allocator.free(g.portfolio_patterns); try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len); } test "looksLikeUnquotedGlob: empty cursor yields false" { const args = [_][]const u8{ "zfin", "-p", "main.srf" }; try std.testing.expect(!looksLikeUnquotedGlob(&args, args.len)); } test "looksLikeUnquotedGlob: stops at flag-shaped token" { // `-p a.srf -p b.srf` — the second -p halts the scan after zero // .srf files in the run, so the detector returns false. const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" }; try std.testing.expect(!looksLikeUnquotedGlob(&args, 3)); } test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" { // `-p a.srf b.srf compare` — a.srf is the consumed -p value, then // b.srf is the suspicious extra. The non-srf "compare" arrives // after we've already counted b.srf, so the detector fires. const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" }; try std.testing.expect(looksLikeUnquotedGlob(&args, 3)); } test "looksLikeUnquotedGlob: empty arg returns false" { const args = [_][]const u8{ "zfin", "-p", "a.srf", "" }; try std.testing.expect(!looksLikeUnquotedGlob(&args, 3)); } // Single test binary: all source is in one module (file imports, no module // boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level // decls, which transitively pulls in every file imported (directly or // indirectly) via a `const x = @import(...)` form. As long as a file is // reachable that way through the import graph, its `test` blocks are // collected by the test runner — no explicit `_ = @import(...)` lines // required here. // // If a new `.zig` file's tests aren't being discovered (test count doesn't // rise after adding a file with tests), the cause is almost always that // the file is only referenced via a *type extraction* like // `const T = @import("foo.zig").T;` — that form pulls in the type but // doesn't sema-touch the file struct, so its tests are skipped. Fix the // importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md // "Test discovery" for the canary procedure. test { std.testing.refAllDecls(@This()); }