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: framework.Meta = .{ .name = "cache", .group = .infra, .synopsis = "Inspect or clear the local provider-data cache", .help = \\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`. \\ , .uppercase_first_arg = false, }; 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, .dividends, .splits, .options, .earnings, .etf_profile, }; const display_labels = [_][]const u8{ "candles", "dividends", "splits", "options", "earnings", "etf_profile", }; 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; } 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 // 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; }; } } 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); // Track totals var total_size: u64 = 0; var total_files: usize = 0; for (symbols.items) |symbol| { try out.print("{s}\n", .{symbol}); // Print header try out.print(" {s:<14} {s:>10} {s}\n", .{ "Type", "Size", "Updated" }); try out.print(" {s:->14} {s:->10} {s:->24}\n", .{ "", "", "" }); var symbol_size: u64 = 0; var symbol_files: usize = 0; 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 }); } } 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 }); } } } 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 |_| {} } 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 { exists: bool = false, size: u64 = 0, is_negative: bool = false, created: ?i64 = null, expired: bool = false, last_date_buf: [10]u8 = undefined, last_date_len: u4 = 0, fn lastDate(self: *const FileInfo) ?[]const u8 { if (self.last_date_len == 0) return null; return self.last_date_buf[0..self.last_date_len]; } }; fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8, symbol: []const u8, dt: DataType) FileInfo { var store = Store.init(io, allocator, cache_dir); // Get file size via stat const path = std.fs.path.join(allocator, &.{ cache_dir, symbol, dt.fileName() }) catch return .{}; defer allocator.free(path); const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return .{}; // Check for negative cache if (store.isNegative(symbol, dt)) { return .{ .exists = true, .size = stat.size, .is_negative = true }; } // For candles_daily, use candles_meta for freshness and last_date if (dt == .candles_daily) { const meta_result = store.readCandleMeta(symbol) orelse return .{ .exists = true, .size = stat.size }; var last_date_buf: [10]u8 = undefined; const date_str = std.fmt.bufPrint(&last_date_buf, "{f}", .{meta_result.meta.last_date}) catch unreachable; return .{ .exists = true, .size = stat.size, .created = meta_result.created, .expired = !store.isCandleMetaFresh(symbol), .last_date_buf = last_date_buf, .last_date_len = @intCast(date_str.len), }; } // For all other types, read the file and use the srf iterator for directives const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(50 * 1024 * 1024)) catch return .{ .exists = true, .size = stat.size }; defer allocator.free(data); var reader = std.Io.Reader.fixed(data); const it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return .{ .exists = true, .size = stat.size }; defer it.deinit(); return .{ .exists = true, .size = stat.size, .created = it.created, .expired = if (it.expires != null) !it.isFresh(io) else false, }; } fn formatSize(buf: *[10]u8, size: u64) []const u8 { if (size < 1024) { return std.fmt.bufPrint(buf, "{d} B", .{size}) catch "?"; } else if (size < 1024 * 1024) { const kb: f64 = @as(f64, @floatFromInt(size)) / 1024.0; return std.fmt.bufPrint(buf, "{d:.1} KB", .{kb}) catch "?"; } else { const mb: f64 = @as(f64, @floatFromInt(size)) / (1024.0 * 1024.0); return std.fmt.bufPrint(buf, "{d:.1} MB", .{mb}) catch "?"; } } /// Pure age formatter: renders `after_s - before_s` as a human string /// (`"5m ago"`, `"2h ago"`, `"3d ago"`, `"just now"`). Caller captures /// `after_s` via `std.Io.Timestamp.now(io, .real).toSeconds()` once per /// frame or command and passes it in. fn formatAge(buf: *[24]u8, before_s: i64, after_s: i64) []const u8 { const age = after_s - before_s; if (age < 0) { return std.fmt.bufPrint(buf, "just now", .{}) catch "?"; } else if (age < 60) { return std.fmt.bufPrint(buf, "{d}s ago", .{@as(u64, @intCast(age))}) catch "?"; } else if (age < 3600) { return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divTrunc(age, 60)))}) catch "?"; } else if (age < 86400) { return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divTrunc(age, 3600)))}) catch "?"; } else { return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divTrunc(age, 86400)))}) catch "?"; } } // ── 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)); } test "formatAge: seconds" { var buf: [24]u8 = undefined; try std.testing.expectEqualStrings("0s ago", formatAge(&buf, 1_700_000_000, 1_700_000_000)); try std.testing.expectEqualStrings("5s ago", formatAge(&buf, 1_700_000_000, 1_700_000_005)); try std.testing.expectEqualStrings("59s ago", formatAge(&buf, 1_700_000_000, 1_700_000_059)); } test "formatAge: minutes" { var buf: [24]u8 = undefined; // 1m at exactly 60s try std.testing.expectEqualStrings("1m ago", formatAge(&buf, 1_700_000_000, 1_700_000_060)); // 59m at 59*60+59 seconds try std.testing.expectEqualStrings("59m ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 59 * 60 + 59)); } test "formatAge: hours" { var buf: [24]u8 = undefined; try std.testing.expectEqualStrings("1h ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 3600)); // Just under a day try std.testing.expectEqualStrings("23h ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 23 * 3600 + 59 * 60)); } test "formatAge: days" { var buf: [24]u8 = undefined; try std.testing.expectEqualStrings("1d ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 86_400)); try std.testing.expectEqualStrings("30d ago", formatAge(&buf, 1_700_000_000, 1_700_000_000 + 30 * 86_400)); } test "formatSize: bytes" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("0 B", formatSize(&buf, 0)); try std.testing.expectEqualStrings("512 B", formatSize(&buf, 512)); try std.testing.expectEqualStrings("1023 B", formatSize(&buf, 1023)); } test "formatSize: kilobytes" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("1.0 KB", formatSize(&buf, 1024)); try std.testing.expectEqualStrings("1.5 KB", formatSize(&buf, 1536)); try std.testing.expectEqualStrings("100.0 KB", formatSize(&buf, 100 * 1024)); } test "formatSize: megabytes" { var buf: [10]u8 = undefined; try std.testing.expectEqualStrings("1.0 MB", formatSize(&buf, 1024 * 1024)); try std.testing.expectEqualStrings("2.5 MB", formatSize(&buf, 2 * 1024 * 1024 + 512 * 1024)); }