From 7c392cec438a272b6d2775c843f006eca0f68aad Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 21 Mar 2026 08:45:27 -0700 Subject: [PATCH] cache command updates --- src/commands/cache.zig | 233 ++++++++++++++++++++++++++++++++++++++--- src/main.zig | 10 +- 2 files changed, 229 insertions(+), 14 deletions(-) diff --git a/src/commands/cache.zig b/src/commands/cache.zig index fb4800a..4a1acaa 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -1,37 +1,246 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); +const srf = @import("srf"); + +const Store = zfin.cache.Store; +const DataType = zfin.cache.DataType; + +/// 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 run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void { if (std.mem.eql(u8, subcommand, "stats")) { - try out.print("Cache directory: {s}\n", .{config.cache_dir}); - std.fs.cwd().access(config.cache_dir, .{}) catch { - try out.print(" (empty -- no cached data)\n", .{}); - return; - }; + try out.print("Cache directory: {s}\n\n", .{config.cache_dir}); + var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { try out.print(" (empty -- no cached data)\n", .{}); return; }; defer dir.close(); - var count: usize = 0; + + // 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() catch null) |entry| { if (entry.kind == .directory) { - try out.print(" {s}/\n", .{entry.name}); - count += 1; + const name = allocator.dupe(u8, entry.name) catch continue; + symbols.append(allocator, name) catch { + allocator.free(name); + continue; + }; } } - if (count == 0) { + + if (symbols.items.len == 0) { try out.print(" (empty -- no cached data)\n", .{}); - } else { - try out.print("\n {d} symbol(s) cached\n", .{count}); + 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(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); + 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(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.fs.cwd().statFile(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), + }); } else if (std.mem.eql(u8, subcommand, "clear")) { - var store = zfin.cache.Store.init(allocator, config.cache_dir); + var store = Store.init(allocator, config.cache_dir); try store.clearAll(); try out.writeAll("Cache cleared.\n"); } else { try cli.stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\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(allocator: std.mem.Allocator, cache_dir: []const u8, symbol: []const u8, dt: DataType) FileInfo { + var store = Store.init(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.fs.cwd().statFile(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 = meta_result.meta.last_date.format(&last_date_buf); + + 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.fs.cwd().readFileAlloc(allocator, path, 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() 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 "?"; + } +} + +fn formatAge(buf: *[24]u8, timestamp: i64) []const u8 { + const now = std.time.timestamp(); + const age = now - timestamp; + + 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 "?"; + } +} diff --git a/src/main.zig b/src/main.zig index 3afe07a..9d12799 100644 --- a/src/main.zig +++ b/src/main.zig @@ -103,8 +103,14 @@ pub fn main() !u8 { var svc = zfin.DataService.init(allocator, config); defer svc.deinit(); - // Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") - if (args.len >= 3) { + // Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol. + // Skip normalization for commands where args[2] is a subcommand or file path. + if (args.len >= 3 and + !std.mem.eql(u8, command, "cache") and + !std.mem.eql(u8, command, "enrich") and + !std.mem.eql(u8, command, "analysis") and + !std.mem.eql(u8, command, "portfolio")) + { for (args[2]) |*c| c.* = std.ascii.toUpper(c.*); }