IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
307 lines
12 KiB
Zig
307 lines
12 KiB
Zig
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(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});
|
|
|
|
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),
|
|
});
|
|
} 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");
|
|
}
|
|
}
|
|
|
|
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 = 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.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 "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));
|
|
}
|