From 6d9c864a402d0d2cf3da884c0842571e498504f9 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 15:41:27 -0700 Subject: [PATCH] migrate cache to new cli framework --- src/commands/cache.zig | 313 +++++++++++++++++++++++++++-------------- src/main.zig | 67 ++++----- 2 files changed, 239 insertions(+), 141 deletions(-) diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 9a40bf0..8eb59a9 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -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 + \\ + \\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)); diff --git a/src/main.zig b/src/main.zig index f9011c2..3ad2187 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 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");