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