//! `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 }), }; } /// Validate the hand-maintained `harvested` / `harvested_date` pair on /// each `accounts.srf` entry. Pure over the parsed map so every branch /// is unit-testable. /// /// Two failure modes, both hand-edit typos rather than corruption, so /// both are WARN and neither stops anything: /// /// - `harvested` with no `harvested_date`: renders nothing, because a /// hand-copied figure with no date is indistinguishable from a /// stale one. Silently invisible is worse than a nag. /// - `harvested_date` in the future relative to `as_of`: also renders /// nothing, and is almost always a year typo. /// /// A `harvested_date` older than 12 months is NOT flagged - that's the /// designed behavior (the annotation retires itself), not an error. fn harvestedFieldChecks(arena: std.mem.Allocator, am: analysis.AccountMap, as_of: Date) !Check { var undated: std.ArrayList([]const u8) = .empty; var future: std.ArrayList([]const u8) = .empty; var configured: usize = 0; for (am.entries) |e| { if (e.harvested == null) continue; configured += 1; const on = e.harvested_date orelse { try undated.append(arena, e.account); continue; }; if (as_of.lessThan(on)) try future.append(arena, e.account); } if (configured == 0) { return .{ .status = .info, .label = "accounts.srf harvested", .detail = "no accounts declare a harvested figure" }; } if (undated.items.len == 0 and future.items.len == 0) { return .{ .status = .ok, .label = "accounts.srf harvested", .detail = try std.fmt.allocPrint(arena, "{d} account(s) with a dated harvested figure", .{configured}), }; } if (undated.items.len > 0 and future.items.len > 0) { return .{ .status = .warn, .label = "accounts.srf harvested", .detail = try std.fmt.allocPrint(arena, "missing harvested_date: {s}; harvested_date in the future: {s}", .{ try joinCapped(arena, undated.items, 6), try joinCapped(arena, future.items, 6), }), }; } if (undated.items.len > 0) { return .{ .status = .warn, .label = "accounts.srf harvested", .detail = try std.fmt.allocPrint(arena, "harvested set but harvested_date missing (annotation will not render): {s}", .{ try joinCapped(arena, undated.items, 6), }), }; } return .{ .status = .warn, .label = "accounts.srf harvested", .detail = try std.fmt.allocPrint(arena, "harvested_date is in the future (annotation will not render): {s}", .{ try joinCapped(arena, future.items, 6), }), }; } /// Validate the `tax_mix_*` carve-outs in accounts.srf. /// /// Two distinct failure modes, reported in priority order: /// /// - **A rejected mix.** `AccountTaxEntry.taxMixChecked` refuses a /// declaration that is non-positive, non-finite, names the account's /// own `tax_type`, or over-allocates past 100 - and silently falls /// back to the bare `tax_type`. That fallback is the whole reason /// this check exists: without it the only signal is a log line the /// user probably never sees, and their By Tax Type breakdown is /// quietly not what they asked for. WARN. /// - **A missing or future `tax_mix_date`.** Unlike `harvested`, a /// dateless mix still applies, so this is advisory rather than /// "your data is invisible". Still worth a WARN: an undated split /// can't be aged, so `zfin audit` can never tell you it went stale. /// /// A merely *old* `tax_mix_date` is not flagged here - that's `audit`'s /// job (`tax_mix_stale_days`), and duplicating it would double-report /// the same routine maintenance. fn taxMixFieldChecks(arena: std.mem.Allocator, am: analysis.AccountMap, as_of: Date) !Check { const label = "accounts.srf tax mix"; var rejected: std.ArrayList([]const u8) = .empty; var undated: std.ArrayList([]const u8) = .empty; var future: std.ArrayList([]const u8) = .empty; var configured: usize = 0; // Borrowed from `TaxMixProblem.label`; names the first cause found // so the message says *why*, not just "invalid". var first_cause: []const u8 = ""; for (am.entries) |e| { if (!e.hasTaxMix()) continue; configured += 1; if (e.taxMixChecked().problem) |p| { if (first_cause.len == 0) first_cause = p.label(); try rejected.append(arena, e.account); // A rejected mix's date is moot; don't pile on. continue; } const on = e.tax_mix_date orelse { try undated.append(arena, e.account); continue; }; if (as_of.lessThan(on)) try future.append(arena, e.account); } if (configured == 0) { return .{ .status = .info, .label = label, .detail = "no accounts declare a tax mix" }; } if (rejected.items.len > 0) { return .{ .status = .warn, .label = label, .detail = try std.fmt.allocPrint(arena, "{s} (falling back to tax_type): {s}", .{ first_cause, try joinCapped(arena, rejected.items, 6), }), }; } if (undated.items.len > 0 and future.items.len > 0) { return .{ .status = .warn, .label = label, .detail = try std.fmt.allocPrint(arena, "missing tax_mix_date: {s}; tax_mix_date in the future: {s}", .{ try joinCapped(arena, undated.items, 6), try joinCapped(arena, future.items, 6), }), }; } if (undated.items.len > 0) { return .{ .status = .warn, .label = label, .detail = try std.fmt.allocPrint(arena, "tax mix declared but tax_mix_date missing (audit cannot age it): {s}", .{ try joinCapped(arena, undated.items, 6), }), }; } if (future.items.len > 0) { return .{ .status = .warn, .label = label, .detail = try std.fmt.allocPrint(arena, "tax_mix_date is in the future: {s}", .{ try joinCapped(arena, future.items, 6), }), }; } return .{ .status = .ok, .label = label, .detail = try std.fmt.allocPrint(arena, "{d} account(s) with a valid, dated tax mix", .{configured}), }; } /// 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 - `; returns /// `` (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 // /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")); try checks.append(arena, try harvestedFieldChecks(arena, am, ctx.today)); try checks.append(arena, try taxMixFieldChecks(arena, am, ctx.today)); } else { try checks.append(arena, .{ .status = .info, .label = "accounts.srf coverage", .detail = "skipped (accounts.srf not loaded)" }); try checks.append(arena, .{ .status = .info, .label = "accounts.srf harvested", .detail = "skipped (accounts.srf not loaded)" }); try checks.append(arena, .{ .status = .info, .label = "accounts.srf tax mix", .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 "harvestedFieldChecks: no account declares harvested -> info" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.info, c.status); } test "harvestedFieldChecks: dated figures are ok and counted" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24 \\account::Sample Trust,tax_type::taxable,harvested:num:-8000,harvested_date::2025-11-01 \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.ok, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "2 account(s)") != null); } test "harvestedFieldChecks: a stale harvested_date is not a warning" { // Retiring the annotation after 12 months is designed behavior, not // an error - doctor must stay quiet about it. var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2020-01-01 ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.ok, c.status); } test "harvestedFieldChecks: harvested without a date warns and names the account" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300 ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "harvested_date missing") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null); } test "harvestedFieldChecks: future harvested_date warns and names the account" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24 ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "future") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null); } test "harvestedFieldChecks: both problems at once are reported together" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300 \\account::Sample Trust,tax_type::taxable,harvested:num:8000,harvested_date::2027-06-24 ); defer am.deinit(); const c = try harvestedFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Tax Loss") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Trust") != null); } test "taxMixFieldChecks: no account declares a mix -> info" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.info, c.status); try testing.expectEqualStrings("accounts.srf tax mix", c.label); } test "taxMixFieldChecks: a valid dated mix is ok and counted" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-07-01 \\account::Sample Brokerage,tax_type::taxable ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.ok, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "1 account(s)") != null); } test "taxMixFieldChecks: a merely old tax_mix_date is audit's job, not doctor's" { // Staleness belongs to `zfin audit` (tax_mix_stale_days). Reporting it // here too would double-nag on routine maintenance. var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2020-01-01 ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.ok, c.status); } test "taxMixFieldChecks: a mix without a date warns and names the account" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4 ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample 401k") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "tax_mix_date") != null); } test "taxMixFieldChecks: a future tax_mix_date warns and names the account" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2027-01-01 ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "future") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample 401k") != null); } test "taxMixFieldChecks: undated and future at once are reported together" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4 \\account::Sample Other 401k,tax_type::traditional,tax_mix_roth:num:10,tax_mix_date::2027-01-01 ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample 401k") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Other 401k") != null); } test "taxMixFieldChecks: every rejection reason warns and says why" { // A rejected mix silently falls back to the bare tax_type, so this // check is the only place the user learns their split isn't applying. // Each cause must name itself rather than collapsing to "invalid". const cases = [_]struct { srf: []const u8, needle: []const u8 }{ .{ .srf = "account::Sample 401k,tax_type::traditional,tax_mix_roth:num:0", .needle = "> 0" }, .{ .srf = "account::Sample 401k,tax_type::traditional,tax_mix_roth:num:-5", .needle = "> 0" }, .{ .srf = "account::Sample 401k,tax_type::traditional,tax_mix_roth:num:nan", .needle = "finite" }, .{ .srf = "account::Sample 401k,tax_type::traditional,tax_mix_traditional:num:40", .needle = "own tax_type" }, .{ .srf = "account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140", .needle = "less than 100" }, }; for (cases) |case| { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const data = try std.fmt.allocPrint(arena.allocator(), "#!srfv1\n{s}\n", .{case.srf}); var am = try analysis.parseAccountsFile(arena.allocator(), data); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, case.needle) != null); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample 401k") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "falling back to tax_type") != null); } } test "taxMixFieldChecks: a rejected mix outranks a missing date" { // Both accounts have something to report; the rejection is the more // actionable one because it means the split isn't applying at all. var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); var am = try analysis.parseAccountsFile(arena.allocator(), \\#!srfv1 \\account::Sample Undated,tax_type::traditional,tax_mix_roth:num:22.4 \\account::Sample Broken,tax_type::traditional,tax_mix_roth:num:140 ); defer am.deinit(); const c = try taxMixFieldChecks(arena.allocator(), am, Date.fromYmd(2026, 7, 25)); try testing.expectEqual(Status.warn, c.status); try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Broken") != null); try testing.expect(std.mem.indexOf(u8, c.detail, "falling back to tax_type") != null); // The undated account is not mentioned; it'll surface once the // rejection is fixed. try testing.expect(std.mem.indexOf(u8, c.detail, "Sample Undated") == null); } 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(§ions, .ok)); try testing.expectEqual(@as(usize, 1), countByStatus(§ions, .warn)); try testing.expectEqual(@as(usize, 1), countByStatus(§ions, .fail)); try testing.expectEqual(@as(usize, 0), countByStatus(§ions, .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, §ions); 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(§ions, .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("404 Not Found")); 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")); }