migrate cache to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 15:41:27 -07:00
parent 2dcae073b0
commit 6d9c864a40
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 239 additions and 141 deletions

View file

@ -1,11 +1,43 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const srf = @import("srf");
const Store = zfin.cache.Store;
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).
const display_types = [_]DataType{
.candles_daily,
@ -25,134 +57,168 @@ const display_labels = [_][]const u8{
"etf_profile",
};
pub fn run(io: std.Io, allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void {
if (std.mem.eql(u8, subcommand, "stats")) {
// Capture wall-clock once per invocation so every "X ago" display
// line in the table is computed against the same reference point.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
try out.print("Cache directory: {s}\n\n", .{config.cache_dir});
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
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;
}
var dir = std.Io.Dir.cwd().openDir(io, config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{});
return;
};
defer dir.close(io);
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
switch (parsed.sub) {
.stats => try runStats(ctx),
.clear => try runClear(ctx),
}
}
// Collect and sort symbol names
var symbols: std.ArrayList([]const u8) = .empty;
defer {
for (symbols.items) |s| allocator.free(s);
symbols.deinit(allocator);
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
// 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();
try out.print("Cache directory: {s}\n\n", .{config.cache_dir});
var dir = std.Io.Dir.cwd().openDir(io, config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{});
return;
};
defer dir.close(io);
// Collect and sort symbol names
var symbols: std.ArrayList([]const u8) = .empty;
defer {
for (symbols.items) |s| allocator.free(s);
symbols.deinit(allocator);
}
var iter = dir.iterate();
while (iter.next(io) catch null) |entry| {
if (entry.kind == .directory) {
const name = allocator.dupe(u8, entry.name) catch continue;
symbols.append(allocator, name) catch {
allocator.free(name);
continue;
};
}
}
var iter = dir.iterate();
while (iter.next(io) catch null) |entry| {
if (entry.kind == .directory) {
const name = allocator.dupe(u8, entry.name) catch continue;
symbols.append(allocator, name) catch {
allocator.free(name);
continue;
};
}
if (symbols.items.len == 0) {
try out.print(" (empty -- no cached data)\n", .{});
return;
}
std.mem.sort([]const u8, symbols.items, {}, struct {
fn cmp(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.cmp);
if (symbols.items.len == 0) {
try out.print(" (empty -- no cached data)\n", .{});
return;
}
// Track totals
var total_size: u64 = 0;
var total_files: usize = 0;
std.mem.sort([]const u8, symbols.items, {}, struct {
fn cmp(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.cmp);
for (symbols.items) |symbol| {
try out.print("{s}\n", .{symbol});
// Track totals
var total_size: u64 = 0;
var total_files: usize = 0;
// Print header
try out.print(" {s:<14} {s:>10} {s}\n", .{ "Type", "Size", "Updated" });
try out.print(" {s:->14} {s:->10} {s:->24}\n", .{ "", "", "" });
for (symbols.items) |symbol| {
try out.print("{s}\n", .{symbol});
var symbol_size: u64 = 0;
var symbol_files: usize = 0;
// Print header
try out.print(" {s:<14} {s:>10} {s}\n", .{ "Type", "Size", "Updated" });
try out.print(" {s:->14} {s:->10} {s:->24}\n", .{ "", "", "" });
for (display_types, display_labels) |dt, label| {
const info = getFileInfo(io, allocator, config.cache_dir, symbol, dt);
if (info.exists) {
symbol_files += 1;
symbol_size += info.size;
var symbol_size: u64 = 0;
var symbol_files: usize = 0;
var size_buf: [10]u8 = undefined;
const size_str = formatSize(&size_buf, info.size);
for (display_types, display_labels) |dt, label| {
const info = getFileInfo(io, allocator, config.cache_dir, symbol, dt);
if (info.exists) {
symbol_files += 1;
symbol_size += info.size;
var size_buf: [10]u8 = undefined;
const size_str = formatSize(&size_buf, info.size);
if (info.is_negative) {
try out.print(" {s:<14} {s:>10} (negative cache)\n", .{ label, size_str });
} else if (info.created) |ts| {
var age_buf: [24]u8 = undefined;
const age_str = formatAge(&age_buf, ts, now_s);
const thru = info.lastDate() orelse "";
if (info.expired) {
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (stale, thru {s})\n", .{ label, size_str, age_str, thru });
} else {
try out.print(" {s:<14} {s:>10} {s} (stale)\n", .{ label, size_str, age_str });
}
if (info.is_negative) {
try out.print(" {s:<14} {s:>10} (negative cache)\n", .{ label, size_str });
} else if (info.created) |ts| {
var age_buf: [24]u8 = undefined;
const age_str = formatAge(&age_buf, ts, now_s);
const thru = info.lastDate() orelse "";
if (info.expired) {
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (stale, thru {s})\n", .{ label, size_str, age_str, thru });
} else {
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (thru {s})\n", .{ label, size_str, age_str, thru });
} else {
try out.print(" {s:<14} {s:>10} {s}\n", .{ label, size_str, age_str });
}
try out.print(" {s:<14} {s:>10} {s} (stale)\n", .{ label, size_str, age_str });
}
} else {
try out.print(" {s:<14} {s:>10} (no timestamp)\n", .{ label, size_str });
if (thru.len > 0) {
try out.print(" {s:<14} {s:>10} {s} (thru {s})\n", .{ label, size_str, age_str, thru });
} else {
try out.print(" {s:<14} {s:>10} {s}\n", .{ label, size_str, age_str });
}
}
} else {
try out.print(" {s:<14} {s:>10} (no timestamp)\n", .{ label, size_str });
}
}
// Also count candles_meta size (not displayed as its own row but is part of total)
const meta_info = getFileInfo(io, allocator, config.cache_dir, symbol, .candles_meta);
if (meta_info.exists) {
symbol_size += meta_info.size;
symbol_files += 1;
}
if (symbol_files == 0) {
try out.print(" (empty)\n", .{});
}
try out.print("\n", .{});
total_size += symbol_size;
total_files += symbol_files;
}
// Also count the top-level cusip_tickers.srf if present
const cusip_path = std.fs.path.join(allocator, &.{ config.cache_dir, "cusip_tickers.srf" }) catch null;
if (cusip_path) |path| {
defer allocator.free(path);
if (std.Io.Dir.cwd().statFile(io, path, .{})) |stat| {
total_size += stat.size;
total_files += 1;
} else |_| {}
// Also count candles_meta size (not displayed as its own row but is part of total)
const meta_info = getFileInfo(io, allocator, config.cache_dir, symbol, .candles_meta);
if (meta_info.exists) {
symbol_size += meta_info.size;
symbol_files += 1;
}
var total_buf: [10]u8 = undefined;
try out.print("{d} symbol(s), {d} file(s), {s} total\n", .{
symbols.items.len,
total_files,
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");
if (symbol_files == 0) {
try out.print(" (empty)\n", .{});
}
try out.print("\n", .{});
total_size += symbol_size;
total_files += symbol_files;
}
// Also count the top-level cusip_tickers.srf if present
const cusip_path = std.fs.path.join(allocator, &.{ config.cache_dir, "cusip_tickers.srf" }) catch null;
if (cusip_path) |path| {
defer allocator.free(path);
if (std.Io.Dir.cwd().statFile(io, path, .{})) |stat| {
total_size += stat.size;
total_files += 1;
} else |_| {}
}
var total_buf: [10]u8 = undefined;
try out.print("{d} symbol(s), {d} file(s), {s} total\n", .{
symbols.items.len,
total_files,
formatSize(&total_buf, total_size),
});
}
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 {
@ -253,6 +319,43 @@ fn formatAge(buf: *[24]u8, before_s: i64, after_s: i64) []const u8 {
// 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'" {
var buf: [24]u8 = undefined;
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 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"),
.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 =
\\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);
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);
} 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")) {
if (cmd_args.len < 1) {
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;
}
// 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 history = @import("commands/history.zig");
const portfolio = @import("commands/portfolio.zig");