//! CLI command framework - comptime-validated registry for `zfin` subcommands. //! //! The CLI dispatch in `src/main.zig` walks a `command_modules` registry - //! an anonymous struct literal - at comptime to derive the dispatch chain, //! the grouped `zfin help` output, and the per-command `zfin --help` //! handler. This file defines the contract every command module must //! satisfy. Mirrors the TUI tab framework in `src/tui/tab_framework.zig`. //! //! ## Contract (per command module) //! //! ```zig //! pub const ParsedArgs = ...; // struct or tagged union //! //! pub const meta: framework.Meta = .{ //! .name = "perf", //! .group = .symbol_lookup, //! .synopsis = "Show 1y/3y/5y/10y trailing returns", //! .help = "...", // multi-line; rendered by `--help` //! .uppercase_first_arg = true, //! .user_errors = error{ MissingSymbol, UnexpectedArg }, //! }; //! //! pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs; //! pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void; //! ``` //! //! The `validateCommandModule(Module)` walker enforces the contract at //! comptime with copy-paste-ready error messages. It's invoked from a //! single registry walk in `src/main.zig`; individual command files //! do NOT carry their own validation block. See the doc-comment on //! `validateCommandModule` for the rationale. //! //! ## Global-option promotion criterion //! //! A flag belongs as a global iff it is *orthogonal* to every command's //! semantics (purely an I/O / output / cache-policy concern) AND every //! command would honor it identically. Today's globals are `--no-color`, //! `-p`, `-w`, and `--refresh-data=`. `--as-of` does //! NOT pass this test because its meaning varies between view-as-of //! (`projections`) and write-as-of (`snapshot`), and the two-sided //! commands need two endpoints, not one - so it stays per-command. const std = @import("std"); const zfin = @import("../root.zig"); const validator = @import("../comptime_validator.zig"); const chart = @import("../charts/chart.zig"); const term_query = @import("../term_query.zig"); const theme = @import("../tui/theme.zig"); // ── Group taxonomy ──────────────────────────────────────────── /// Workflow-grouping tag used for `zfin help`'s sectioned output. /// Every command's `meta.group` field references one of these. /// Order in this enum is the order sections render in `zfin help`. pub const Group = enum { /// Per-symbol lookups: `perf`, `quote`, `history` (symbol mode), /// `divs`, `splits`, `options`, `earnings`, `etf`. symbol_lookup, /// Portfolio analysis: `portfolio`, `analysis`, `history` /// (portfolio mode), `projections`, `milestones`. portfolio, /// Time-series & journaling: `snapshot`, `compare`, /// `contributions`. timeseries, /// Data hygiene: `audit`, `enrich`, `lookup`. hygiene, /// Infrastructure: `cache`, `version`, `interactive`. infra, /// Display label rendered above each section in `zfin help`. pub fn label(self: Group) []const u8 { return switch (self) { .symbol_lookup => "Per-symbol lookups", .portfolio => "Portfolio analysis", .timeseries => "Time-series & journaling", .hygiene => "Data hygiene", .infra => "Infrastructure", }; } }; // ── Meta ────────────────────────────────────────────────────── /// Per-command metadata. Every command module declares /// `pub const meta: framework.Meta = .{ ... };` with all fields /// populated. The compiler enforces the contract via the field /// types; `validateCommandModule` reduces to a single /// `expectDeclWithType` check on the declaration's type. /// /// Fields are deliberately required (no defaults) so command /// authors think about each one rather than relying on framework /// defaults. In particular, `uppercase_first_arg` deserves an /// explicit decision per command - see field doc-comment. pub const Meta = struct { /// User-facing subcommand name. Must match the field name used /// for this command in the `command_modules` registry literal /// (the dispatcher matches by `f.name`). name: []const u8, /// Workflow-grouping tag for the `zfin help` sectioned output. /// See `Group` for the canonical taxonomy. group: Group, /// One-line description rendered next to the command name in /// `zfin help`. synopsis: []const u8, /// Multi-line help text rendered by `zfin --help`. The /// dispatcher emits this verbatim; include the usage line, /// flag descriptions, and any caveats. Trailing newline is /// added automatically if missing. help: []const u8, /// Whether to uppercase `cmd_args[0]` before calling /// `parseArgs`. Symbol-taking commands (perf, quote, divs, /// splits, options, earnings, etf, history-symbol-mode, /// lookup) set this to `true` so users can type /// `zfin perf aapl` and have it normalized to `AAPL`. /// Non-symbol commands (cache, version, portfolio, ...) set /// it to `false`. /// /// The dispatcher's `normalizeFirstArg` honors a "skip when /// arg starts with `-`" rule, so flag-leading invocations /// like `zfin history --since 2026-01-01` pass through /// unchanged regardless of this setting. /// /// No default - every command author must make an explicit /// choice. This is metadata about command shape, not an /// optional opt-in; the `false` answer is just as load-bearing /// as `true`. uppercase_first_arg: bool, /// Error set listing the command's user-level errors - errors /// where the command has already printed a useful message to /// stderr and the dispatcher should just return exit 1 silently. /// Anything NOT in this set propagates to Zig's panic handler /// with a stack trace, signaling a genuine bug worth investigating. /// /// Examples: /// - `error.MissingSymbol` (parseArgs printed "requires a symbol /// argument") -> user-level. /// - `error.SnapshotNotFound` (run printed "No snapshot at or /// before X") -> user-level. /// - `error.OutOfMemory`, `error.Unexpected*` -> not user-level; /// these should crash visibly so they don't get swallowed. /// /// No default - every command author must enumerate the errors /// their `parseArgs` and `run` deliberately return as user /// signals. Commands with no user-level errors (`version`) /// declare `error{}`. Adding a new `return error.X` to a /// command means you also add `X` here if it's user-level - /// the explicit list IS the contract. user_errors: type, }; /// Returns true if `err` is a member of the comptime-known /// `UserErrors` error set. Used by the framework dispatcher to /// decide whether a `Module.run` error is a user-level signal /// (return exit 1 silently) or an internal bug (propagate to /// Zig's panic handler). /// /// Implemented as a comptime-unrolled equality chain over the /// error set's variants. Cost is one comparison per variant; the /// largest user-error set today is on the order of 10 variants, /// so this is cheap. pub fn isUserError(comptime UserErrors: type, err: anyerror) bool { inline for (@typeInfo(UserErrors).error_set orelse return false) |variant| { if (err == @field(anyerror, variant.name)) return true; } return false; } // ── Refresh policy ──────────────────────────────────────────── /// Cache-freshness policy for the invocation. Set via the global /// `--refresh-data=` flag (default: `.auto`). Threaded into /// `loadPortfolioPrices` and through every multi-symbol command's /// price-loading code. /// /// User-facing flag values match the variant names exactly: /// /// - `auto`: respect cache TTLs. Fresh entries served from cache; /// stale entries trigger a provider refetch (or server sync if /// `ZFIN_SERVER` is configured). The right behavior for almost /// every invocation; the default. /// - `force`: ignore cache TTL and re-validate against providers. /// Candle history is topped up incrementally (new bars appended, /// existing history kept), not wiped. To force a full re-download /// from scratch (e.g. suspected-bad data or a retroactive split /// adjustment), use `cache clear`. /// - `never`: serve whatever's in cache regardless of TTL. No /// provider calls. Useful for offline operation, debugging, and /// reproducible historical analysis. pub const RefreshPolicy = enum { auto, force, never }; // ── Globals ─────────────────────────────────────────────────── /// Parsed global options. Shared between `parseGlobals` (in main.zig) /// and `RunCtx`. Lives here so `command_modules` entries can reach /// fields like `globals.portfolio_patterns` without depending on main.zig. pub const Globals = struct { no_color: bool = false, /// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may /// contain glob metacharacters). Each `-p VALUE` adds one entry. /// Empty means "use the default pattern" (`portfolio*.srf`). /// Resolution and union-merge happens in `RunCtx.resolvePortfolioPaths`. 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=`. refresh_policy: RefreshPolicy = .auto, /// Chart graphics mode from `--chart` (auto / braille / WxH). Default /// is auto: kitty graphics when the terminal supports it, else braille. chart_config: chart.ChartConfig = .{}, }; // ── RunCtx ──────────────────────────────────────────────────── /// Per-invocation context bundle. Every command's `parseArgs` and /// `run` receive this. Commands ignore fields they don't need. /// /// Lifetime: created in `runCli` after globals + arena setup, freed /// implicitly when the invocation ends. `allocator` IS the arena; /// commands should allocate against it freely without tracking /// per-allocation deinits. /// /// Fields here are *invocation context*, not per-command state. /// Per-command state belongs in the command's own module. If you're /// tempted to add a field used by only one command, push back - /// it probably belongs in the command's `ParsedArgs` instead. pub const RunCtx = struct { io: std.Io, /// Per-invocation arena. Use this for almost all allocations. allocator: std.mem.Allocator, /// General-purpose allocator. Reserved for handoff to the TUI /// (which has different lifetime requirements than CLI batch /// commands). Most commands should ignore this. gpa: std.mem.Allocator, /// Process environment. Used by `interactive` to thread into /// the TUI's Config refresh, and by anything that needs to /// inspect env vars at command time. environ_map: *const std.process.Environ.Map, config: zfin.Config, /// Null for commands that don't need provider access (`version`, /// `cache`). All other commands must error gracefully if they /// receive a null here. svc: ?*zfin.DataService, globals: Globals, /// Today's date, captured once at invocation entry. Stable /// across all uses within this invocation per the AGENTS.md /// `today` vs `as_of` rule. today: zfin.Date, /// Wall-clock seconds since epoch, captured once at invocation /// entry. For metadata fields like snapshot `captured_at` and /// rollup `#!created=`. now_s: i64, color: bool, out: *std.Io.Writer, /// Detected inline-graphics capability (kitty) + cell size, captured /// once at invocation entry. Commands consult this together with /// `globals.chart_config` to choose kitty-graphics vs braille output. graphics_caps: term_query.Caps = .{}, /// Theme for all CLI charts - inline terminal (kitty) charts and /// `--export-chart` PNGs - resolved once from `--theme ` (or /// `~/.config/zfin/theme.srf`, else the built-in default). The same /// resolved theme also drives the CLI text palette and the TUI. chart_theme: theme.Theme = theme.default_theme, /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or /// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME. /// Returns the union of all matched files; an empty list if no /// patterns matched anywhere. /// /// Caller MUST `result.deinit()` to release the per-path /// allocations (paths and their containing slice). Allocations /// come from the arena allocator. pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths { return resolvePatterns( self.io, self.allocator, self.config, self.globals.portfolio_patterns, ); } /// Single-path convenience: returns the *first* resolved /// portfolio path. Used by sibling-file derivation /// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`, /// `history/`) - these files always live next to the first /// portfolio file. Returns the default pattern's first match /// when -p is not set; falls through to a literal default if /// nothing matched. /// /// **Do NOT use this for portfolio CONTENT loading.** Portfolio /// content must come from the multi-file glob via /// `cli.loadPortfolio` (live) or /// `portfolio_loader.loadPortfolioFromPathsAtRev` (git /// historical). This singular helper is for choosing ONE /// concrete path to derive sibling files from - never for /// reading lots out of. pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath { var paths = self.resolvePortfolioPaths() catch { return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null }; }; if (paths.inner.paths.len == 0) { paths.deinit(); return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null }; } // Hand off the first inner ResolvedPath; release the rest. // We can't reuse paths.deinit() because that would free // the path we want to keep. const keep = paths.inner.paths[0]; for (paths.inner.paths[1..]) |rp| rp.deinit(self.allocator); self.allocator.free(paths.inner.paths); // The convenience view (`paths.paths`) is allocated separately. self.allocator.free(paths.paths); return .{ .path = keep.path, .resolved = keep }; } /// Same as `resolvePortfolioPath` but for the watchlist file. pub fn resolveWatchlistPath(self: *RunCtx) ResolvedPath { return resolveUserPath( self.io, self.allocator, self.config, self.globals.watchlist_path, zfin.Config.default_watchlist_filename, ); } }; pub const ResolvedPath = struct { path: []const u8, resolved: ?zfin.Config.ResolvedPath, pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void { if (self.resolved) |r| r.deinit(allocator); } }; /// Multi-path resolution result returned by `resolvePortfolioPaths`. /// Wraps a `Config.ResolvedPaths` plus a borrowed slice of the /// `[]const u8` paths for ergonomic iteration. Caller must /// `deinit()` to release everything. pub const ResolvedPaths = struct { inner: zfin.Config.ResolvedPaths, /// Convenience view: the path strings, one per resolved file. /// Slices borrow from `inner.paths[i].path`. Allocated in the /// same allocator as `inner`. paths: []const []const u8, pub fn deinit(self: ResolvedPaths) void { self.inner.allocator.free(self.paths); self.inner.deinit(); } }; /// Resolve portfolio path `patterns` against the config (cwd then /// ZFIN_HOME) and return the union of all matched files. Same /// semantics as `RunCtx.resolvePortfolioPaths` but takes the inputs /// directly so non-CLI callers (the TUI, tests) can use the same /// resolution logic without constructing a `RunCtx`. /// /// `patterns` may be empty, in which case the default /// `Config.default_portfolio_filename` glob is used. /// /// Caller MUST `result.deinit()` to release the per-path /// allocations (paths and their containing slice). pub fn resolvePatterns( io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, patterns: []const []const u8, ) !ResolvedPaths { if (patterns.len == 0) { // Default pattern. const inner = try config.resolveUserFiles(io, allocator, zfin.Config.default_portfolio_filename); const paths = try inner.paths_slice(allocator); return .{ .inner = inner, .paths = paths }; } // Multi-pattern: resolve each, concatenate, de-duplicate by path. // For non-glob patterns that resolve to nothing, we still surface // the literal pattern so the caller can produce a useful "Cannot // read portfolio file: " error. Mirrors the legacy // single-path behavior where an explicit -p VALUE was always // returned, present-on-disk or not. var all_paths: std.ArrayList(zfin.Config.ResolvedPath) = .empty; errdefer { for (all_paths.items) |rp| rp.deinit(allocator); all_paths.deinit(allocator); } for (patterns) |pat| { const part = try config.resolveUserFiles(io, allocator, pat); var consumed: usize = 0; for (part.paths) |rp| { var dup = false; for (all_paths.items) |existing| { if (std.mem.eql(u8, existing.path, rp.path)) { dup = true; break; } } if (dup) { rp.deinit(allocator); } else { try all_paths.append(allocator, rp); } consumed += 1; } // Free only the outer slice; paths were transferred above. allocator.free(part.paths); // Explicit-but-not-found: if `pat` is a literal (not a glob) // and resolved to zero matches, surface it as a literal // ResolvedPath so the caller's "cannot read" error message // names the right file. Globs that match nothing are // dropped silently (the user knows they passed a glob). if (consumed == 0 and !zfin.Config.isGlobPattern(pat)) { // Avoid duplicating if a previous iteration already added // this literal path. var dup = false; for (all_paths.items) |existing| { if (std.mem.eql(u8, existing.path, pat)) { dup = true; break; } } if (!dup) { const path_copy = try allocator.dupe(u8, pat); try all_paths.append(allocator, .{ .path = path_copy, .owned = true }); } } } // Mixed-directory check: sibling files (accounts.srf, metadata.srf, // transaction_log.srf) are derived from the *first* portfolio file's // directory. If patterns resolved to files spanning more than one // directory, sibling-file lookup would silently use only the first // directory's siblings - confusing and almost certainly not what // the user wants. Error out and tell the user to consolidate. // // The errdefer above handles cleanup; we just need to surface the // error and let it run. (Don't free in-line - that would be a // double-free.) if (all_paths.items.len > 1) { const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse "."; for (all_paths.items[1..]) |rp| { const this_dir = std.fs.path.dirnamePosix(rp.path) orelse "."; if (!std.mem.eql(u8, first_dir, this_dir)) { return error.MixedPortfolioDirs; } } } const owned = try all_paths.toOwnedSlice(allocator); const inner: zfin.Config.ResolvedPaths = .{ .paths = owned, .allocator = allocator }; const path_view = try inner.paths_slice(allocator); return .{ .inner = inner, .paths = path_view }; } fn resolveUserPath( io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, explicit: ?[]const u8, default_name: []const u8, ) ResolvedPath { if (explicit) |p| { if (config.resolveUserFile(io, allocator, p)) |r| { return .{ .path = r.path, .resolved = r }; } return .{ .path = p, .resolved = null }; } if (config.resolveUserFile(io, allocator, default_name)) |r| { return .{ .path = r.path, .resolved = r }; } return .{ .path = default_name, .resolved = null }; } // ── Comptime contract validation ────────────────────────────── /// Validate a command module against the framework contract. Emits /// a `@compileError` with the full expected signature for any /// missing or wrong-shape decl. /// /// **Call site policy: registry-walk only.** This function should /// be invoked exactly once per command, from the `command_modules` /// registry walker in `src/main.zig`. Do NOT add in-file /// `comptime { framework.validateCommandModule(@This()); }` blocks /// to individual command files - they're redundant with the /// registry walk under both `zig build` and ZLS build-on-save (the /// only ZLS mode that evaluates comptime; ZLS's own semantic /// analyzer doesn't run comptime reliably). The registry walk also /// produces a better diagnostic, identifying the offending command /// by its registry name (`commands.cache`) rather than just its /// module identity. The TUI tab framework follows the same policy /// in `src/tui.zig`. pub fn validateCommandModule(comptime Module: type) void { comptime { const mod_name = @typeName(Module); // ── Top-level decls ──────────────────────────────────── if (!@hasDecl(Module, "ParsedArgs")) { @compileError("Command module `" ++ mod_name ++ "` is missing `pub const ParsedArgs = ...;` " ++ "(use `pub const ParsedArgs = struct {};` if the command takes no arguments)"); } const ParsedArgs = Module.ParsedArgs; // ── meta ─────────────────────────────────────────────── // One check covers all five fields. The compiler enforces // field types via `Meta`'s declaration; missing fields // surface as Zig's native "missing field" comptime errors // pointing at the call site. validator.expectDeclWithType( "Command module", mod_name, Module, "meta", Meta, "pub const meta: framework.Meta = .{ .name = \"...\", .group = ..., .synopsis = \"...\", .help = \"...\", .uppercase_first_arg = ... };", ); // ── parseArgs ────────────────────────────────────────── validator.expectFnInferredError( "Command module", mod_name, Module, "parseArgs", &.{ *RunCtx, []const []const u8 }, ParsedArgs, "pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs", ); // ── run ──────────────────────────────────────────────── validator.expectFnInferredError( "Command module", mod_name, Module, "run", &.{ *RunCtx, ParsedArgs }, void, "pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void", ); } } // ── Help rendering ──────────────────────────────────────────── /// If `cmd_args[0]` exists, is non-empty, and isn't a flag, return /// a copy with the first byte-string uppercased. Otherwise return /// `cmd_args` unchanged. The new slice is owned by `allocator` /// (typically `RunCtx.allocator`, the per-invocation arena). /// /// Mirrors the existing pre-dispatch normalization in /// `main.zig:runCli` so migrated commands behave identically to /// the legacy if-else chain. pub fn normalizeFirstArg( allocator: std.mem.Allocator, cmd_args: []const []const u8, ) ![]const []const u8 { if (cmd_args.len == 0) return cmd_args; const first = cmd_args[0]; if (first.len == 0 or first[0] == '-') return cmd_args; const upper = try allocator.dupe(u8, first); for (upper) |*c| c.* = std.ascii.toUpper(c.*); const owned = try allocator.alloc([]const u8, cmd_args.len); owned[0] = upper; for (cmd_args[1..], 1..) |a, i| owned[i] = a; return owned; } /// Print a single command's help text. Called by main.zig when the /// user invokes `zfin --help` or `zfin -h`. The help /// text comes from the module's `meta.help` field verbatim - no /// post-processing, so multi-paragraph caveats render exactly as /// authored. pub fn printCommandHelp(out: *std.Io.Writer, comptime Module: type) !void { try out.writeAll(Module.meta.help); if (Module.meta.help.len == 0 or Module.meta.help[Module.meta.help.len - 1] != '\n') { try out.writeAll("\n"); } } /// Print the grouped `zfin help` output. Walks the supplied /// `Modules` registry (an anonymous-struct literal) and groups /// commands by `meta.group`, rendering one section per group with /// the group's display label as the header. /// /// `header_text` and `footer_text` are emitted before the first /// group and after the last respectively - the caller supplies /// them so main.zig can keep its bespoke "Usage" line, the /// global-options block, and the env-vars list while letting the /// command list itself be derived from the registry. pub fn printGroupedUsage( out: *std.Io.Writer, comptime Modules: type, modules: Modules, header_text: []const u8, footer_text: []const u8, ) !void { try out.writeAll(header_text); inline for (std.meta.fields(Group)) |gf| { const group_value: Group = @enumFromInt(gf.value); var first_in_group = true; inline for (std.meta.fields(Modules)) |mf| { const Module = @field(modules, mf.name); if (Module.meta.group == group_value) { if (first_in_group) { try out.writeAll("\n"); try out.writeAll(group_value.label()); try out.writeAll(":\n"); first_in_group = false; } try out.writeAll(" "); try out.writeAll(mf.name); // Pad name to 16 cols so synopses align. if (mf.name.len < 16) { var pad: usize = 16 - mf.name.len; while (pad > 0) : (pad -= 1) try out.writeAll(" "); } try out.writeAll(" "); try out.writeAll(Module.meta.synopsis); try out.writeAll("\n"); } } } try out.writeAll(footer_text); } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; // Probe module: a minimal stub satisfying the command contract. // Used by the validator's happy-path test below. const ProbeModule = struct { pub const ParsedArgs = struct {}; pub const meta: Meta = .{ .name = "probe", .group = .infra, .synopsis = "test probe", .help = "Usage: zfin probe\n", .uppercase_first_arg = false, .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; } pub fn run(_: *RunCtx, _: ParsedArgs) !void {} }; test "validateCommandModule: probe module passes" { comptime validateCommandModule(ProbeModule); } test "Group.label: every variant has a non-empty label" { inline for (std.meta.fields(Group)) |f| { const g: Group = @enumFromInt(f.value); try testing.expect(g.label().len > 0); } } test "RefreshPolicy: auto is the default variant" { const policy: RefreshPolicy = .auto; try testing.expectEqual(RefreshPolicy.auto, policy); } test "Globals: zero-init defaults" { const g: Globals = .{}; try testing.expect(!g.no_color); try testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len); try testing.expect(g.watchlist_path == null); try testing.expectEqual(RefreshPolicy.auto, g.refresh_policy); } test "printCommandHelp: writes meta.help and ensures trailing newline" { var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printCommandHelp(&w, ProbeModule); try testing.expectEqualStrings("Usage: zfin probe\n", w.buffered()); } test "printCommandHelp: appends newline when meta.help lacks one" { const NoNewline = struct { pub const ParsedArgs = struct {}; pub const meta: Meta = .{ .name = "x", .group = .infra, .synopsis = "x", .help = "no trailing newline", .uppercase_first_arg = false, .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; } pub fn run(_: *RunCtx, _: ParsedArgs) !void {} }; var buf: [64]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printCommandHelp(&w, NoNewline); try testing.expectEqualStrings("no trailing newline\n", w.buffered()); } test "normalizeFirstArg: empty args returns slice unchanged" { const empty: []const []const u8 = &.{}; const out = try normalizeFirstArg(testing.allocator, empty); try testing.expectEqual(@as(usize, 0), out.len); } test "normalizeFirstArg: lowercase symbol becomes uppercase" { const args = [_][]const u8{ "aapl", "extra" }; const out = try normalizeFirstArg(testing.allocator, &args); // Save out[0] before freeing the slice - once `out` is freed, // indexing it is use-after-free. const upper = out[0]; defer testing.allocator.free(out); defer testing.allocator.free(upper); try testing.expectEqualStrings("AAPL", out[0]); try testing.expectEqualStrings("extra", out[1]); } test "normalizeFirstArg: leading flag is left untouched" { const args = [_][]const u8{ "--since", "1W" }; const out = try normalizeFirstArg(testing.allocator, &args); // Returned the original slice unchanged - no allocation to free. try testing.expectEqual(@as(usize, 2), out.len); try testing.expectEqualStrings("--since", out[0]); try testing.expectEqualStrings("1W", out[1]); } test "normalizeFirstArg: empty first arg is left untouched" { const args = [_][]const u8{ "", "AAPL" }; const out = try normalizeFirstArg(testing.allocator, &args); try testing.expectEqualStrings("", out[0]); } test "printGroupedUsage: groups in canonical order with headers + synopses" { const FakeRegistry = struct { const ProbeA = struct { pub const ParsedArgs = struct {}; pub const meta: Meta = .{ .name = "alpha", .group = .symbol_lookup, .synopsis = "alpha synopsis", .help = "alpha help\n", .uppercase_first_arg = false, .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; } pub fn run(_: *RunCtx, _: ParsedArgs) !void {} }; const ProbeB = struct { pub const ParsedArgs = struct {}; pub const meta: Meta = .{ .name = "beta", .group = .infra, .synopsis = "beta synopsis", .help = "beta help\n", .uppercase_first_arg = false, .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; } pub fn run(_: *RunCtx, _: ParsedArgs) !void {} }; }; const reg = .{ .alpha = FakeRegistry.ProbeA, .beta = FakeRegistry.ProbeB, }; var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printGroupedUsage(&w, @TypeOf(reg), reg, "HEAD\n", "FOOT\n"); const out = w.buffered(); // Header / footer present. try testing.expect(std.mem.startsWith(u8, out, "HEAD\n")); try testing.expect(std.mem.endsWith(u8, out, "FOOT\n")); // Group labels in canonical order. const sym_idx = std.mem.indexOf(u8, out, "Per-symbol lookups:") orelse return error.MissingSymbolLookups; const infra_idx = std.mem.indexOf(u8, out, "Infrastructure:") orelse return error.MissingInfra; try testing.expect(sym_idx < infra_idx); // Each command's name + synopsis present. try testing.expect(std.mem.indexOf(u8, out, "alpha") != null); try testing.expect(std.mem.indexOf(u8, out, "alpha synopsis") != null); try testing.expect(std.mem.indexOf(u8, out, "beta") != null); try testing.expect(std.mem.indexOf(u8, out, "beta synopsis") != null); } test "printGroupedUsage: omits empty groups" { const FakeRegistry = struct { const Probe = struct { pub const ParsedArgs = struct {}; pub const meta: Meta = .{ .name = "only", .group = .infra, .synopsis = "only synopsis", .help = "h\n", .uppercase_first_arg = false, .user_errors = error{}, }; pub fn parseArgs(_: *RunCtx, _: []const []const u8) !ParsedArgs { return .{}; } pub fn run(_: *RunCtx, _: ParsedArgs) !void {} }; }; const reg = .{ .only = FakeRegistry.Probe }; var buf: [512]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try printGroupedUsage(&w, @TypeOf(reg), reg, "", ""); const out = w.buffered(); // The Infrastructure section appears... try testing.expect(std.mem.indexOf(u8, out, "Infrastructure:") != null); // ...but Per-symbol lookups (which has no commands in this fake // registry) does NOT. try testing.expect(std.mem.indexOf(u8, out, "Per-symbol lookups:") == null); } // ── resolvePortfolioPaths tests ─────────────────────────────── test "resolvePatterns: empty patterns falls back to default glob" { // With no zfin_home configured, resolveUserFiles for the default // pattern returns 0 matches in a clean tmp dir. The Impl returns // an empty resolved list (not an error) so callers can produce // "no portfolio file found" themselves. const config: zfin.Config = .{ .cache_dir = "/tmp" }; const empty: []const []const u8 = &.{}; var result = try resolvePatterns(std.testing.io, testing.allocator, config, empty); defer result.deinit(); // 0 or 1 match depending on whether the test runner's cwd has a // portfolio*.srf file. We just check it doesn't crash and the // shape is consistent. try testing.expectEqual(result.inner.paths.len, result.paths.len); } test "resolvePatterns: literal not-found is preserved as a literal" { // The legacy single-path API returned an explicit -p value even // when it didn't exist on disk, so the caller could produce a // "Cannot read: " error naming the right file. We mirror // that for the multi-path API. const config: zfin.Config = .{ .cache_dir = "/tmp" }; const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"}; var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns); defer result.deinit(); try testing.expectEqual(@as(usize, 1), result.paths.len); try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]); } test "resolvePatterns: glob with no matches resolves to empty" { // Globs that match nothing are dropped silently - the user // typed a glob, they know it might match zero files. const config: zfin.Config = .{ .cache_dir = "/tmp" }; const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"}; var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns); defer result.deinit(); try testing.expectEqual(@as(usize, 0), result.paths.len); } test "resolvePatterns: two patterns matching same dir union-merge" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_main.srf", .data = "x" }); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_other.srf", .data = "x" }); var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); const dir_path = dir_path_buf[0..dir_path_len]; const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" }); defer testing.allocator.free(main_path); const other_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_other.srf" }); defer testing.allocator.free(other_path); const patterns = [_][]const u8{ main_path, other_path }; const config: zfin.Config = .{ .cache_dir = "/tmp" }; var result = try resolvePatterns(io, testing.allocator, config, &patterns); defer result.deinit(); try testing.expectEqual(@as(usize, 2), result.paths.len); } test "resolvePatterns: duplicate pattern de-dups" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "x" }); var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); const dir_path = dir_path_buf[0..dir_path_len]; const p = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio.srf" }); defer testing.allocator.free(p); const patterns = [_][]const u8{ p, p }; const config: zfin.Config = .{ .cache_dir = "/tmp" }; var result = try resolvePatterns(io, testing.allocator, config, &patterns); defer result.deinit(); // Same path passed twice -> 1 entry. try testing.expectEqual(@as(usize, 1), result.paths.len); } test "resolvePatterns: mixed directories error" { const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); defer tmp_a.cleanup(); var tmp_b = std.testing.tmpDir(.{}); defer tmp_b.cleanup(); try tmp_a.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" }); try tmp_b.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" }); var dir_a_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_a_len = try tmp_a.dir.realPath(io, &dir_a_buf); var dir_b_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_b_len = try tmp_b.dir.realPath(io, &dir_b_buf); const path_a = try std.fs.path.join(testing.allocator, &.{ dir_a_buf[0..dir_a_len], "portfolio_a.srf" }); defer testing.allocator.free(path_a); const path_b = try std.fs.path.join(testing.allocator, &.{ dir_b_buf[0..dir_b_len], "portfolio_b.srf" }); defer testing.allocator.free(path_b); const patterns = [_][]const u8{ path_a, path_b }; const config: zfin.Config = .{ .cache_dir = "/tmp" }; try testing.expectError(error.MixedPortfolioDirs, resolvePatterns(io, testing.allocator, config, &patterns)); } test "resolvePatterns: same dir different patterns OK (no mixed-dir error)" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" }); try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" }); var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); const dir_path = dir_path_buf[0..dir_path_len]; const path_a = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_a.srf" }); defer testing.allocator.free(path_a); const path_b = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_b.srf" }); defer testing.allocator.free(path_b); const patterns = [_][]const u8{ path_a, path_b }; const config: zfin.Config = .{ .cache_dir = "/tmp" }; var result = try resolvePatterns(io, testing.allocator, config, &patterns); defer result.deinit(); try testing.expectEqual(@as(usize, 2), result.paths.len); }