zfin/src/commands/doctor.zig

1061 lines
49 KiB
Zig

//! `zfin doctor` - health check for the file constellation + environment.
//!
//! Answers "is my zfin setup sane?" without making any changes: it
//! resolves and parse-checks every config file, cross-references
//! accounts/symbols/transfers, audits the environment (API keys, cache,
//! hand-maintained data staleness, server reachability), and prints a
//! plain-English capability summary.
//!
//! Read-only: no provider fetches, no cache writes, no portfolio
//! mutation. It deliberately does NOT use `ctx.svc`. The one network
//! call is an optional `GET {ZFIN_SERVER}/help` to confirm the server
//! is a reachable zfin-server and report its version.
//!
//! Exit code: 0 when every check is OK/INFO/WARN; 1 (via
//! `error.DoctorFailed`) only when a FAIL fired - i.e. a file that
//! exists but does not parse. Missing optional files, cross-reference
//! gaps, stale data, an unreachable server, and absent API keys are all
//! non-fatal. Suitable for CI / cron.
//!
//! Architecture: `run` does all the I/O (resolve paths, read files,
//! parse, probe) and feeds the resolved facts into pure builder
//! functions (`fileStatus`, `coverageCheck`, `capabilityChecks`,
//! `countByStatus`) that produce `Check` data. A separate renderer
//! writes the report. The pure builders carry the test coverage.
const std = @import("std");
const zfin = @import("../root.zig");
const srf = @import("srf");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Config = zfin.Config;
const Lot = @import("../models/portfolio.zig").Lot;
const Date = @import("../Date.zig");
const cache = @import("../cache/store.zig");
const classification = @import("../models/classification.zig");
const analysis = @import("../analytics/analysis.zig");
const transaction_log = @import("../models/transaction_log.zig");
const imported_values = @import("../data/imported_values.zig");
const history = @import("../history.zig");
const staleness = @import("../data/staleness.zig");
const http = @import("../net/http.zig");
const keybinds = @import("../tui/keybinds.zig");
const theme = @import("../tui/theme.zig");
const cache_cmd = @import("cache.zig");
pub const ParsedArgs = struct {};
pub const meta: framework.Meta = .{
.name = "doctor",
.group = .infra,
.synopsis = "Health-check the file constellation + environment (read-only)",
.help =
\\Usage: zfin doctor
\\
\\Inspect the zfin setup and report problems without changing
\\anything. Four sections:
\\
\\ Files each config file: present? where? parses?
\\ Cross-checks accounts/symbols/transfers reference real entries
\\ Environment cache size, hand-maintained data staleness,
\\ ZFIN_SERVER reachability + version (GET /help)
\\ Capabilities which API keys are set and what each enables
\\
\\Every check is OK / INFO / WARN / FAIL. Exit code is 0 unless a
\\file that EXISTS fails to parse (FAIL); missing optional files,
\\cross-reference gaps, stale data, an unreachable server, and
\\absent API keys are all non-fatal. Suitable for CI / cron.
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, DoctorFailed },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
for (cmd_args) |a| {
cli.stderrPrint(ctx.io, "Error: unexpected argument to 'doctor': ");
cli.stderrPrint(ctx.io, a);
cli.stderrPrint(ctx.io, "\n");
return error.UnexpectedArg;
}
return .{};
}
// ── Report data model ─────────────────────────────────────────
const Status = enum {
ok,
info,
warn,
fail,
fn intent(self: Status) fmt.StyleIntent {
return switch (self) {
.ok => .positive,
.info => .muted,
.warn => .warning,
.fail => .negative,
};
}
fn label(self: Status) []const u8 {
return switch (self) {
.ok => "OK ",
.info => "INFO",
.warn => "WARN",
.fail => "FAIL",
};
}
};
/// One diagnostic line: a status, a short subject, and a one-line
/// context string. `detail` may be borrowed or arena-allocated.
const Check = struct {
status: Status,
label: []const u8,
detail: []const u8 = "",
};
const Section = struct {
title: []const u8,
checks: []const Check,
};
// ── Pure builders (the tested core) ───────────────────────────
const FileReq = enum { required, optional };
/// Classify a file's health from whether it was found, whether parsing
/// produced an error, and whether the file is required. A present file
/// that fails to parse is the only FAIL; a missing required file is a
/// WARN (a fresh install legitimately has no portfolio yet); a missing
/// optional file is INFO.
fn fileStatus(found: bool, parse_err: ?[]const u8, req: FileReq) Status {
if (!found) return switch (req) {
.required => .warn,
.optional => .info,
};
return if (parse_err != null) .fail else .ok;
}
fn containsStr(haystack: []const []const u8, needle: []const u8) bool {
for (haystack) |h| if (std.mem.eql(u8, h, needle)) return true;
return false;
}
/// Render up to `cap` items as "a, b, c (+N more)".
fn joinCapped(arena: std.mem.Allocator, items: []const []const u8, cap: usize) ![]const u8 {
var buf: std.ArrayList(u8) = .empty;
const shown = @min(items.len, cap);
for (items[0..shown], 0..) |it, i| {
if (i > 0) try buf.appendSlice(arena, ", ");
try buf.appendSlice(arena, it);
}
if (items.len > cap) {
const more = try std.fmt.allocPrint(arena, " (+{d} more)", .{items.len - cap});
try buf.appendSlice(arena, more);
}
return buf.items;
}
/// Cross-reference check: every name in `needed` should appear in
/// `known`. OK when all are present (or `needed` is empty); WARN listing
/// the missing ones otherwise. Operates on plain string slices so it's
/// equally usable for account names, held symbols, and transfer
/// endpoints - and trivially unit-testable.
fn coverageCheck(
arena: std.mem.Allocator,
label: []const u8,
needed: []const []const u8,
known: []const []const u8,
missing_prefix: []const u8,
) !Check {
var missing: std.ArrayList([]const u8) = .empty;
for (needed) |n| {
if (!containsStr(known, n) and !containsStr(missing.items, n)) {
try missing.append(arena, n);
}
}
if (missing.items.len == 0) {
return .{ .status = .ok, .label = label, .detail = "all referenced entries present" };
}
const listed = try joinCapped(arena, missing.items, 6);
return .{
.status = .warn,
.label = label,
.detail = try std.fmt.allocPrint(arena, "{s}: {s}", .{ missing_prefix, listed }),
};
}
/// Build the per-key capability checks from a resolved `Config`. Pure
/// over `Config` (no I/O), so every branch is unit-testable by
/// constructing a `Config` literal. Present keys -> OK with the
/// capability they unlock; absent keys -> INFO with the consequence
/// (never WARN - keyless operation is a valid configuration). Key
/// VALUES are never read, only presence.
fn capabilityChecks(arena: std.mem.Allocator, config: Config) ![]const Check {
var checks: std.ArrayList(Check) = .empty;
try checks.append(arena, keyCheck("TIINGO_API_KEY", config.tiingo_key, "daily candles", "Yahoo-only candle fallback; some symbols (esp. mutual funds) may not price"));
try checks.append(arena, keyCheck("POLYGON_API_KEY", config.polygon_key, "dividend/split history + dividend-reinvested total return", "price-only returns; no dividend/split history"));
try checks.append(arena, keyCheck("FMP_API_KEY", config.fmp_key, "earnings history and estimates", "no earnings data"));
try checks.append(arena, keyCheck("TWELVEDATA_API_KEY", config.twelvedata_key, "quote fallback after Yahoo", "no quote fallback if Yahoo fails"));
try checks.append(arena, keyCheck("ZFIN_USER_EMAIL", config.user_email, "ETF profiles and `enrich`", "ETF profiles and `enrich` unavailable"));
try checks.append(arena, keyCheck("OPENFIGI_API_KEY", config.openfigi_key, "faster CUSIP lookups (higher rate limit)", "CUSIP lookups work at the lower keyless rate limit"));
// Always-on, keyless capabilities - informational reassurance.
try checks.append(arena, .{ .status = .ok, .label = "Quotes (Yahoo)", .detail = "always available, no key required" });
try checks.append(arena, .{ .status = .ok, .label = "Options (CBOE)", .detail = "always available, no key required" });
return checks.items;
}
fn keyCheck(name: []const u8, value: ?[]const u8, when_present: []const u8, when_absent: []const u8) Check {
return if (value != null)
.{ .status = .ok, .label = name, .detail = when_present }
else
.{ .status = .info, .label = name, .detail = when_absent };
}
/// Count checks of a given status across a set of sections.
fn countByStatus(sections: []const Section, status: Status) usize {
var n: usize = 0;
for (sections) |s| {
for (s.checks) |c| {
if (c.status == status) n += 1;
}
}
return n;
}
/// Extract the version token from a zfin-server `/help` response body.
/// The first line is `zfin-server <version> - <description>`; returns
/// `<version>` (e.g. "f3c1690"), or null if the body isn't a
/// zfin-server help page. Pure - testable without a network call.
fn parseServerVersion(body: []const u8) ?[]const u8 {
const trimmed = std.mem.trimStart(u8, body, " \t\r\n");
const prefix = "zfin-server ";
if (!std.mem.startsWith(u8, trimmed, prefix)) return null;
const rest = trimmed[prefix.len..];
const end = std.mem.indexOfAny(u8, rest, " \t\r\n") orelse rest.len;
if (end == 0) return null;
return rest[0..end];
}
fn trimTrailingSlash(s: []const u8) []const u8 {
return if (s.len > 0 and s[s.len - 1] == '/') s[0 .. s.len - 1] else s;
}
// ── I/O glue: parse-checks ────────────────────────────────────
/// Validate that bytes form a parseable SRF stream (valid `#!srfv1`
/// header + iterable records). Used for files whose typed parser is
/// infallible (`projections.srf`), so a malformed file would otherwise
/// be silently swallowed into defaults.
fn validateSrf(allocator: std.mem.Allocator, bytes: []const u8) anyerror!void {
var reader = std.Io.Reader.fixed(bytes);
var it = srf.iterator(&reader, allocator, .{ .parse_allocator = .none }) catch return error.InvalidSrf;
defer it.deinit();
while (it.next() catch return error.InvalidSrf) |_| {}
}
// Discard-style parse wrappers (arena owns everything; no deinit needed).
fn vMetadata(a: std.mem.Allocator, b: []const u8) anyerror!void {
_ = try classification.parseClassificationFile(a, b);
}
fn vAccounts(a: std.mem.Allocator, b: []const u8) anyerror!void {
_ = try analysis.parseAccountsFile(a, b);
}
fn vTransfers(a: std.mem.Allocator, b: []const u8) anyerror!void {
_ = try transaction_log.parseTransactionLogFile(a, b);
}
fn vImported(a: std.mem.Allocator, b: []const u8) anyerror!void {
_ = try imported_values.parseImportedValues(a, b);
}
/// Read `path` and run `parseFn` over its bytes, producing a `Check`.
/// Missing/unreadable/parse-error all map through `fileStatus`.
fn checkSrfFile(
io: std.Io,
arena: std.mem.Allocator,
label: []const u8,
path: []const u8,
req: FileReq,
comptime parseFn: fn (std.mem.Allocator, []const u8) anyerror!void,
) Check {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch |err| {
if (err == error.FileNotFound) {
return .{ .status = fileStatus(false, null, req), .label = label, .detail = if (req == .required) "not found" else "not present" };
}
return .{ .status = .fail, .label = label, .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
};
if (parseFn(arena, bytes)) |_| {
return .{ .status = fileStatus(true, null, req), .label = label, .detail = path };
} else |err| {
return .{ .status = fileStatus(true, @errorName(err), req), .label = label, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
}
}
/// Join a sibling filename onto the anchor portfolio's directory.
fn siblingPath(arena: std.mem.Allocator, anchor: []const u8, name: []const u8) ![]const u8 {
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor, std.fs.path.sep)) |idx| idx + 1 else 0;
return std.fmt.allocPrint(arena, "{s}{s}", .{ anchor[0..dir_end], name });
}
// ── run ───────────────────────────────────────────────────────
pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const io = ctx.io;
const arena = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const config = ctx.config;
var sections: std.ArrayList(Section) = .empty;
// Collected for cross-reference (Section B).
var all_lots: std.ArrayList(Lot) = .empty;
var account_map: ?analysis.AccountMap = null;
var class_map: ?classification.ClassificationMap = null;
var transfer_log: ?transaction_log.TransactionLog = null;
// ── Section A: Files ──────────────────────────────────────
{
var checks: std.ArrayList(Check) = .empty;
const source: []const u8 = if (config.zfin_home) |h| h else "cwd";
// Portfolio file(s) - globbed, union-merged. Parse-check each.
var anchor: ?[]const u8 = null;
const pf = config.resolveUserFiles(io, arena, Config.default_portfolio_filename) catch
Config.ResolvedPaths{ .paths = &.{}, .allocator = arena };
if (pf.paths.len == 0) {
try checks.append(arena, .{
.status = fileStatus(false, null, .required),
.label = "portfolio*.srf",
.detail = try std.fmt.allocPrint(arena, "not found (searched {s})", .{source}),
});
} else {
anchor = pf.paths[0].path;
for (pf.paths) |rp| {
const c = try checkPortfolioFile(io, arena, rp.path, &all_lots);
try checks.append(arena, c);
}
}
if (anchor) |a| {
// Accounts - parsed + kept for cross-reference.
{
const r = checkSrfFile(io, arena, "accounts.srf", try siblingPath(arena, a, "accounts.srf"), .optional, vAccounts);
try checks.append(arena, r);
if (r.status == .ok) {
const path = try siblingPath(arena, a, "accounts.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
account_map = analysis.parseAccountsFile(arena, b) catch null;
} else |_| {}
}
}
// Metadata - parsed + kept.
{
const r = checkSrfFile(io, arena, "metadata.srf", try siblingPath(arena, a, "metadata.srf"), .optional, vMetadata);
try checks.append(arena, r);
if (r.status == .ok) {
const path = try siblingPath(arena, a, "metadata.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
class_map = classification.parseClassificationFile(arena, b) catch null;
} else |_| {}
}
}
// Transaction log - parsed + kept.
{
const r = checkSrfFile(io, arena, "transaction_log.srf", try siblingPath(arena, a, "transaction_log.srf"), .optional, vTransfers);
try checks.append(arena, r);
if (r.status == .ok) {
const path = try siblingPath(arena, a, "transaction_log.srf");
if (std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024))) |b| {
transfer_log = transaction_log.parseTransactionLogFile(arena, b) catch null;
} else |_| {}
}
}
try checks.append(arena, checkSrfFile(io, arena, "projections.srf", try siblingPath(arena, a, "projections.srf"), .optional, validateSrf));
// imported_values.srf and the snapshots both live under
// <portfolio_dir>/history/, NOT directly beside the
// portfolio file.
const hist_dir = history.deriveHistoryDir(arena, a) catch null;
if (hist_dir) |hd| {
try checks.append(arena, checkSrfFile(io, arena, "history/imported_values.srf", try std.fs.path.join(arena, &.{ hd, "imported_values.srf" }), .optional, vImported));
} else {
try checks.append(arena, .{ .status = .info, .label = "history/imported_values.srf", .detail = "could not derive history dir" });
}
try checks.append(arena, checkSnapshots(io, arena, hist_dir));
}
// keys.srf / theme.srf live under $HOME/.config/zfin.
try checks.append(arena, checkUserConfigFiles(io, arena, config, .keys));
try checks.append(arena, checkUserConfigFiles(io, arena, config, .theme));
try sections.append(arena, .{ .title = "Files", .checks = checks.items });
}
// ── Section B: Cross-references ───────────────────────────
{
var checks: std.ArrayList(Check) = .empty;
// Account coverage.
if (account_map) |am| {
const lot_accts = try uniqueAccounts(arena, all_lots.items);
const known = try accountNames(arena, am);
try checks.append(arena, try coverageCheck(arena, "accounts.srf coverage", lot_accts, known, "accounts in portfolio missing from accounts.srf"));
} else {
try checks.append(arena, .{ .status = .info, .label = "accounts.srf coverage", .detail = "skipped (accounts.srf not loaded)" });
}
// Metadata (classification) coverage for classifiable holdings.
if (class_map) |cm| {
const held = try classifiableSymbols(arena, all_lots.items);
const classified = try classifiedSymbols(arena, cm);
try checks.append(arena, try coverageCheck(arena, "metadata.srf coverage", held, classified, "held symbols missing from metadata.srf"));
} else {
try checks.append(arena, .{ .status = .info, .label = "metadata.srf coverage", .detail = "skipped (metadata.srf not loaded)" });
}
// Transfer-log account references.
if (transfer_log) |tl| {
const endpoints = try transferEndpoints(arena, tl);
const known = try knownAccountNames(arena, account_map, all_lots.items);
try checks.append(arena, try coverageCheck(arena, "transaction_log.srf references", endpoints, known, "transfer accounts not found in portfolio/accounts.srf"));
} else {
try checks.append(arena, .{ .status = .info, .label = "transaction_log.srf references", .detail = "skipped (transaction_log.srf not loaded)" });
}
try sections.append(arena, .{ .title = "Cross-checks", .checks = checks.items });
}
// ── Section C: Environment ────────────────────────────────
{
var checks: std.ArrayList(Check) = .empty;
// Cache stats.
var store = cache.Store.init(io, arena, config.cache_dir);
const ds = store.diskStats();
if (ds.symbols == 0 and ds.files == 0) {
try checks.append(arena, .{ .status = .info, .label = "Cache", .detail = try std.fmt.allocPrint(arena, "empty ({s})", .{config.cache_dir}) });
} else {
var size_buf: [10]u8 = undefined;
try checks.append(arena, .{
.status = .ok,
.label = "Cache",
.detail = try std.fmt.allocPrint(arena, "{d} symbols, {d} files, {s} ({s})", .{ ds.symbols, ds.files, cache_cmd.formatSize(&size_buf, ds.bytes), config.cache_dir }),
});
}
// Hand-maintained data staleness.
for (staleness.entries) |e| {
switch (staleness.entryStatus(e, ctx.today)) {
.ok => try checks.append(arena, .{ .status = .ok, .label = e.name, .detail = "current" }),
.overdue => try checks.append(arena, .{
.status = .warn,
.label = e.name,
.detail = try std.fmt.allocPrint(arena, "overdue for refresh; see {s}", .{e.source_file}),
}),
}
}
// ZFIN_SERVER: GET /help to confirm it's a reachable
// zfin-server and report its version. max_retries=0 so a
// dead host fails fast instead of retry-looping. (No receive
// timeout exists in the HTTP client, so a connected-but-silent
// host could still stall - acceptable for an on-demand check.)
if (config.server_url) |url| {
try checks.append(arena, serverCheck(io, arena, url));
} else {
try checks.append(arena, .{ .status = .info, .label = "ZFIN_SERVER", .detail = "not set (provider fetch only; no server sync)" });
}
try sections.append(arena, .{ .title = "Environment", .checks = checks.items });
}
// ── Section D: Capabilities ───────────────────────────────
try sections.append(arena, .{ .title = "Capabilities", .checks = try capabilityChecks(arena, config) });
// ── Render + exit code ────────────────────────────────────
try renderReport(out, color, sections.items);
// Flush explicitly: when we return `error.DoctorFailed` below the
// dispatcher's post-run flush (main.zig) is skipped, so without
// this the buffered report would be lost on the FAIL exit path.
try out.flush();
if (countByStatus(sections.items, .fail) > 0) return error.DoctorFailed;
}
/// Deserialize a portfolio file, append its lots to `all_lots` for
/// later cross-referencing, and report parse status.
fn checkPortfolioFile(io: std.Io, arena: std.mem.Allocator, path: []const u8, all_lots: *std.ArrayList(Lot)) !Check {
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(64 * 1024 * 1024)) catch |err| {
if (err == error.FileNotFound) return .{ .status = .warn, .label = "portfolio.srf", .detail = "not found" };
return .{ .status = .fail, .label = "portfolio.srf", .detail = std.fmt.allocPrint(arena, "unreadable: {s}", .{@errorName(err)}) catch "unreadable" };
};
const pf = cache.deserializePortfolio(arena, bytes) catch |err| {
return .{ .status = .fail, .label = path, .detail = std.fmt.allocPrint(arena, "parse error: {s}", .{@errorName(err)}) catch "parse error" };
};
try all_lots.appendSlice(arena, pf.lots);
return .{ .status = .ok, .label = path, .detail = std.fmt.allocPrint(arena, "{d} lots", .{pf.lots.len}) catch "" };
}
/// Enumerate the `history/` dir and parse-check each
/// `*-portfolio.srf` snapshot. A broken snapshot is a FAIL.
fn checkSnapshots(io: std.Io, arena: std.mem.Allocator, hist_dir_opt: ?[]const u8) Check {
const hist_dir = hist_dir_opt orelse
return .{ .status = .info, .label = "history/ snapshots", .detail = "could not derive history dir" };
var dir = std.Io.Dir.cwd().openDir(io, hist_dir, .{ .iterate = true }) catch
return .{ .status = .info, .label = "history/ snapshots", .detail = "no history/ directory" };
defer dir.close(io);
var total: usize = 0;
var bad: usize = 0;
var first_bad: ?[]const u8 = null;
var iter = dir.iterate();
while (iter.next(io) catch null) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, history.snapshot_suffix)) continue;
total += 1;
const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ hist_dir, entry.name }) catch continue;
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(16 * 1024 * 1024)) catch {
bad += 1;
if (first_bad == null) first_bad = arena.dupe(u8, entry.name) catch entry.name;
continue;
};
if (history.parseSnapshotBytes(arena, bytes)) |_| {} else |_| {
bad += 1;
if (first_bad == null) first_bad = arena.dupe(u8, entry.name) catch entry.name;
}
}
if (total == 0) return .{ .status = .info, .label = "history/ snapshots", .detail = "no snapshots yet" };
if (bad == 0) return .{ .status = .ok, .label = "history/ snapshots", .detail = std.fmt.allocPrint(arena, "{d} snapshots, all parse", .{total}) catch "" };
return .{ .status = .fail, .label = "history/ snapshots", .detail = std.fmt.allocPrint(arena, "{d}/{d} failed to parse (e.g. {s})", .{ bad, total, first_bad orelse "?" }) catch "parse failures" };
}
/// GET {url}/help and classify it as a reachable zfin-server (with
/// version), a host that responded but isn't a recognized
/// zfin-server, or an unreachable host. Read-only request; no retries.
fn serverCheck(io: std.Io, arena: std.mem.Allocator, url: []const u8) Check {
const help_url = std.fmt.allocPrint(arena, "{s}/help", .{trimTrailingSlash(url)}) catch
return .{ .status = .warn, .label = "ZFIN_SERVER", .detail = "out of memory building probe URL" };
var client = http.Client.init(io, arena);
client.max_retries = 0; // fail fast; doctor shouldn't retry-loop on a dead host
defer client.deinit();
if (client.get(help_url)) |resp| {
var r = resp;
defer r.deinit();
if (parseServerVersion(r.body)) |ver| {
return .{ .status = .ok, .label = "ZFIN_SERVER", .detail = std.fmt.allocPrint(arena, "reachable: zfin-server {s} ({s})", .{ ver, url }) catch "reachable" };
}
return .{ .status = .warn, .label = "ZFIN_SERVER", .detail = std.fmt.allocPrint(arena, "responded, but not a zfin-server /help page ({s})", .{url}) catch "unexpected /help response" };
} else |err| switch (err) {
// The server answered with an HTTP status (it's reachable; the
// /help route just isn't a 200 zfin help page).
error.NotFound, error.Unauthorized, error.RateLimited, error.PaymentRequired, error.ServerError, error.InvalidResponse => return .{
.status = .warn,
.label = "ZFIN_SERVER",
.detail = std.fmt.allocPrint(arena, "responded ({s}) but /help unavailable ({s})", .{ @errorName(err), url }) catch "no /help",
},
// Transport-level failure: genuinely unreachable.
else => return .{
.status = .warn,
.label = "ZFIN_SERVER",
.detail = std.fmt.allocPrint(arena, "unreachable: {s} ({s})", .{ @errorName(err), url }) catch "unreachable",
},
}
}
const UserConfigKind = enum { keys, theme };
/// Check `$HOME/.config/zfin/{keys,theme}.srf`. These resolve from
/// $HOME only (not ZFIN_HOME / cwd), mirroring the TUI loader.
fn checkUserConfigFiles(io: std.Io, arena: std.mem.Allocator, config: Config, kind: UserConfigKind) Check {
const filename = switch (kind) {
.keys => "keys.srf",
.theme => "theme.srf",
};
const home = if (config.environ_map) |em| em.get("HOME") else null;
if (home == null) {
return .{ .status = .info, .label = filename, .detail = "HOME unset; using built-in defaults" };
}
const path = std.fs.path.join(arena, &.{ home.?, ".config", "zfin", filename }) catch
return .{ .status = .info, .label = filename, .detail = "path join failed" };
// Distinguish missing from present-but-broken.
std.Io.Dir.cwd().access(io, path, .{}) catch
return .{ .status = .info, .label = filename, .detail = "not present; using built-in defaults" };
switch (kind) {
.keys => switch (keybinds.loadFromFileChecked(io, arena, path)) {
.keymap => |km| {
if (km.warnings.len > 0) {
return .{ .status = .warn, .label = filename, .detail = std.fmt.allocPrint(arena, "loaded with {d} warning(s)", .{km.warnings.len}) catch "loaded with warnings" };
}
return .{ .status = .ok, .label = filename, .detail = path };
},
.err => |e| return .{ .status = .fail, .label = filename, .detail = std.fmt.allocPrint(arena, "invalid: {s}", .{@errorName(e)}) catch "invalid" },
.fallback => return .{ .status = .fail, .label = filename, .detail = "present but unparseable (fell back to defaults)" },
},
.theme => {
if (theme.loadFromFile(io, arena, path) != null) {
return .{ .status = .ok, .label = filename, .detail = path };
}
return .{ .status = .fail, .label = filename, .detail = "present but unparseable (fell back to defaults)" };
},
}
}
// ── Cross-reference extraction (struct -> name slices) ─────────
fn uniqueAccounts(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
for (lots) |lot| {
const acct = lot.account orelse continue;
if (!containsStr(list.items, acct)) try list.append(arena, acct);
}
return list.items;
}
fn accountNames(arena: std.mem.Allocator, am: analysis.AccountMap) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
for (am.entries) |e| try list.append(arena, e.account);
return list.items;
}
/// Accounts known from accounts.srf OR appearing on a lot - the union
/// against which transfer endpoints are validated.
fn knownAccountNames(arena: std.mem.Allocator, am: ?analysis.AccountMap, lots: []const Lot) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
if (am) |m| for (m.entries) |e| {
if (!containsStr(list.items, e.account)) try list.append(arena, e.account);
};
for (lots) |lot| {
const acct = lot.account orelse continue;
if (!containsStr(list.items, acct)) try list.append(arena, acct);
}
return list.items;
}
/// Symbols of classifiable holdings (stocks/ETFs). Cash, options, and
/// CDs don't need a metadata entry, mirroring `analysis`'s unclassified
/// semantics.
///
/// Uses `lot.priceSymbol()` (the `ticker::` alias when set, else the
/// raw symbol) because positions/allocations aggregate under the
/// ticker (see `Portfolio.positionsAsOf`), and the classification
/// engine matches metadata against the allocation's symbol. So a
/// `DI-SPX` lot with `ticker::SPY` is classified via SPY's entry, and
/// doctor must check SPY, not DI-SPX.
fn classifiableSymbols(arena: std.mem.Allocator, lots: []const Lot) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
for (lots) |lot| {
if (lot.security_type != .stock) continue;
const sym = lot.priceSymbol();
if (!containsStr(list.items, sym)) try list.append(arena, sym);
}
return list.items;
}
fn classifiedSymbols(arena: std.mem.Allocator, cm: classification.ClassificationMap) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
for (cm.entries) |e| {
if (!containsStr(list.items, e.symbol)) try list.append(arena, e.symbol);
}
return list.items;
}
fn transferEndpoints(arena: std.mem.Allocator, tl: transaction_log.TransactionLog) ![]const []const u8 {
var list: std.ArrayList([]const u8) = .empty;
for (tl.transfers) |t| {
if (!containsStr(list.items, t.from)) try list.append(arena, t.from);
if (!containsStr(list.items, t.to)) try list.append(arena, t.to);
}
return list.items;
}
// ── Renderer ──────────────────────────────────────────────────
fn renderReport(out: *std.Io.Writer, color: bool, sections: []const Section) !void {
try cli.printBold(out, color, "zfin doctor\n", .{});
for (sections) |section| {
try out.print("\n", .{});
try cli.printBold(out, color, "{s}\n", .{section.title});
for (section.checks) |c| {
try out.print(" [", .{});
try cli.printIntent(out, color, c.status.intent(), "{s}", .{c.status.label()});
try out.print("] {s}", .{c.label});
if (c.detail.len > 0) try out.print(": {s}", .{c.detail});
try out.print("\n", .{});
}
}
const oks = countByStatus(sections, .ok);
const warns = countByStatus(sections, .warn);
const fails = countByStatus(sections, .fail);
try out.print("\n", .{});
try cli.printBold(out, color, "Summary: ", .{});
try out.print("{d} OK, ", .{oks});
try cli.printIntent(out, color, if (warns > 0) .warning else .normal, "{d} warning(s)", .{warns});
try out.print(", ", .{});
try cli.printIntent(out, color, if (fails > 0) .negative else .normal, "{d} failure(s)", .{fails});
try out.print("\n", .{});
}
// ── Tests ─────────────────────────────────────────────────────
const testing = std.testing;
test "parseArgs: any argument is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--whatever"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: no args ok" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
_ = try parseArgs(&ctx, &args);
}
test "fileStatus: missing required is warn, missing optional is info" {
try testing.expectEqual(Status.warn, fileStatus(false, null, .required));
try testing.expectEqual(Status.info, fileStatus(false, null, .optional));
}
test "fileStatus: present + parses is ok; present + parse error is fail" {
try testing.expectEqual(Status.ok, fileStatus(true, null, .required));
try testing.expectEqual(Status.ok, fileStatus(true, null, .optional));
try testing.expectEqual(Status.fail, fileStatus(true, "InvalidData", .required));
try testing.expectEqual(Status.fail, fileStatus(true, "InvalidData", .optional));
}
test "coverageCheck: all present is ok" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const needed = [_][]const u8{ "Sample IRA", "Sample Brokerage" };
const known = [_][]const u8{ "Sample IRA", "Sample Brokerage", "Sample Roth" };
const c = try coverageCheck(arena.allocator(), "accounts", &needed, &known, "missing");
try testing.expectEqual(Status.ok, c.status);
}
test "coverageCheck: missing entries produce a warn listing them" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const needed = [_][]const u8{ "Sample IRA", "Sample HSA" };
const known = [_][]const u8{"Sample IRA"};
const c = try coverageCheck(arena.allocator(), "accounts", &needed, &known, "missing from accounts.srf");
try testing.expectEqual(Status.warn, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample HSA") != null);
try testing.expect(std.mem.indexOf(u8, c.detail, "missing from accounts.srf") != null);
}
test "coverageCheck: empty needed is ok" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const known = [_][]const u8{"Sample IRA"};
const c = try coverageCheck(arena.allocator(), "accounts", &.{}, &known, "missing");
try testing.expectEqual(Status.ok, c.status);
}
test "coverageCheck: dedupes repeated missing names" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const needed = [_][]const u8{ "Sample HSA", "Sample HSA", "Sample HSA" };
const c = try coverageCheck(arena.allocator(), "accounts", &needed, &.{}, "missing");
try testing.expectEqual(Status.warn, c.status);
// "Sample HSA" should appear exactly once.
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, c.detail, "Sample HSA"));
}
test "capabilityChecks: present keys are ok, absent keys are info (never warn)" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const cfg: Config = .{
.cache_dir = "/tmp/x",
.tiingo_key = "set",
.polygon_key = null,
};
const checks = try capabilityChecks(arena.allocator(), cfg);
// No capability check is ever a warn/fail - keyless is valid.
for (checks) |c| try testing.expect(c.status == .ok or c.status == .info);
// Find TIINGO (set -> ok) and POLYGON (unset -> info).
var saw_tiingo_ok = false;
var saw_polygon_info = false;
for (checks) |c| {
if (std.mem.eql(u8, c.label, "TIINGO_API_KEY")) {
saw_tiingo_ok = c.status == .ok;
}
if (std.mem.eql(u8, c.label, "POLYGON_API_KEY")) {
saw_polygon_info = c.status == .info;
}
}
try testing.expect(saw_tiingo_ok);
try testing.expect(saw_polygon_info);
}
test "capabilityChecks: includes the always-on keyless capabilities" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const cfg: Config = .{ .cache_dir = "/tmp/x" };
const checks = try capabilityChecks(arena.allocator(), cfg);
var saw_yahoo = false;
var saw_cboe = false;
for (checks) |c| {
if (std.mem.indexOf(u8, c.label, "Yahoo") != null) saw_yahoo = true;
if (std.mem.indexOf(u8, c.label, "CBOE") != null) saw_cboe = true;
}
try testing.expect(saw_yahoo);
try testing.expect(saw_cboe);
}
test "countByStatus: tallies across sections" {
const a = [_]Check{
.{ .status = .ok, .label = "a" },
.{ .status = .warn, .label = "b" },
};
const b = [_]Check{
.{ .status = .fail, .label = "c" },
.{ .status = .ok, .label = "d" },
};
const sections = [_]Section{
.{ .title = "A", .checks = &a },
.{ .title = "B", .checks = &b },
};
try testing.expectEqual(@as(usize, 2), countByStatus(&sections, .ok));
try testing.expectEqual(@as(usize, 1), countByStatus(&sections, .warn));
try testing.expectEqual(@as(usize, 1), countByStatus(&sections, .fail));
try testing.expectEqual(@as(usize, 0), countByStatus(&sections, .info));
}
test "renderReport: writes sections, labels, and a summary (no color)" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const checks = [_]Check{
.{ .status = .ok, .label = "portfolio.srf", .detail = "10 lots" },
.{ .status = .fail, .label = "metadata.srf", .detail = "parse error: InvalidData" },
};
const sections = [_]Section{.{ .title = "Files", .checks = &checks }};
try renderReport(&w, false, &sections);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Files") != null);
try testing.expect(std.mem.indexOf(u8, out, "[OK ] portfolio.srf: 10 lots") != null);
try testing.expect(std.mem.indexOf(u8, out, "[FAIL] metadata.srf: parse error: InvalidData") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 OK") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 failure(s)") != null);
// color=false -> no ANSI escapes.
try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "fileStatus drives exit: a parse failure yields a FAIL the summary counts" {
const checks = [_]Check{.{ .status = fileStatus(true, "InvalidData", .optional), .label = "x" }};
const sections = [_]Section{.{ .title = "Files", .checks = &checks }};
try testing.expect(countByStatus(&sections, .fail) == 1);
}
// ── Cross-reference extraction helpers ────────────────────────
fn testLot(symbol: []const u8, sec: @import("../models/portfolio.zig").LotType, account: ?[]const u8) Lot {
return .{
.symbol = symbol,
.shares = 1,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = sec,
.account = account,
};
}
test "uniqueAccounts: dedupes and skips null accounts" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const lots = [_]Lot{
testLot("VTI", .stock, "Sample IRA"),
testLot("CASH", .cash, null),
testLot("BND", .stock, "Sample Brokerage"),
testLot("VTI", .stock, "Sample IRA"),
};
const got = try uniqueAccounts(arena.allocator(), &lots);
try testing.expectEqual(@as(usize, 2), got.len);
try testing.expectEqualStrings("Sample IRA", got[0]);
try testing.expectEqualStrings("Sample Brokerage", got[1]);
}
test "classifiableSymbols: only stock/ETF lots, deduped (cash/option/cd excluded)" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const lots = [_]Lot{
testLot("VTI", .stock, "A"),
testLot("SPY 250101C00500000", .option, "A"),
testLot("CASH", .cash, "A"),
testLot("912828ZT0", .cd, "A"),
testLot("VTI", .stock, "B"),
testLot("AAPL", .stock, "A"),
};
const got = try classifiableSymbols(arena.allocator(), &lots);
try testing.expectEqual(@as(usize, 2), got.len);
try testing.expectEqualStrings("VTI", got[0]);
try testing.expectEqualStrings("AAPL", got[1]);
}
test "classifiableSymbols: resolves the ticker:: alias (DI-SPX/ticker::SPY -> SPY)" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var di = testLot("DI-SPX", .stock, "A");
di.ticker = "SPY"; // direct-indexing proxy aliased to SPY
var plain = testLot("02315N402", .stock, "A"); // CUSIP, no ticker alias
_ = &plain;
const lots = [_]Lot{ di, plain };
const got = try classifiableSymbols(arena.allocator(), &lots);
// DI-SPX resolves to its ticker SPY; the unaliased CUSIP stays itself.
try testing.expectEqual(@as(usize, 2), got.len);
try testing.expectEqualStrings("SPY", got[0]);
try testing.expectEqualStrings("02315N402", got[1]);
}
test "accountNames / knownAccountNames: from AccountMap and union with lots" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .taxable },
.{ .account = "Sample Roth", .tax_type = .roth },
};
const am: analysis.AccountMap = .{ .entries = &entries, .allocator = arena.allocator() };
const names = try accountNames(arena.allocator(), am);
try testing.expectEqual(@as(usize, 2), names.len);
const lots = [_]Lot{ testLot("VTI", .stock, "Sample Brokerage"), testLot("BND", .stock, "Sample IRA") };
const known = try knownAccountNames(arena.allocator(), am, &lots);
// Union: IRA, Roth (from map) + Brokerage (from lot); IRA not duplicated.
try testing.expectEqual(@as(usize, 3), known.len);
try testing.expect(containsStr(known, "Sample Brokerage"));
try testing.expect(containsStr(known, "Sample Roth"));
}
test "classifiedSymbols: from ClassificationMap, deduped" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var entries = [_]classification.ClassificationEntry{
.{ .symbol = "VTI" },
.{ .symbol = "AAPL" },
.{ .symbol = "VTI" },
};
const cm: classification.ClassificationMap = .{ .entries = &entries, .allocator = arena.allocator() };
const got = try classifiedSymbols(arena.allocator(), cm);
try testing.expectEqual(@as(usize, 2), got.len);
}
test "transferEndpoints: collects from/to deduped" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var transfers = [_]transaction_log.TransferRecord{
.{ .transfer = Date.fromYmd(2024, 1, 1), .amount = 100, .from = "Sample IRA", .to = "Sample Brokerage", .dest_lot = .{ .cash = {} } },
.{ .transfer = Date.fromYmd(2024, 2, 1), .amount = 50, .from = "Sample IRA", .to = "Sample HSA", .dest_lot = .{ .cash = {} } },
};
const tl: transaction_log.TransactionLog = .{ .transfers = &transfers, .allocator = arena.allocator() };
const got = try transferEndpoints(arena.allocator(), tl);
// IRA, Brokerage, HSA - IRA appears in both records but once here.
try testing.expectEqual(@as(usize, 3), got.len);
try testing.expect(containsStr(got, "Sample IRA"));
try testing.expect(containsStr(got, "Sample HSA"));
}
test "cross-ref end to end: missing account surfaces as a warn" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const lots = [_]Lot{ testLot("VTI", .stock, "Sample IRA"), testLot("BND", .stock, "Sample HSA") };
var entries = [_]analysis.AccountTaxEntry{.{ .account = "Sample IRA", .tax_type = .taxable }};
const am: analysis.AccountMap = .{ .entries = &entries, .allocator = arena.allocator() };
const lot_accts = try uniqueAccounts(arena.allocator(), &lots);
const known = try accountNames(arena.allocator(), am);
const c = try coverageCheck(arena.allocator(), "accounts.srf coverage", lot_accts, known, "accounts in portfolio missing from accounts.srf");
try testing.expectEqual(Status.warn, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "Sample HSA") != null);
}
test "siblingPath: joins a filename onto the anchor's directory" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
try testing.expectEqualStrings("/home/u/data/accounts.srf", try siblingPath(a, "/home/u/data/portfolio.srf", "accounts.srf"));
// Bare filename (no separator) -> sibling is just the name.
try testing.expectEqualStrings("accounts.srf", try siblingPath(a, "portfolio.srf", "accounts.srf"));
}
test "validateSrf: accepts a valid stream, rejects a headerless one" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try validateSrf(arena.allocator(), "#!srfv1\nsymbol::VTI\n");
try testing.expectError(error.InvalidSrf, validateSrf(arena.allocator(), "no magic header here"));
}
test "checkSrfFile: present + parses -> ok" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "metadata.srf", .data = "#!srfv1\nsymbol::VTI,asset_class::US Large Cap\n" });
const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
const path = try std.fmt.allocPrint(a, "{s}/metadata.srf", .{dir});
const c = checkSrfFile(testing.io, a, "metadata.srf", path, .optional, vMetadata);
try testing.expectEqual(Status.ok, c.status);
}
test "checkSrfFile: present + unparseable -> fail" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "metadata.srf", .data = "this is not srf\n" });
const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
const path = try std.fmt.allocPrint(a, "{s}/metadata.srf", .{dir});
const c = checkSrfFile(testing.io, a, "metadata.srf", path, .optional, vMetadata);
try testing.expectEqual(Status.fail, c.status);
try testing.expect(std.mem.indexOf(u8, c.detail, "parse error") != null);
}
test "checkSrfFile: missing optional -> info, missing required -> warn" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir = try tmp.dir.realPathFileAlloc(testing.io, ".", a);
const missing = try std.fmt.allocPrint(a, "{s}/nope.srf", .{dir});
try testing.expectEqual(Status.info, checkSrfFile(testing.io, a, "nope.srf", missing, .optional, vMetadata).status);
try testing.expectEqual(Status.warn, checkSrfFile(testing.io, a, "nope.srf", missing, .required, vMetadata).status);
}
test "parseServerVersion: extracts version from a zfin-server /help body" {
const body =
\\zfin-server f3c1690 - financial data API
\\
\\Endpoints:
\\ GET /{SYMBOL}/returns Trailing returns
;
try testing.expectEqualStrings("f3c1690", parseServerVersion(body).?);
}
test "parseServerVersion: tolerates leading whitespace" {
try testing.expectEqualStrings("abc123", parseServerVersion("\n zfin-server abc123 - x").?);
}
test "parseServerVersion: non-zfin body returns null" {
try testing.expectEqual(@as(?[]const u8, null), parseServerVersion("<html>404 Not Found</html>"));
try testing.expectEqual(@as(?[]const u8, null), parseServerVersion(""));
// Prefix present but no version token after it.
try testing.expectEqual(@as(?[]const u8, null), parseServerVersion("zfin-server "));
}
test "trimTrailingSlash: drops a single trailing slash" {
try testing.expectEqualStrings("https://h", trimTrailingSlash("https://h/"));
try testing.expectEqualStrings("https://h", trimTrailingSlash("https://h"));
}