545 lines
23 KiB
Zig
545 lines
23 KiB
Zig
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"),
|
|
.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> [command options]
|
|
\\
|
|
\\Per-command help: zfin <command> --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=<value> 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 <FILE> Portfolio file (default: portfolio.srf;
|
|
\\ cwd → 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> Pre-load a symbol and open on the
|
|
\\ Quote tab. Without this flag, the TUI
|
|
\\ opens on the Portfolio tab.
|
|
\\ --chart <MODE> 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 <SYMBOL> Pre-load a symbol and open on the
|
|
\\ Quote tab. Without this flag, the TUI
|
|
\\ opens on the Portfolio tab.
|
|
\\ --chart <MODE> 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=<value>`)
|
|
\\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 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>`.
|
|
/// 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=<value>` got something other than auto/force/never.
|
|
InvalidRefreshDataValue,
|
|
};
|
|
|
|
/// 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;
|
|
}
|
|
// `--refresh-data=<value>` 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;
|
|
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(
|
|
io: std.Io,
|
|
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(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 };
|
|
}
|
|
|
|
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.
|
|
const globals = parseGlobals(args) catch |err| {
|
|
switch (err) {
|
|
error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"),
|
|
error.UnknownGlobalFlag => {
|
|
try cli.stderrPrint(io, "Error: unknown global flag: ");
|
|
if (globalOffender(args)) |bad| {
|
|
try cli.stderrPrint(io, bad);
|
|
}
|
|
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
|
},
|
|
error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
if (globals.cursor >= args.len) {
|
|
try 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.
|
|
{
|
|
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 {};
|
|
stale_writer.interface.flush() catch {};
|
|
}
|
|
|
|
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();
|
|
try tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today);
|
|
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 <subcommand>`.
|
|
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 <cmd> --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_path = globals.portfolio_path,
|
|
.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;
|
|
}
|
|
}
|
|
|
|
try 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 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). `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());
|
|
}
|