diff --git a/TODO.md b/TODO.md index b608d6a..d60b97a 100644 --- a/TODO.md +++ b/TODO.md @@ -141,61 +141,36 @@ have PNG export and were deferred: exports PNG natively today; would need an external dependency or a pixel-buffer-to-format conversion. -## `zfin doctor` health-check command — priority LOW +## Note-field handling: holistic review (priority LOW) -Front-door command for the file constellation and environment. -Answers "is my zfin setup sane?" without making any changes. +The lot `note::` field is nominally a human annotation, but it leaks +into behavior in at least one place: for CUSIP-like holdings with a +note, `valuation.shortLabel(note)` becomes the allocation's +`display_symbol` (`src/analytics/valuation.zig`, ~line 396), and the +classification engine then matches `metadata.srf` entries against BOTH +the allocation symbol AND `display_symbol` (`src/analytics/analysis.zig`, +~line 611). So a free-text note can silently become a +classification-matching key, which is surprising and fragile (editing +a note could change what classifies). -The configuration surface has grown organically and only the README -explains the file layout: +Surfaced while building `zfin doctor`: its metadata cross-reference +deliberately checks only `lot.priceSymbol()` (the ticker alias or raw +symbol), NOT the note-derived `display_symbol`, because coupling a +diagnostic to free-text note content felt wrong. That asymmetry is the +tell: the cross-ref and the engine now disagree on what counts as +"classified" for a note-bearing CUSIP. -- `portfolio.srf` — lots -- `metadata.srf` — classifications -- `accounts.srf` — tax types -- `projections.srf` — retirement config -- `transaction_log.srf` — internal transfers -- `imported_values.srf` — back-history -- `history/*-portfolio.srf` — snapshots -- `~/.config/zfin/keys.srf` — keybinds -- `~/.config/zfin/theme.srf` — colors +Do a pass over every `note` consumer and classify each use as +display-only vs behavior-affecting; decide whether note-derived values +should ever be matching keys, document/justify any that stay, and then +reconcile `doctor`'s metadata cross-ref with whatever the engine +settles on. Starting points (grep `\.note` and `note::`): -Plus 5 API keys, a cache directory, and an optional `ZFIN_SERVER`. - -### v1 scope (full health check) - -- **File inventory + parse check.** For each of the files above: - does it exist, where was it resolved from (cwd vs `ZFIN_HOME` vs - `~/.config/zfin`), and does it parse cleanly. No fixes. -- **Cross-reference checks.** Every account in `portfolio.srf` - has an `accounts.srf` entry. Every held symbol has a - `metadata.srf` entry (or is opted out). Every snapshot file - parses as a portfolio. `transaction_log.srf` records reference - real account names. -- **Environment audit.** Which API keys are set (presence only, - never the value). Cache size + symbol count. `ZFIN_SERVER` - reachability if set (HEAD/OPTIONS, low timeout). Staleness of - hand-maintained data files (T-bill rates, Shiller `ie_data.csv`) - — same registry as `data/staleness.zig`. -- **Output shape.** Sectioned, color-coded. Every check is - `OK` / `WARN` / `FAIL` with a one-line context. Exit code 0 on - all-OK or warnings only; non-zero only on FAIL. Suitable for - CI / cron / pre-commit. - -### Driver - -The file constellation has grown to nine files in three locations, -plus five API keys, plus the cache, plus the optional server. -Today only the README explains the structure. A `doctor` command -surfaces it, catches regressions, and is the obvious place to point -new users (or to point future-self after a long break). - -### Open question for when this is picked up - -Does this *replace* the portfolio-hygiene portion of `audit`, or -live alongside it? Probably alongside — `audit` is reconciliation -against external broker exports, `doctor` is internal-state -validation. But worth confirming the boundary before implementing -to avoid duplicated checks. +- `valuation.shortLabel` -> `display_symbol`, used as a classification + match key in `analysis.zig` (the main offender). +- Cash / illiquid / CD row rendering (display labels; likely fine). +- `transaction_log` transfer `note` (annotation). +- audit / contributions matchers (do any key off notes?). ## Split `audit.zig` into per-broker reconcilers — priority LOW diff --git a/src/cache/store.zig b/src/cache/store.zig index 1b0814f..dc29059 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -274,6 +274,52 @@ pub const Store = struct { }; } + /// Aggregate on-disk cache statistics. + pub const DiskStats = struct { symbols: usize = 0, files: usize = 0, bytes: u64 = 0 }; + + /// Walk the cache directory read-only and tally symbol + /// subdirectories, total data files, and total bytes. Top-level + /// files (e.g. `cusip_tickers.srf`) count toward `files`/`bytes` + /// but not `symbols`. Returns all-zero (not an error) when the + /// cache directory does not exist yet. No mutation, no fetches. + pub fn diskStats(self: *Store) DiskStats { + const io = self.io; + var stats: DiskStats = .{}; + var dir = std.Io.Dir.cwd().openDir(io, self.cache_dir, .{ .iterate = true }) catch return stats; + defer dir.close(io); + + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + switch (entry.kind) { + .file => { + const path = std.fs.path.join(self.allocator, &.{ self.cache_dir, entry.name }) catch continue; + defer self.allocator.free(path); + const st = std.Io.Dir.cwd().statFile(io, path, .{}) catch continue; + stats.files += 1; + stats.bytes += st.size; + }, + .directory => { + stats.symbols += 1; + const subpath = std.fs.path.join(self.allocator, &.{ self.cache_dir, entry.name }) catch continue; + defer self.allocator.free(subpath); + var sub = std.Io.Dir.cwd().openDir(io, subpath, .{ .iterate = true }) catch continue; + defer sub.close(io); + var sub_iter = sub.iterate(); + while (sub_iter.next(io) catch null) |f| { + if (f.kind != .file) continue; + const fpath = std.fs.path.join(self.allocator, &.{ subpath, f.name }) catch continue; + defer self.allocator.free(fpath); + const st = std.Io.Dir.cwd().statFile(io, fpath, .{}) catch continue; + stats.files += 1; + stats.bytes += st.size; + } + }, + else => {}, + } + } + return stats; + } + // ── Generic typed API ──────────────────────────────────────── /// Map a model type to its cache DataType. @@ -1952,6 +1998,35 @@ test "writeMerged Dividend: union sorted desc, new entry added" { try std.testing.expect(result.data[2].ex_date.eql(Date.fromYmd(2024, 2, 15))); } +test "diskStats: empty cache is all zeros; populated counts symbols/files/bytes" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(dir_path); + + var s = Store.init(io, allocator, dir_path); + + // Fresh tmp dir → nothing cached. + const empty = s.diskStats(); + try std.testing.expectEqual(@as(usize, 0), empty.symbols); + try std.testing.expectEqual(@as(usize, 0), empty.files); + try std.testing.expectEqual(@as(u64, 0), empty.bytes); + + // Two symbols; AAA carries two data types, BBB one. + var divs = [_]Dividend{.{ .ex_date = Date.fromYmd(2024, 5, 15), .amount = 0.50, .type = .regular }}; + var splits = [_]Split{.{ .date = Date.fromYmd(2020, 8, 31), .numerator = 4, .denominator = 1 }}; + s.write(Dividend, "AAA", divs[0..], .{ .seconds = Ttl.dividends }); + s.write(Split, "AAA", splits[0..], .{ .seconds = Ttl.splits }); + s.write(Dividend, "BBB", divs[0..], .{ .seconds = Ttl.dividends }); + + const stats = s.diskStats(); + try std.testing.expectEqual(@as(usize, 2), stats.symbols); // AAA, BBB + try std.testing.expectEqual(@as(usize, 3), stats.files); // AAA/{dividends,splits} + BBB/dividends + try std.testing.expect(stats.bytes > 0); +} + test "writeMerged Dividend: no-change merge still rewrites to refresh expires" { // The "nothing changed" path used to skip the rewrite as an // optimization. Problem: once the on-disk `#!expires=` aged diff --git a/src/commands/cache.zig b/src/commands/cache.zig index f1657cc..3b924aa 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -289,7 +289,7 @@ fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8, }; } -fn formatSize(buf: *[10]u8, size: u64) []const u8 { +pub fn formatSize(buf: *[10]u8, size: u64) []const u8 { if (size < 1024) { return std.fmt.bufPrint(buf, "{d} B", .{size}) catch "?"; } else if (size < 1024 * 1024) { diff --git a/src/commands/doctor.zig b/src/commands/doctor.zig new file mode 100644 index 0000000..49eaec5 --- /dev/null +++ b/src/commands/doctor.zig @@ -0,0 +1,1061 @@ +//! `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 - `; 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")); + } 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(§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")); +} diff --git a/src/data/staleness.zig b/src/data/staleness.zig index 8663a5e..72077d3 100644 --- a/src/data/staleness.zig +++ b/src/data/staleness.zig @@ -87,6 +87,23 @@ pub const entries = [_]StaleEntry{ }, }; +/// Per-entry refresh verdict as of `as_of`. +pub const Status = enum { ok, overdue }; + +/// Verdict for a single entry as of `as_of`, applying the same window +/// logic as `check` (see the module doc-block). Exposed so callers +/// that want an explicit per-entry OK/overdue line (e.g. `zfin doctor`) +/// can render every entry, not just the overdue ones that `check` +/// emits. +pub fn entryStatus(entry: StaleEntry, as_of: Date) Status { + const this_years_due = Date.fromYmd(as_of.year(), entry.due_month, entry.due_day); + // Not yet nag season. + if (as_of.lessThan(this_years_due)) return .ok; + // Already refreshed this cycle. + if (!entry.last_updated.lessThan(this_years_due)) return .ok; + return .overdue; +} + /// Write a warning line for each entry in `entries` that is overdue /// for refresh as of `as_of`. Writes nothing when every entry is /// fresh. Entries are processed in order; multiple warnings are @@ -98,15 +115,12 @@ pub fn check( ) !void { var wrote_any = false; for (list) |entry| { + if (entryStatus(entry, as_of) == .ok) continue; const this_years_due = Date.fromYmd( as_of.year(), entry.due_month, entry.due_day, ); - // Not yet nag season. - if (as_of.lessThan(this_years_due)) continue; - // Already refreshed this cycle. - if (!entry.last_updated.lessThan(this_years_due)) continue; if (wrote_any) try writer.writeAll("\n"); wrote_any = true; @@ -308,3 +322,19 @@ test "real registry compiles and is non-empty" { try std.testing.expect(e.due_day >= 1 and e.due_day <= 31); } } + +test "entryStatus: ok before due date" { + const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2020, 1, 1), .due_month = 4, .due_day = 1, .source_file = "x" }; + try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 3, 31))); +} + +test "entryStatus: overdue on/after due date when stale" { + const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "x" }; + try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 4, 1))); + try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 6, 15))); +} + +test "entryStatus: ok when refreshed this cycle" { + const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2026, 4, 1), .due_month = 4, .due_day = 1, .source_file = "x" }; + try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 6, 15))); +} diff --git a/src/main.zig b/src/main.zig index 5fde0c0..27d31b4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -43,6 +43,7 @@ const command_modules = .{ // Infrastructure .cache = @import("commands/cache.zig"), + .doctor = @import("commands/doctor.zig"), .version = @import("commands/version.zig"), };