migrate cache to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 15:41:27 -07:00
parent 9813d951d9
commit 722ddc0e98
2 changed files with 239 additions and 141 deletions

View file

@ -1,11 +1,43 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig");
const srf = @import("srf"); const srf = @import("srf");
const Store = zfin.cache.Store; const Store = zfin.cache.Store;
const DataType = zfin.cache.DataType; const DataType = zfin.cache.DataType;
pub const Subcommand = enum { stats, clear };
pub const ParsedArgs = struct {
sub: Subcommand,
};
pub const meta = struct {
pub const name: []const u8 = "cache";
pub const group: framework.Group = .infra;
pub const synopsis: []const u8 = "Inspect or clear the local provider-data cache";
pub const help: []const u8 =
\\Usage: zfin cache <stats|clear>
\\
\\Subcommands:
\\ stats List every cached symbol with per-data-type size,
\\ age, and freshness state. Stale entries (past TTL)
\\ are flagged. Includes the cusip_tickers.srf file
\\ if present.
\\ clear Delete every file under the cache directory.
\\ No confirmation; the next provider call will
\\ re-fetch everything.
\\
\\Cache directory is `$ZFIN_CACHE_DIR` if set, else `~/.cache/zfin`.
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
/// Data types to show in the stats table (skip candles_meta and meta internal bookkeeping). /// Data types to show in the stats table (skip candles_meta and meta internal bookkeeping).
const display_types = [_]DataType{ const display_types = [_]DataType{
.candles_daily, .candles_daily,
@ -25,10 +57,45 @@ const display_labels = [_][]const u8{
"etf_profile", "etf_profile",
}; };
pub fn run(io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (std.mem.eql(u8, subcommand, "stats")) { if (cmd_args.len < 1) {
try cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n");
return error.MissingSubcommand;
}
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n");
return error.UnexpectedArg;
}
const sub_str = cmd_args[0];
if (std.mem.eql(u8, sub_str, "stats")) {
return .{ .sub = .stats };
}
if (std.mem.eql(u8, sub_str, "clear")) {
return .{ .sub = .clear };
}
try cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '");
try cli.stderrPrint(ctx.io, sub_str);
try cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n");
return error.UnknownSubcommand;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
switch (parsed.sub) {
.stats => try runStats(ctx),
.clear => try runClear(ctx),
}
}
fn runStats(ctx: *framework.RunCtx) !void {
const io = ctx.io;
const allocator = ctx.allocator;
const config = ctx.config;
const out = ctx.out;
// Capture wall-clock once per invocation so every "X ago" display // Capture wall-clock once per invocation so every "X ago" display
// line in the table is computed against the same reference point. // line in the table is computed against the same reference point.
//
// wall-clock required: human-readable "age" rendering needs the
// current time rather than the per-invocation `today`.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
try out.print("Cache directory: {s}\n\n", .{config.cache_dir}); try out.print("Cache directory: {s}\n\n", .{config.cache_dir});
@ -146,13 +213,12 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, subcom
total_files, total_files,
formatSize(&total_buf, total_size), formatSize(&total_buf, total_size),
}); });
} else if (std.mem.eql(u8, subcommand, "clear")) {
var store = Store.init(io, allocator, config.cache_dir);
try store.clearAll();
try out.writeAll("Cache cleared.\n");
} else {
try cli.stderrPrint(io, "Unknown cache subcommand. Use 'stats' or 'clear'.\n");
} }
fn runClear(ctx: *framework.RunCtx) !void {
var store = Store.init(ctx.io, ctx.allocator, ctx.config.cache_dir);
try store.clearAll();
try ctx.out.writeAll("Cache cleared.\n");
} }
const FileInfo = struct { const FileInfo = struct {
@ -253,6 +319,43 @@ fn formatAge(buf: *[24]u8, before_s: i64, after_s: i64) []const u8 {
// Tests // Tests
test "parseArgs: 'stats' resolves to .stats" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"stats"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(Subcommand.stats, parsed.sub);
}
test "parseArgs: 'clear' resolves to .clear" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"clear"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(Subcommand.clear, parsed.sub);
}
test "parseArgs: missing subcommand errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
try std.testing.expectError(error.MissingSubcommand, parseArgs(&ctx, &args));
}
test "parseArgs: unknown subcommand errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"bogus"};
try std.testing.expectError(error.UnknownSubcommand, parseArgs(&ctx, &args));
}
test "parseArgs: extra args error" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "stats", "extra" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "formatAge: future timestamp renders 'just now'" { test "formatAge: future timestamp renders 'just now'" {
var buf: [24]u8 = undefined; var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("just now", formatAge(&buf, 1_700_000_100, 1_700_000_000)); try std.testing.expectEqualStrings("just now", formatAge(&buf, 1_700_000_100, 1_700_000_000));

View file

@ -2,6 +2,37 @@ const std = @import("std");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const tui = @import("tui.zig"); const tui = @import("tui.zig");
const cli = @import("commands/common.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"),
.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"),
// Data hygiene
.lookup = @import("commands/lookup.zig"),
// Infrastructure
.cache = @import("commands/cache.zig"),
};
comptime {
for (std.meta.fields(@TypeOf(command_modules))) |f| {
cmd_framework.validateCommandModule(@field(command_modules, f.name));
}
}
const usage = const usage =
\\Usage: zfin [global options] <command> [command options] \\Usage: zfin [global options] <command> [command options]
@ -486,12 +517,6 @@ fn runCli(init: std.process.Init) !u8 {
defer if (wl.resolved) |r| r.deinit(allocator); defer if (wl.resolved) |r| r.deinit(allocator);
const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null; const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null;
try commands.portfolio.run(io, allocator, &svc, pf.path, wl_path, force_refresh, today, color, out); try commands.portfolio.run(io, allocator, &svc, pf.path, wl_path, force_refresh, today, color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'cache' requires a subcommand (stats, clear)\n");
return 1;
}
try commands.cache.run(io, allocator, config, cmd_args[0], out);
} else if (std.mem.eql(u8, command, "enrich")) { } else if (std.mem.eql(u8, command, "enrich")) {
if (cmd_args.len < 1) { if (cmd_args.len < 1) {
try cli.stderrPrint(io, "Error: 'enrich' requires a portfolio file path or symbol\n"); try cli.stderrPrint(io, "Error: 'enrich' requires a portfolio file path or symbol\n");
@ -797,36 +822,6 @@ fn globalOffender(args: []const []const u8) ?[]const u8 {
return null; return null;
} }
// Command modules
const cmd_framework = @import("commands/framework.zig");
/// Comptime registry of CLI commands that have migrated to the
/// framework contract. The dispatcher in `runCli` walks this with
/// `inline for`; the field name is the user-facing command name
/// and the value is the imported module struct. Each registered
/// module satisfies `framework.validateCommandModule` (enforced at
/// the registry's comptime block below).
///
/// During the migration window, this registry coexists with the
/// legacy if-else chain in `runCli`. Once every command is
/// registered, the legacy chain goes away (commit 17).
const command_modules = .{
.divs = @import("commands/divs.zig"),
.earnings = @import("commands/earnings.zig"),
.etf = @import("commands/etf.zig"),
.lookup = @import("commands/lookup.zig"),
.options = @import("commands/options.zig"),
.perf = @import("commands/perf.zig"),
.quote = @import("commands/quote.zig"),
.splits = @import("commands/splits.zig"),
};
comptime {
for (std.meta.fields(@TypeOf(command_modules))) |f| {
cmd_framework.validateCommandModule(@field(command_modules, f.name));
}
}
const commands = struct { const commands = struct {
const history = @import("commands/history.zig"); const history = @import("commands/history.zig");
const portfolio = @import("commands/portfolio.zig"); const portfolio = @import("commands/portfolio.zig");