migrate cache to new cli framework
This commit is contained in:
parent
2dcae073b0
commit
6d9c864a40
2 changed files with 239 additions and 141 deletions
|
|
@ -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));
|
||||
|
|
|
|||
67
src/main.zig
67
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> [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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue