658 lines
27 KiB
Zig
658 lines
27 KiB
Zig
//! 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 <cmd> --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=<auto|never|force>`. `--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");
|
|
|
|
// ── 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 <cmd> --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=<value>` 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`: invalidate cache before reading. Re-fetches every
|
|
/// symbol's data from providers regardless of TTL freshness.
|
|
/// Useful when you suspect cached data is wrong or after rotating
|
|
/// providers.
|
|
/// - `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_path` without depending on main.zig.
|
|
pub 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,
|
|
/// Cache-freshness policy from `--refresh-data=<value>`.
|
|
refresh_policy: RefreshPolicy = .auto,
|
|
};
|
|
|
|
// ── 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,
|
|
|
|
/// Resolve the portfolio path argument (from `-p`/`--portfolio`
|
|
/// or the default `portfolio.srf` filename) through cwd → ZFIN_HOME.
|
|
/// Returns the path the caller should open. Caller must free
|
|
/// `resolved.deinit(allocator)` if non-null. Allocations come
|
|
/// from the arena.
|
|
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
|
|
return resolveUserPath(
|
|
self.io,
|
|
self.allocator,
|
|
self.config,
|
|
self.globals.portfolio_path,
|
|
zfin.Config.default_portfolio_filename,
|
|
);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
};
|
|
|
|
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 <cmd> --help` or `zfin <cmd> -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.expect(g.portfolio_path == null);
|
|
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);
|
|
}
|