zfin/src/commands/audit/hygiene.zig

2286 lines
104 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Flagless portfolio hygiene check for the `audit` command.
//!
//! When `zfin audit` runs with no brokerage flag, this is what
//! executes: stale-manual-price detection, working-tree-vs-HEAD
//! price/date mismatch detection, account-cadence overdue checks,
//! stale hand-declared `harvested` figures, automatic brokerage-file
//! discovery + reconciliation, and the large-new-lot transfer nudge.
//!
//! The reconciliation in Section 5 delegates to the per-broker
//! modules (`fidelity.zig`, `schwab.zig`) and the shared display in
//! `common.zig`; this file owns only the hygiene logic, so a future
//! `zfin doctor` can reuse it without pulling in the reconciler
//! surface.
const std = @import("std");
const zfin = @import("../../root.zig");
const cli = @import("../common.zig");
const framework = @import("../framework.zig");
const contributions = @import("../contributions.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const Date = @import("../../Date.zig");
const srf = @import("srf");
const git = @import("../../git.zig");
const test_git = @import("../../testutil/git.zig");
const common = @import("common.zig");
const fidelity = @import("../../analytics/reconcile/fidelity.zig");
const schwab = @import("schwab.zig");
const discover = zfin.brokerage.discover;
const fmt = cli.fmt;
// ── Hygiene check (flagless audit) ──────────────────────────
/// Constants for hygiene check behavior. Kept as named constants for
/// easy future tuning.
/// How recent a brokerage export must be for the hygiene check to pick it
/// up. Passed to `discover.brokerFiles` rather than living inside it, because
/// callers disagree: a hygiene report wants only today's downloads, while a
/// reconciler is better served by seeing a stale file and saying so.
const audit_file_max_age_hours = 24;
pub const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
/// Safety cap on how many portfolio commits the account-cadence walk
/// scans when resolving each account's last-update timestamp. The walk
/// normally early-exits once every account resolves; this bounds the
/// pathological case where an account never changed in tracked history
/// (so it never resolves) from forcing a scan of the entire repo.
const max_history_commits_scanned: usize = 500;
/// Age at which `zfin audit` starts nagging that an account's
/// hand-declared `harvested` figure (see `AccountTaxEntry.harvested`)
/// needs a refresh.
///
/// Not configurable, and deliberately so: unlike `update_cadence` -
/// which nags every account by default and therefore *needs* a
/// per-account silence knob - this section only ever considers accounts
/// that explicitly declare `harvested`. Not wanting the nag and not
/// wanting the field are the same choice, so the opt-out already exists.
///
/// 90 days is the same interval as `UpdateCadence.quarterly`, but is
/// deliberately an independent constant: retuning the account
/// reconciliation cadence shouldn't silently move this.
const harvested_stale_days: u32 = 90;
/// Age at which `zfin audit` starts nagging that an account's declared
/// `tax_mix_*` carve-outs (see `AccountTaxEntry.tax_mix_taxable`) need a
/// refresh.
///
/// Same opt-in-by-declaring logic as `harvested_stale_days`, and the
/// same 90 days, but again an independent constant: the two figures
/// drift for unrelated reasons. A tax mix drifts as payroll
/// contributions land in different sleeves, which is roughly quarterly
/// in effect; a harvested total drifts whenever the sleeve churns.
///
/// Note this nag is the *only* consequence of a stale mix. Unlike the
/// harvested annotation, the mix never stops applying - see
/// `AccountTaxEntry.tax_mix_date`.
const tax_mix_stale_days: u32 = 90;
/// Compute which accounts have been modified between two parsed portfolios.
/// Returns a set of account names that have any lot-level differences.
/// Compares by serializing each lot to a canonical string per account,
/// sorting, and checking for equality. Simple and robust -- any field
/// change in a lot produces a different string.
fn findModifiedAccounts(
allocator: std.mem.Allocator,
old_portfolio: zfin.Portfolio,
new_portfolio: zfin.Portfolio,
) !std.StringHashMap(void) {
var modified = std.StringHashMap(void).init(allocator);
errdefer modified.deinit();
// Collect serialized lot strings grouped by account
var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
defer {
var it = old_accts.valueIterator();
while (it.next()) |v| {
for (v.items) |s| allocator.free(s);
v.deinit(allocator);
}
old_accts.deinit();
}
var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
defer {
var it = new_accts.valueIterator();
while (it.next()) |v| {
for (v.items) |s| allocator.free(s);
v.deinit(allocator);
}
new_accts.deinit();
}
for (old_portfolio.lots) |lot| {
const acct = lot.account orelse continue;
const entry = try old_accts.getOrPut(acct);
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
}
for (new_portfolio.lots) |lot| {
const acct = lot.account orelse continue;
const entry = try new_accts.getOrPut(acct);
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
}
// Compare per account: sort both lists, then check equality
var all = std.StringHashMap(void).init(allocator);
defer all.deinit();
{
var it = old_accts.keyIterator();
while (it.next()) |k| try all.put(k.*, {});
}
{
var it = new_accts.keyIterator();
while (it.next()) |k| try all.put(k.*, {});
}
var acct_it = all.keyIterator();
while (acct_it.next()) |acct_key| {
const acct = acct_key.*;
const old_ptr = old_accts.getPtr(acct);
const new_ptr = new_accts.getPtr(acct);
const old_len = if (old_ptr) |p| p.items.len else 0;
const new_len = if (new_ptr) |p| p.items.len else 0;
if (old_len != new_len) {
try modified.put(acct, {});
continue;
}
if (old_len == 0) continue;
const old_items = old_ptr.?.items;
const new_items = new_ptr.?.items;
std.mem.sort([]const u8, old_items, {}, strLessThan);
std.mem.sort([]const u8, new_items, {}, strLessThan);
var differs = false;
for (old_items, new_items) |a, b| {
if (!std.mem.eql(u8, a, b)) {
differs = true;
break;
}
}
if (differs) try modified.put(acct, {});
}
return modified;
}
fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
/// Serialize a lot to a canonical SRF string for comparison.
/// Uses the SRF serializer with comptime reflection, so any new
/// field added to Lot is automatically included.
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
const lots = [_]portfolio_mod.Lot{lot};
return std.fmt.allocPrint(allocator, "{f}", .{srf.fmt(portfolio_mod.Lot, &lots, .{ .emit_directives = false })});
}
/// Resolve, per account, the committer timestamp (Unix epoch seconds)
/// of the newest commit in which that account's lots changed in
/// `ri.rel_path`. Walks the file's git history newest-to-oldest,
/// diffing adjacent commit pairs and attributing each change to the
/// newer commit of the pair; results are written into `out`.
///
/// The walk covers full history (no cadence-derived cutoff): an
/// account more than 2x its cadence overdue - exactly the ones we most
/// want an age readout for - would otherwise always fall outside a
/// 2x-cadence window and degrade to "no update history found". It
/// stops early once every account in `all_accounts` has a timestamp,
/// or after scanning `max_history_commits_scanned` commits (the safety
/// valve for a never-changed account).
///
/// Accounts with no detectable change in tracked history are simply
/// absent from `out` (e.g. an account present since before the first
/// portfolio commit, whose initial add has no parent commit to diff
/// against); the caller renders those as "no update history found".
///
/// Keys written into `out` are borrowed from `all_accounts` (stable
/// working-copy account-name pointers), never from the transient
/// historical portfolio strings. Git unavailability degrades to an
/// empty result rather than an error.
fn findLastUpdateTimestamps(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
ri: git.RepoInfo,
all_accounts: *const std.StringHashMap(void),
out: *std.StringHashMap(i64),
) !void {
const commits = git.listCommitsTouching(io, allocator, env, ri.root, ri.rel_path, null) catch &.{};
defer git.freeCommitTouches(allocator, commits);
var prev_data: ?[]const u8 = null;
defer if (prev_data) |pd| allocator.free(pd);
for (commits, 0..) |ct, ci| {
// Stop once every account is resolved, or at the scan cap.
if (out.count() >= all_accounts.count()) break;
if (ci >= max_history_commits_scanned) break;
const rev_data = git.show(io, allocator, env, ri.root, ct.commit, ri.rel_path) catch continue;
if (ci > 0) {
if (prev_data) |pd| {
// rev_data is older, pd is newer (commits are newest-first).
var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
allocator.free(rev_data);
continue;
};
defer old_pf.deinit();
var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch {
allocator.free(rev_data);
continue;
};
defer new_pf.deinit();
var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch {
allocator.free(rev_data);
continue;
};
defer mods.deinit();
// The newer commit's timestamp is when these accounts changed.
const update_ts = commits[ci - 1].timestamp;
// Match against stable working-copy account names.
var acct_iter = all_accounts.keyIterator();
while (acct_iter.next()) |stable_name| {
if (out.contains(stable_name.*)) continue;
if (mods.contains(stable_name.*)) {
try out.put(stable_name.*, update_ts);
}
}
}
}
if (prev_data) |pd| allocator.free(pd);
prev_data = rev_data;
}
}
/// Staleness color based on age vs threshold.
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
const t: i32 = @intCast(threshold);
if (age_days <= t) return cli.CLR_MUTED;
if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
return cli.CLR_NEGATIVE;
}
/// One stale (or undated) manual price found during the hygiene scan.
/// String fields borrow from the scanned portfolio's lots and are
/// valid for the lifetime of that portfolio.
const StaleManualPrice = struct {
account: []const u8,
symbol: []const u8,
note: ?[]const u8,
price: f64,
/// `null` when the lot carries a manual `price` but no
/// `price_date` - the most-stale case (it can't even be aged),
/// not the least.
price_date: ?Date,
/// Days since `price_date`; `null` when undated.
age_days: ?i32,
};
/// Collect manual-priced lots that are stale (older than `stale_days`)
/// or undated, for the "Stale manual prices" hygiene section.
///
/// Staleness is purely a property of the `price` / `price_date` the
/// user typed on the lot - it has nothing to do with the account's
/// `update_cadence` (that's the reconciliation cadence, a separate
/// concept handled by the "Accounts overdue" section). The single
/// threshold is `stale_days` (the `--stale-days` flag, default 3).
///
/// Restricted to open `security_type == .stock` lots: CDs/cash/options
/// carry `price` as a fixed face value that never goes "stale by age,"
/// and closed lots aren't worth nagging about. Undated manual prices
/// are always included regardless of `stale_days` - a manual price
/// with no `price_date` can't be aged, which is the worst case, not a
/// pass. Caller owns the returned list; string fields borrow from
/// `portfolio`.
fn collectStaleManualPrices(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
as_of: Date,
stale_days: u32,
) !std.ArrayList(StaleManualPrice) {
var out = std.ArrayList(StaleManualPrice).empty;
errdefer out.deinit(allocator);
const threshold: i32 = @intCast(stale_days);
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
const price = lot.price orelse continue;
if (!lot.isOpen(as_of)) continue;
const account = lot.account orelse "(no account)";
if (lot.price_date) |pd| {
const age = as_of.days - pd.days;
if (age <= threshold) continue; // fresh enough
try out.append(allocator, .{
.account = account,
.symbol = lot.symbol,
.note = lot.note,
.price = price,
.price_date = pd,
.age_days = age,
});
} else {
try out.append(allocator, .{
.account = account,
.symbol = lot.symbol,
.note = lot.note,
.price = price,
.price_date = null,
.age_days = null,
});
}
}
return out;
}
/// Sort stale manual prices by account, then symbol - so the display
/// can group lines under per-account headers.
fn staleLessThan(_: void, a: StaleManualPrice, b: StaleManualPrice) bool {
const acc = std.mem.order(u8, a.account, b.account);
if (acc != .eq) return acc == .lt;
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}
/// One account whose hand-declared, hand-dated figure has gone stale
/// (or was never dated). Shared by the "Stale harvested figures" and
/// "Stale tax-mix figures" sections, which have identical shape: one
/// row per account, worst first. `account` borrows from the
/// `AccountMap` it was collected from and is valid for that map's
/// lifetime.
const StaleDeclared = struct {
account: []const u8,
/// Days since the figure's date; `null` when the figure was declared
/// with no date at all - the worst case, since an undated figure
/// can't be aged.
age_days: ?i32,
/// Extra clause appended after the age, or `""` for none.
///
/// Lets the harvested section explain that its annotation has
/// retired without the renderer needing to know what an annotation
/// is. Static strings only.
note: []const u8 = "",
};
/// Collect accounts whose `harvested` figure is older than
/// `harvested_stale_days`, or that declare `harvested` with no
/// `harvested_date`, for the "Stale harvested figures" hygiene section.
///
/// Only accounts that explicitly declare `harvested` are considered -
/// that declaration IS the opt-in to being nagged, which is why the
/// threshold needs no per-account override.
///
/// Future-dated entries fall out as fresh (their age is negative).
/// That's deliberate: `zfin doctor` already reports a future
/// `harvested_date` as the config typo it is, and surfacing the same
/// mistake a second time in staleness clothing would be worse than
/// reporting it once.
///
/// Caller owns the returned list; `account` fields borrow from
/// `account_map`.
fn collectStaleHarvested(
allocator: std.mem.Allocator,
account_map: analysis.AccountMap,
as_of: Date,
) !std.ArrayList(StaleDeclared) {
var out = std.ArrayList(StaleDeclared).empty;
errdefer out.deinit(allocator);
const threshold: i32 = @intCast(harvested_stale_days);
for (account_map.entries) |e| {
if (e.harvested == null) continue;
// Whether the figure still renders anywhere is derived by asking
// `format.fmtHarvestAnnotation` whether it would emit anything,
// NOT by comparing the age against 365. Those two disagree at
// leap-year boundaries, because the formatter gates on the
// calendar-exact `as_of.subtractYears(1)`. Going through the
// formatter makes the "no longer displayed" note definitionally
// true - the nag cannot contradict what the user sees.
//
// SAFETY: immediately overwritten by fmtHarvestAnnotation below.
var ann_buf: [fmt.harvest_annotation_max_len]u8 = undefined;
const hidden = fmt.fmtHarvestAnnotation(&ann_buf, e.harvested, e.harvested_date, as_of).len == 0;
const note: []const u8 = if (hidden) " - no longer displayed" else "";
if (e.harvested_date) |on| {
const age = as_of.days - on.days;
if (age <= threshold) continue; // fresh enough (or future-dated)
try out.append(allocator, .{ .account = e.account, .age_days = age, .note = note });
} else {
try out.append(allocator, .{ .account = e.account, .age_days = null, .note = note });
}
}
return out;
}
/// Collect accounts whose `tax_mix_*` carve-outs are older than
/// `tax_mix_stale_days`, or that declare a mix with no `tax_mix_date`,
/// for the "Stale tax-mix figures" hygiene section.
///
/// Same opt-in shape as `collectStaleHarvested`: only accounts that
/// declare a mix are considered, and future-dated entries fall out as
/// fresh so `doctor` owns reporting that typo.
///
/// Accounts whose mix was *rejected* still count. The user clearly
/// meant to declare one, and `doctor` explains why it didn't take -
/// silently dropping the row would hide the account from both reports.
///
/// Caller owns the returned list; `account` fields borrow from
/// `account_map`.
fn collectStaleTaxMix(
allocator: std.mem.Allocator,
account_map: analysis.AccountMap,
as_of: Date,
) !std.ArrayList(StaleDeclared) {
var out = std.ArrayList(StaleDeclared).empty;
errdefer out.deinit(allocator);
const threshold: i32 = @intCast(tax_mix_stale_days);
for (account_map.entries) |e| {
if (!e.hasTaxMix()) continue;
// No `note` counterpart to harvested's "no longer displayed":
// a stale tax mix keeps applying, by design. See
// `AccountTaxEntry.tax_mix_date`.
if (e.tax_mix_date) |on| {
const age = as_of.days - on.days;
if (age <= threshold) continue; // fresh enough (or future-dated)
try out.append(allocator, .{ .account = e.account, .age_days = age });
} else {
try out.append(allocator, .{ .account = e.account, .age_days = null });
}
}
return out;
}
/// Sort stale hand-declared figures worst-first: undated entries (which
/// can't be aged at all), then oldest, then account name as a stable
/// tiebreak.
///
/// Deliberately unlike `staleLessThan`, which sorts alphabetically:
/// that section emits many rows per account and needs them grouped,
/// while these emit exactly one row per account, so severity order
/// is strictly more useful.
fn staleDeclaredLessThan(_: void, a: StaleDeclared, b: StaleDeclared) bool {
if (a.age_days == null and b.age_days != null) return true;
if (a.age_days != null and b.age_days == null) return false;
if (a.age_days) |ad| {
const bd = b.age_days.?;
if (ad != bd) return ad > bd;
}
return std.mem.order(u8, a.account, b.account) == .lt;
}
/// How many accounts declare a `harvested` figure at all. Lets the
/// display distinguish "nothing to report because you don't use this
/// feature" (print nothing) from "nothing to report because everything
/// is current" (print a reassuring `(none)`).
fn countHarvestedAccounts(account_map: analysis.AccountMap) usize {
var n: usize = 0;
for (account_map.entries) |e| {
if (e.harvested != null) n += 1;
}
return n;
}
/// How many accounts declare `tax_mix_*` carve-outs at all. Same
/// use-the-feature-or-stay-quiet gate as `countHarvestedAccounts`.
fn countTaxMixAccounts(account_map: analysis.AccountMap) usize {
var n: usize = 0;
for (account_map.entries) |e| {
if (e.hasTaxMix()) n += 1;
}
return n;
}
/// Render one "stale hand-declared figure" hygiene section.
///
/// The two callers have identical shape - one row per account, worst
/// first - so the layout, the age coloring, and the reassuring `(none)`
/// live here. `title` is the whole section header; `undated_msg` is what
/// a row with no date says instead of an age.
///
/// `rows` is expected pre-sorted by `staleDeclaredLessThan`.
fn printStaleDeclaredSection(
out: *std.Io.Writer,
color: bool,
rows: []const StaleDeclared,
threshold_days: u32,
title: []const u8,
undated_msg: []const u8,
) !void {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{title});
if (rows.len == 0) {
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
return;
}
for (rows) |e| {
try out.print(" {s:<32} ", .{e.account});
if (e.age_days) |ad| {
const clr = stalenessColor(ad, threshold_days);
try cli.printFg(out, color, clr, "last updated {d} days ago{s}\n", .{ @as(u32, @intCast(ad)), e.note });
} else {
try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s}\n", .{undated_msg});
}
}
}
/// A lot whose manual `price` moved between HEAD and the working tree
/// while its `price_date` stayed identical - the "bumped the price,
/// forgot the date" mistake. String fields borrow from the working-
/// tree portfolio.
const PriceDateMismatch = struct {
account: []const u8,
symbol: []const u8,
old_price: f64,
new_price: f64,
price_date: Date,
};
/// Build a lot identity that survives a manual-price edit: a lot keeps
/// its symbol, account, open_date, and open_price when you only change
/// `price`/`price_date`. Used to pair HEAD lots with working-tree lots.
/// Caller owns the returned slice.
fn lotIdentityKey(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
return std.fmt.allocPrint(allocator, "{s}\x00{s}\x00{d}\x00{d:.6}", .{
lot.symbol,
lot.account orelse "",
lot.open_date.days,
lot.open_price,
});
}
/// Find lots whose manual `price` changed between `committed` (HEAD)
/// and `working` (on-disk) while `price_date` stayed identical.
///
/// This is the working-tree-vs-HEAD detector for the recurring "I
/// updated the price but forgot the date" mistake, run when `audit`
/// fires before a commit. Only open `security_type == .stock` lots
/// with a non-null `price_date` on both sides participate: the undated
/// case is reported by `collectStaleManualPrices`, and legitimate
/// back-dating (moving the date to a past close) is *not* flagged
/// because the date field changed. Lots are paired by
/// `lotIdentityKey`; newly-added or removed lots are ignored. Caller
/// owns the returned list; string fields borrow from `working`.
fn findPriceDateMismatches(
allocator: std.mem.Allocator,
committed: zfin.Portfolio,
working: zfin.Portfolio,
as_of: Date,
) !std.ArrayList(PriceDateMismatch) {
var out = std.ArrayList(PriceDateMismatch).empty;
errdefer out.deinit(allocator);
// Index HEAD lots by stable identity -> their (price, price_date).
const HeadLot = struct { price: ?f64, price_date: ?Date };
var head = std.StringHashMap(HeadLot).init(allocator);
defer {
var it = head.keyIterator();
while (it.next()) |k| allocator.free(k.*);
head.deinit();
}
for (committed.lots) |lot| {
if (lot.security_type != .stock) continue;
const key = try lotIdentityKey(allocator, lot);
const gop = try head.getOrPut(key);
if (gop.found_existing) {
// Duplicate identity (rare) - ambiguous, don't guess.
allocator.free(key);
continue;
}
gop.value_ptr.* = .{ .price = lot.price, .price_date = lot.price_date };
}
for (working.lots) |lot| {
if (lot.security_type != .stock) continue;
if (!lot.isOpen(as_of)) continue;
const new_price = lot.price orelse continue;
const new_date = lot.price_date orelse continue; // undated -> stale-price section's job
const key = try lotIdentityKey(allocator, lot);
defer allocator.free(key);
const prior = head.get(key) orelse continue; // newly-added lot
const old_price = prior.price orelse continue; // price added, not moved
const old_date = prior.price_date orelse continue; // was undated before
// The mistake: price moved, date did not.
if (old_date.days == new_date.days and @abs(old_price - new_price) >= 0.005) {
try out.append(allocator, .{
.account = lot.account orelse "(no account)",
.symbol = lot.symbol,
.old_price = old_price,
.new_price = new_price,
.price_date = new_date,
});
}
}
return out;
}
/// Sort price/date mismatches by account, then symbol - for grouped
/// per-account display.
fn mismatchLessThan(_: void, a: PriceDateMismatch, b: PriceDateMismatch) bool {
const acc = std.mem.order(u8, a.account, b.account);
if (acc != .eq) return acc == .lt;
return std.mem.order(u8, a.symbol, b.symbol) == .lt;
}
/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder - the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template defaults to `type::cash` (cash moved in, then invested -
/// the common case). If the securities themselves were moved between
/// accounts, change it to `type::in_kind` and fill in the `from::`
/// account that the shares left.
fn printLargeLotWarning(
out: *std.Io.Writer,
lot: contributions.UnmatchedLargeLot,
color: bool,
) !void {
var val_buf: [32]u8 = undefined;
var date_buf: [10]u8 = undefined;
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
const kind_label: []const u8 = switch (lot.security_type) {
.stock => "STOCK",
.cash => "CASH",
.cd => "CD",
.option => "OPTION",
else => "LOT",
};
const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
try out.print(
" {s}: new {s} lot {s} ",
.{ lot.account, kind_label, sym_for_display },
);
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
try out.print(" on {s}\n", .{date_str});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
// Amount formatted with cents precision so the suggested
// `amount:num:N` exactly matches the lot's value. The matcher
// has a $1 tolerance so a whole-dollar suggestion would usually
// pair, but pasting a value that lies about the actual lot is
// a poor user experience - `transaction_log.srf` should record
// what actually moved.
if (lot.security_type == .cash) {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, lot.value, lot.account },
);
} else {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d:.2},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, lot.value, lot.account, lot.symbol, date_str },
);
}
}
/// Append the account numbers present in `results` to `dst`, duping
/// each string into `allocator` so it outlives the per-file buffer the
/// reconciler's result slices borrow from. Used by the flagless audit
/// to union the present accounts across every discovered export before
/// computing the "accounts not found in any export" advisory once, so a
/// single-account positions CSV no longer flags every sibling account
/// as missing. `T` is the reconciler's comparison type
/// (`common.AccountComparison` or `schwab.SchwabAccountComparison`) -
/// both expose an `account_number` field, which `common.presentNumbers`
/// collects.
fn accumulatePresent(
allocator: std.mem.Allocator,
dst: *std.ArrayList([]const u8),
comptime T: type,
results: []const T,
) !void {
const present = try common.presentNumbers(allocator, T, results);
defer allocator.free(present);
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
}
/// Run the flagless portfolio hygiene check.
pub fn runHygieneCheck(
io: std.Io,
allocator: std.mem.Allocator,
env: *const std.process.Environ.Map,
svc: *zfin.DataService,
portfolio_path: []const u8,
/// Every file in the `portfolio*.srf` glob. Only the large-lot
/// check needs this: it diffs portfolio CONTENT and so must see
/// the merged view. The rest of the hygiene report is deliberately
/// single-file (git blame, commit SHAs) and uses `portfolio_path`.
portfolio_paths: []const []const u8,
stale_days: u32,
verbose: bool,
as_of: Date,
now_s: i64,
color: bool,
refresh: framework.RefreshPolicy,
out: *std.Io.Writer,
) !void {
// Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse {
cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
return;
};
defer account_map.deinit();
try cli.printBold(out, color, " Portfolio hygiene\n", .{});
// ── Section 1: Stale manual prices ──
//
// Manual prices on stock/fund lots whose `price_date` is older than
// `--stale-days` (default 3), plus manual prices with no
// `price_date` at all (the most-stale case). Staleness is a
// property of the price the user typed on the lot - not of the
// account; grouping by account here is display-only organization.
// CDs/cash/options are excluded - their `price` is a fixed face
// value, not an age-stale quote - as are closed lots.
{
var stale = try collectStaleManualPrices(allocator, portfolio, as_of, stale_days);
defer stale.deinit(allocator);
std.mem.sort(StaleManualPrice, stale.items, {}, staleLessThan);
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days - --stale-days to configure)\n", .{stale_days});
if (stale.items.len == 0) {
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
} else {
var current_account: ?[]const u8 = null;
for (stale.items) |e| {
if (current_account == null or !std.mem.eql(u8, current_account.?, e.account)) {
current_account = e.account;
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{e.account});
}
var price_buf: [24]u8 = undefined;
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(e.price)}) catch "$?";
const note_display = e.note orelse "";
if (e.price_date) |pd| {
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
try out.print(" {s:<14} {s:<16} {s:>12} {s} ", .{ e.symbol, note_display, price_str, date_str });
const clr = stalenessColor(e.age_days.?, stale_days);
try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(e.age_days.?))});
} else {
try out.print(" {s:<14} {s:<16} {s:>12} ", .{ e.symbol, note_display, price_str });
try cli.printFg(out, color, cli.CLR_NEGATIVE, "(no price_date set)\n", .{});
}
}
}
}
// ── Section 2: Account cadence check ──
{
// Try to get committed version via git
const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, env, portfolio_path) catch null;
defer if (repo_info) |ri| {
allocator.free(ri.root);
allocator.free(ri.rel_path);
};
// Parse committed portfolio for diff (working copy vs HEAD)
var committed_portfolio: ?zfin.Portfolio = null;
defer if (committed_portfolio) |*cp| cp.deinit();
var committed_data: ?[]const u8 = null;
defer if (committed_data) |d| allocator.free(d);
if (repo_info) |ri| {
committed_data = git.show(io, allocator, env, ri.root, "HEAD", ri.rel_path) catch null;
if (committed_data) |cd| {
committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
}
}
// ── Section 1b: manual price changed without bumping price_date ──
//
// Catches the recurring "I updated the price but forgot the
// date" mistake at the moment it matters - when `audit` runs
// against the working tree before a commit. Diffs the on-disk
// portfolio against HEAD; a lot whose `price` moved while its
// `price_date` stayed put is flagged. Silent when there's no
// committed version to compare against (not a repo / new file)
// and when no such mismatch exists.
if (committed_portfolio) |cp| {
var mismatches = try findPriceDateMismatches(allocator, cp, portfolio, as_of);
defer mismatches.deinit(allocator);
std.mem.sort(PriceDateMismatch, mismatches.items, {}, mismatchLessThan);
if (mismatches.items.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Manual price changed without updating price_date (working tree vs HEAD)\n", .{});
var current_account: ?[]const u8 = null;
for (mismatches.items) |m| {
if (current_account == null or !std.mem.eql(u8, current_account.?, m.account)) {
current_account = m.account;
try cli.printFg(out, color, cli.CLR_HEADER, " {s}\n", .{m.account});
}
var old_buf: [24]u8 = undefined;
var new_buf: [24]u8 = undefined;
var date_buf: [10]u8 = undefined;
const old_str = std.fmt.bufPrint(&old_buf, "{f}", .{Money.from(m.old_price)}) catch "$?";
const new_str = std.fmt.bufPrint(&new_buf, "{f}", .{Money.from(m.new_price)}) catch "$?";
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{m.price_date}) catch "????-??-??";
try out.print(" {s:<14} {s} -> {s} ", .{ m.symbol, old_str, new_str });
try cli.printFg(out, color, cli.CLR_WARNING, "price_date still {s} - bump it\n", .{date_str});
}
}
}
// Find accounts modified in working copy (uncommitted changes)
var working_copy_modified = std.StringHashMap(void).init(allocator);
defer working_copy_modified.deinit();
if (committed_portfolio) |cp| {
working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
}
// Collect all unique account names from working copy portfolio
// (these pointers are stable for the lifetime of the function)
var all_accounts = std.StringHashMap(void).init(allocator);
defer all_accounts.deinit();
for (portfolio.lots) |lot| {
if (lot.account) |acct| {
try all_accounts.put(acct, {});
}
}
// Find last update time for each account via git history.
// Walks the portfolio file's full history (no cadence-derived
// cutoff), keyed by stable working-copy account names. Absent
// entries render as "no update history found" below.
var last_update_ts = std.StringHashMap(i64).init(allocator);
defer last_update_ts.deinit();
if (repo_info) |ri| {
try findLastUpdateTimestamps(io, allocator, env, ri, &all_accounts, &last_update_ts);
}
// Display overdue accounts
var overdue_header_shown = false;
var updated_accounts = std.ArrayList([]const u8).empty;
defer updated_accounts.deinit(allocator);
// Check accounts updated in working copy
var wc_it = working_copy_modified.keyIterator();
while (wc_it.next()) |key| {
try updated_accounts.append(allocator, key.*);
}
// Check overdue accounts
var acct_it = all_accounts.keyIterator();
while (acct_it.next()) |acct_key| {
const acct_name = acct_key.*;
// Skip if already updated in working copy
if (working_copy_modified.contains(acct_name)) continue;
// Look up cadence from accounts.srf
var cadence = analysis.UpdateCadence.weekly; // default
for (account_map.entries) |entry| {
if (std.mem.eql(u8, entry.account, acct_name)) {
cadence = entry.update_cadence;
break;
}
}
const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'
// Find last update time
var age_days: ?i32 = null;
if (last_update_ts.get(acct_name)) |ts| {
const age_s = now_s - ts;
age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
}
// If we have no git history for this account, it's definitely overdue
const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
if (days <= @as(i32, @intCast(threshold_days))) continue;
if (!overdue_header_shown) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default - set update_cadence in accounts.srf)\n", .{});
overdue_header_shown = true;
}
try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() });
if (age_days) |ad| {
const clr = stalenessColor(ad, threshold_days);
try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
} else {
try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{});
}
}
// Display accounts updated in working copy
if (updated_accounts.items.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{});
for (updated_accounts.items) |acct| {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct});
}
}
}
// ── Section 3: Stale harvested figures ──
//
// Accounts whose hand-declared `harvested` figure (accounts.srf) is
// older than `harvested_stale_days`, or that declare it with no
// `harvested_date`. Unlike Section 2 this ages an explicit date
// field rather than walking git history, so the two are separate
// sections - one "days ago" column with two meanings would mislead.
//
// The nag has no upper bound on purpose. Past 12 months the
// annotation stops rendering (see `format.fmtHarvestAnnotation`),
// and this section is then the only thing that explains where it
// went - so it escalates its wording instead of going quiet.
{
const declared = countHarvestedAccounts(account_map);
// Silent for portfolios that don't use the feature at all; once
// an account declares a figure, say so either way.
if (declared > 0) {
var stale_harvest = try collectStaleHarvested(allocator, account_map, as_of);
defer stale_harvest.deinit(allocator);
std.mem.sort(StaleDeclared, stale_harvest.items, {}, staleDeclaredLessThan);
var title_buf: [128]u8 = undefined;
const title = try std.fmt.bufPrint(
&title_buf,
"Stale harvested figures (>{d} days - refresh 'harvested' in accounts.srf)",
.{harvested_stale_days},
);
try printStaleDeclaredSection(
out,
color,
stale_harvest.items,
harvested_stale_days,
title,
"no harvested_date set",
);
}
}
// ── Section 3b: Stale tax-mix figures ──
//
// Same shape as Section 3, for the `tax_mix_*` carve-outs (see
// `analysis.TaxMix`). Kept separate from harvested for the same
// reason Section 3 is kept separate from Section 2: two different
// "days ago" meanings in one column mislead.
//
// Worth nagging about precisely because a stale mix never stops
// applying - it quietly feeds the By Tax Type breakdown forever. The
// alternative (retiring it like the harvested annotation) would
// silently move the user's pre-tax vs post-tax picture, which is
// worse than showing a slightly stale split.
{
const declared = countTaxMixAccounts(account_map);
if (declared > 0) {
var stale_mix = try collectStaleTaxMix(allocator, account_map, as_of);
defer stale_mix.deinit(allocator);
std.mem.sort(StaleDeclared, stale_mix.items, {}, staleDeclaredLessThan);
var title_buf: [128]u8 = undefined;
const title = try std.fmt.bufPrint(
&title_buf,
"Stale tax-mix figures (>{d} days - refresh 'tax_mix_*' in accounts.srf)",
.{tax_mix_stale_days},
);
try printStaleDeclaredSection(
out,
color,
stale_mix.items,
tax_mix_stale_days,
title,
"no tax_mix_date set (mix still applies)",
);
}
}
// ── Section 4: Discover brokerage files ──
// Resolve audit directories
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
var all_files = std.ArrayList(discover.DiscoveredFile).empty;
defer {
for (all_files.items) |f| allocator.free(f.path);
all_files.deinit(allocator);
}
// Check $ZFIN_AUDIT_FILES first
const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null;
if (env_audit_dir) |edir| {
const env_files = try discover.brokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s, audit_file_max_age_hours);
defer allocator.free(env_files);
for (env_files) |f| try all_files.append(allocator, f);
}
// Then check {portfolio_dir}/audit/
const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
defer if (default_audit_dir) |d| allocator.free(d);
if (default_audit_dir) |adir| {
const dir_files = try discover.brokerFiles(io, allocator, adir, "audit/", now_s, audit_file_max_age_hours);
defer allocator.free(dir_files);
for (dir_files) |f| try all_files.append(allocator, f);
}
// Display discovered files
if (all_files.items.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
for (all_files.items) |f| {
const kind_label: []const u8 = switch (f.kind) {
.fidelity_csv => "fidelity",
.schwab_csv => "schwab csv",
.schwab_summary => "schwab summary",
};
try out.print(" {s:<52} {s}\n", .{ f.path, kind_label });
}
}
// ── Section 5: Auto-reconcile discovered files ──
if (all_files.items.len > 0) {
// Build prices map (shared by all reconciliations)
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
const pos_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(pos_syms);
if (pos_syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, refresh, color);
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}
}
}
try out.print("\n", .{});
try cli.printBold(out, color, " Reconciliation\n", .{});
// Present account numbers per institution, unioned across every
// discovered file. The "accounts not found" advisory is computed
// once from these unions AFTER the loop - not per file - so a
// single-account positions CSV no longer flags every other
// account in the institution as missing (it's present in a
// sibling export or the summary). Strings are duped because the
// borrowed account-number slices point into each file's
// `file_data`, which is freed per loop iteration.
var fidelity_present: std.ArrayList([]const u8) = .empty;
var schwab_present: std.ArrayList([]const u8) = .empty;
defer {
for (fidelity_present.items) |s| allocator.free(s);
fidelity_present.deinit(allocator);
for (schwab_present.items) |s| allocator.free(s);
schwab_present.deinit(allocator);
}
for (all_files.items) |f| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
defer allocator.free(file_data);
switch (f.kind) {
.schwab_summary => {
const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as schwab summary but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
continue;
};
defer allocator.free(results);
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try schwab.displaySchwabResults(results, color, out);
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
} else {
var acct_count: usize = 0;
for (results) |r| {
if (r.account_name.len > 0) acct_count += 1;
}
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
// Always show ratio suggestions even in compact
// mode - direct-indexing drift may cause a
// non-zero delta that still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
},
.fidelity_csv => {
const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as fidelity CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
continue;
};
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
// Always show ratio suggestions even in compact mode
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
},
.schwab_csv => {
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as schwab CSV but could not parse ({s}); skipped\n", .{ f.path, @errorName(err) });
continue;
};
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
},
}
}
// One advisory per institution that contributed an export,
// computed against the unioned present-set. "any export"
// reflects that absence is now relative to ALL discovered files
// (see `displayAbsentAccounts`), so an account covered by any
// sibling CSV or the summary no longer surfaces here.
if (fidelity_present.items.len > 0) {
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", fidelity_present.items, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out);
}
if (schwab_present.items.len > 0) {
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", schwab_present.items, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out);
}
}
// ── Section 6: Large new lots - confirm source ──
//
// Cross-check any new_* Change with value >= threshold against
// `transaction_log.srf` (via the shared contributions pipeline).
// Surfaces lots that look like significant external contributions
// OR unrecorded internal transfers - nudges the user to either
// confirm or add a transfer record.
//
// Silent when every large lot matched a transfer record, when
// there are no new lots at all, or when the pipeline can't run
// (not in a git repo). Threshold is per-account: an account's
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
// filter's built-in default applies.
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_paths, &account_map, as_of, color, refresh)) |found| {
var found_mut = found;
defer found_mut.deinit();
if (found_mut.lots.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots - confirm source\n", .{});
for (found_mut.lots) |lot| {
try printLargeLotWarning(out, lot, color);
}
}
}
// ── Section 7: Unhandled stock splits ──
//
// Held symbols with a split AFTER a lot's purchase date that haven't
// opted into automatic split adjustment. Each needs a
// `splits_current_through` on its metadata.srf row, or its shares
// (and every value derived from them) are misstated across the
// split. Cache-only detection; silent when everything is handled.
if (portfolio.stockSymbols(allocator)) |split_syms| {
defer allocator.free(split_syms);
const unhandled = cli.findUnhandledSplits(svc, allocator, portfolio.lots, split_syms, portfolio_path, as_of, cli.fetchOptionsFromPolicy(refresh));
defer allocator.free(unhandled);
if (unhandled.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Unhandled stock splits\n", .{});
for (unhandled) |nudge| {
try out.print(" {s}: stock split on {f}\n", .{ nudge.symbol, nudge.date });
try cli.printFg(out, color, cli.CLR_MUTED, " Add 'splits_current_through::YYYY-MM-DD' (your reconcile date) to {s}'s metadata.srf row.\n", .{nudge.symbol});
}
}
} else |_| {}
try out.print("\n", .{});
}
// ── Tests ────────────────────────────────────────────────────
test "accumulatePresent: unions account numbers across calls" {
const allocator = std.testing.allocator;
var dst: std.ArrayList([]const u8) = .empty;
defer {
for (dst.items) |s| allocator.free(s);
dst.deinit(allocator);
}
const batch1 = [_]common.AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
const batch2 = [_]common.AccountComparison{
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch1);
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch2);
try std.testing.expectEqual(@as(usize, 2), dst.items.len);
try std.testing.expectEqualStrings("1234", dst.items[0]);
try std.testing.expectEqualStrings("5678", dst.items[1]);
}
test "accumulatePresent: result strings are owned copies (survive source free)" {
// The whole point of duping: the borrowed account_number points into
// a per-file buffer freed each loop iteration. Prove the accumulator
// keeps its own copy by freeing the source out from under it.
const allocator = std.testing.allocator;
var dst: std.ArrayList([]const u8) = .empty;
defer {
for (dst.items) |s| allocator.free(s);
dst.deinit(allocator);
}
const num = try allocator.dupe(u8, "9012");
{
const batch = [_]common.AccountComparison{
.{ .account_name = "X", .brokerage_name = "X", .account_number = num, .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch);
}
allocator.free(num); // source gone; dst must hold its own copy
try std.testing.expectEqual(@as(usize, 1), dst.items.len);
try std.testing.expectEqualStrings("9012", dst.items[0]);
}
test "stalenessColor: within threshold" {
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
}
test "stalenessColor: warning zone (1-2x threshold)" {
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
}
test "stalenessColor: critical zone (>2x threshold)" {
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
}
test "UpdateCadence thresholdDays" {
try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
}
test "findModifiedAccounts: detects share changes" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
try std.testing.expect(!modified.contains("Acct B"));
}
test "findModifiedAccounts: detects new lots" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
}
test "findModifiedAccounts: detects price changes" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("401k"));
}
test "findModifiedAccounts: detects removed lots" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
// VTI removed
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
}
test "findModifiedAccounts: no changes" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, pf, pf);
defer modified.deinit();
try std.testing.expectEqual(@as(u32, 0), modified.count());
}
// ── collectStaleManualPrices ─────────────────────────────────
test "collectStaleManualPrices: dated stale stock lot is flagged with account + age" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqualStrings("Sample 529", stale.items[0].account);
try std.testing.expectEqualStrings("F529A", stale.items[0].symbol);
try std.testing.expectEqual(@as(?i32, 31), stale.items[0].age_days);
}
test "collectStaleManualPrices: price within threshold is skipped" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 30) },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
test "collectStaleManualPrices: undated manual price is always flagged" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = null },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqual(@as(?Date, null), stale.items[0].price_date);
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
}
test "collectStaleManualPrices: CDs and cash are excluded" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2020, 1, 1) },
.{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cash, .price = 5000.0, .price_date = Date.fromYmd(2020, 1, 1) },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
test "collectStaleManualPrices: closed lot is excluded" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2020, 1, 1), .close_date = Date.fromYmd(2026, 1, 1), .close_price = 26.0 },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
test "collectStaleManualPrices: lot without a manual price is skipped" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample Brokerage" },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var stale = try collectStaleManualPrices(allocator, pf, as_of, 3);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
// ── collectStaleHarvested ────────────────────────────────────
/// Build an in-memory AccountMap from a literal SRF string, mirroring
/// the helper in `analytics/analysis.zig`. Exercises the real parse
/// path so these tests can't drift from how `accounts.srf` is read.
fn testAccountMap(comptime data: []const u8) !analysis.AccountMap {
return analysis.parseAccountsFile(std.testing.allocator, data);
}
test "collectStaleHarvested: no account declares harvested -> empty" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
try std.testing.expectEqual(@as(usize, 0), countHarvestedAccounts(am));
}
test "collectStaleHarvested: figure within the window is fresh" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-06-24
);
defer am.deinit();
// 31 days old.
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
// ...but it IS declared, so the section still prints "(none)".
try std.testing.expectEqual(@as(usize, 1), countHarvestedAccounts(am));
}
test "collectStaleHarvested: exactly at the threshold is still fresh" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2026-01-01
);
defer am.deinit();
const on = Date.fromYmd(2026, 1, 1);
const at_threshold = on.addDays(@intCast(harvested_stale_days));
var stale = try collectStaleHarvested(allocator, am, at_threshold);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
// One day later it trips.
var stale2 = try collectStaleHarvested(allocator, am, at_threshold.addDays(1));
defer stale2.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale2.items.len);
try std.testing.expectEqual(@as(?i32, @intCast(harvested_stale_days + 1)), stale2.items[0].age_days);
try std.testing.expectEqualStrings("", stale2.items[0].note);
}
test "collectStaleHarvested: undated figure is flagged with a null age" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300
);
defer am.deinit();
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqualStrings("Sample Tax Loss", stale.items[0].account);
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
// An undated figure never renders, so it earns the retired note too.
try std.testing.expect(stale.items[0].note.len > 0);
}
test "collectStaleHarvested: future-dated figure is left to doctor" {
// A future `harvested_date` is a config typo, and `zfin doctor`
// already reports it as one. Surfacing the same mistake here in
// staleness clothing would double-nag for a single error.
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2027-06-24
);
defer am.deinit();
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
test "collectStaleHarvested: note agrees with fmtHarvestAnnotation across the 12-month edge" {
// The load-bearing invariant: the "no longer displayed" message must
// be true. The note is derived from the formatter rather than from a
// day count precisely so the two can't disagree at a leap-year
// boundary. Walk a window that straddles the cutoff and assert
// agreement on every day.
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-02-29
);
defer am.deinit();
const on = Date.fromYmd(2024, 2, 29);
var offset: i32 = 300;
while (offset <= 430) : (offset += 1) {
const as_of = on.addDays(offset);
var stale = try collectStaleHarvested(allocator, am, as_of);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len); // always past 90d here
// SAFETY: immediately overwritten by fmtHarvestAnnotation.
var buf: [fmt.harvest_annotation_max_len]u8 = undefined;
const rendered = fmt.fmtHarvestAnnotation(&buf, 45_300, on, as_of);
try std.testing.expectEqual(rendered.len == 0, stale.items[0].note.len > 0);
}
}
test "collectStaleHarvested: note is set once the annotation retires" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Tax Loss,tax_type::taxable,harvested:num:45300,harvested_date::2024-06-24
);
defer am.deinit();
// ~400 days later: stale AND no longer rendered.
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2025, 7, 29));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqual(@as(?i32, 400), stale.items[0].age_days);
try std.testing.expectEqualStrings(" - no longer displayed", stale.items[0].note);
}
test "staleDeclaredLessThan: undated first, then oldest, then account name" {
const undated_b: StaleDeclared = .{ .account = "B", .age_days = null };
const undated_a: StaleDeclared = .{ .account = "A", .age_days = null };
const old: StaleDeclared = .{ .account = "C", .age_days = 400 };
const newer: StaleDeclared = .{ .account = "D", .age_days = 100 };
// Undated outranks any dated entry, however old.
try std.testing.expect(staleDeclaredLessThan({}, undated_b, old));
try std.testing.expect(!staleDeclaredLessThan({}, old, undated_b));
// Among dated, older first.
try std.testing.expect(staleDeclaredLessThan({}, old, newer));
try std.testing.expect(!staleDeclaredLessThan({}, newer, old));
// Ties break on account name, so output is stable.
try std.testing.expect(staleDeclaredLessThan({}, undated_a, undated_b));
try std.testing.expect(!staleDeclaredLessThan({}, undated_b, undated_a));
}
test "collectStaleHarvested + sort: mixed accounts come out worst-first" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Fresh,tax_type::taxable,harvested:num:1000,harvested_date::2026-07-01
\\account::Sample Mild,tax_type::taxable,harvested:num:2000,harvested_date::2026-01-01
\\account::Sample Ancient,tax_type::taxable,harvested:num:3000,harvested_date::2024-01-01
\\account::Sample Undated,tax_type::taxable,harvested:num:4000
\\account::Sample None,tax_type::taxable
);
defer am.deinit();
var stale = try collectStaleHarvested(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan);
try std.testing.expectEqual(@as(usize, 4), countHarvestedAccounts(am));
try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded
try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account);
try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account);
try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account);
// Only the ancient one has retired from display.
try std.testing.expect(stale.items[1].note.len > 0);
try std.testing.expectEqualStrings("", stale.items[2].note);
}
// ── collectStaleTaxMix ───────────────────────────────────────
test "collectStaleTaxMix: no account declares a mix -> empty" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional
\\account::Sample Brokerage,tax_type::taxable
);
defer am.deinit();
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
try std.testing.expectEqual(@as(usize, 0), countTaxMixAccounts(am));
}
test "collectStaleTaxMix: a mix within the window is fresh" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-07-01
);
defer am.deinit();
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am));
}
test "collectStaleTaxMix: exactly at the threshold is still fresh" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2026-01-01
);
defer am.deinit();
const on = Date.fromYmd(2026, 1, 1);
const at_threshold = on.addDays(@intCast(tax_mix_stale_days));
var stale = try collectStaleTaxMix(allocator, am, at_threshold);
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
// One day later it trips.
var stale2 = try collectStaleTaxMix(allocator, am, at_threshold.addDays(1));
defer stale2.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale2.items.len);
try std.testing.expectEqual(@as(?i32, @intCast(tax_mix_stale_days + 1)), stale2.items[0].age_days);
// No "no longer displayed" counterpart: a stale mix keeps applying.
try std.testing.expectEqualStrings("", stale2.items[0].note);
}
test "collectStaleTaxMix: an undated mix is flagged but never retires" {
// The load-bearing difference from `harvested`: an undated or ancient
// tax mix still feeds the By Tax Type breakdown. It gets a null age so
// it sorts first, but never a note claiming it stopped applying.
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4
);
defer am.deinit();
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqualStrings("Sample 401k", stale.items[0].account);
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
try std.testing.expectEqualStrings("", stale.items[0].note);
// Years later, still applying and still noteless.
var ancient = try collectStaleTaxMix(allocator, am, Date.fromYmd(2030, 7, 25));
defer ancient.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), ancient.items.len);
try std.testing.expectEqualStrings("", ancient.items[0].note);
try std.testing.expectApproxEqAbs(
@as(f64, 0.224),
am.entries[0].taxMix().weightOf(.roth),
1e-12,
);
}
test "collectStaleTaxMix: future-dated mix is left to doctor" {
// Mirrors `collectStaleHarvested`: a future date is a config typo that
// `zfin doctor` already reports, so ageing it here would double-report.
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:22.4,tax_mix_date::2027-01-01
);
defer am.deinit();
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), stale.items.len);
}
test "collectStaleTaxMix: a rejected mix is still collected" {
// The user meant to declare a mix, so the account belongs in the
// report even though the declaration didn't take. Dropping it would
// hide the account from the staleness nag AND leave doctor as the
// only mention.
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample 401k,tax_type::traditional,tax_mix_roth:num:140
);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 1), countTaxMixAccounts(am));
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), stale.items.len);
try std.testing.expectEqual(@as(?i32, null), stale.items[0].age_days);
}
test "collectStaleTaxMix + sort: mixed accounts come out worst-first" {
const allocator = std.testing.allocator;
var am = try testAccountMap(
\\#!srfv1
\\account::Sample Fresh,tax_type::traditional,tax_mix_roth:num:10,tax_mix_date::2026-07-01
\\account::Sample Mild,tax_type::traditional,tax_mix_roth:num:20,tax_mix_date::2026-01-01
\\account::Sample Ancient,tax_type::traditional,tax_mix_roth:num:30,tax_mix_date::2024-01-01
\\account::Sample Undated,tax_type::traditional,tax_mix_roth:num:40
\\account::Sample None,tax_type::traditional
);
defer am.deinit();
var stale = try collectStaleTaxMix(allocator, am, Date.fromYmd(2026, 7, 25));
defer stale.deinit(allocator);
std.mem.sort(StaleDeclared, stale.items, {}, staleDeclaredLessThan);
try std.testing.expectEqual(@as(usize, 4), countTaxMixAccounts(am));
try std.testing.expectEqual(@as(usize, 3), stale.items.len); // Fresh and None excluded
try std.testing.expectEqualStrings("Sample Undated", stale.items[0].account);
try std.testing.expectEqualStrings("Sample Ancient", stale.items[1].account);
try std.testing.expectEqualStrings("Sample Mild", stale.items[2].account);
}
// ── printStaleDeclaredSection ────────────────────────────────
test "printStaleDeclaredSection: no rows emits a reassuring (none)" {
// "You use this feature and everything is current" must be visibly
// different from the section being absent entirely.
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printStaleDeclaredSection(&w, false, &.{}, 90, "Stale widgets (>90 days)", "no widget_date set");
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Stale widgets (>90 days)") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(none)") != null);
}
test "printStaleDeclaredSection: dated rows print an age, undated print the fallback" {
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const rows = [_]StaleDeclared{
.{ .account = "Sample Undated", .age_days = null },
.{ .account = "Sample Ancient", .age_days = 400, .note = " - no longer displayed" },
.{ .account = "Sample Mild", .age_days = 120 },
};
try printStaleDeclaredSection(&w, false, &rows, 90, "Stale harvested figures", "no harvested_date set");
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Stale harvested figures") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(none)") == null);
// Undated rows say why instead of showing a nonsense age.
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Undated") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "no harvested_date set") != null);
// Dated rows show the age, and carry the note when one is set.
try std.testing.expect(std.mem.indexOf(u8, out, "last updated 400 days ago - no longer displayed") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "last updated 120 days ago\n") != null);
}
test "printStaleDeclaredSection: an empty note appends nothing" {
// Regression guard for the tax-mix caller, which never sets a note:
// the age must not pick up stray trailing text.
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const rows = [_]StaleDeclared{.{ .account = "Sample 401k", .age_days = 577 }};
try printStaleDeclaredSection(&w, false, &rows, tax_mix_stale_days, "Stale tax-mix figures", "no tax_mix_date set (mix still applies)");
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "last updated 577 days ago\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "no longer displayed") == null);
}
// ── findPriceDateMismatches ──────────────────────────────────
test "findPriceDateMismatches: price moved but date unchanged is flagged" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var head = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
var work = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
};
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), m.items.len);
try std.testing.expectEqualStrings("F529A", m.items[0].symbol);
try std.testing.expectEqual(@as(f64, 25.0), m.items[0].old_price);
try std.testing.expectEqual(@as(f64, 27.5), m.items[0].new_price);
}
test "findPriceDateMismatches: price moved AND date moved (back-date) is not flagged" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var head = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
var work = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 29) },
};
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), m.items.len);
}
test "findPriceDateMismatches: unchanged price is not flagged" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, pf, pf, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), m.items.len);
}
test "findPriceDateMismatches: newly-added lot is not flagged" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var head = [_]portfolio_mod.Lot{};
var work = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = Date.fromYmd(2026, 5, 1) },
};
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), m.items.len);
}
test "findPriceDateMismatches: undated working lot is left to the stale-price section" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var head = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 25.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
var work = [_]portfolio_mod.Lot{
.{ .symbol = "F529A", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .account = "Sample 529", .price = 27.5, .price_date = null },
};
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), m.items.len);
}
test "findPriceDateMismatches: CD price change is ignored" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 1);
var head = [_]portfolio_mod.Lot{
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10000.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
var work = [_]portfolio_mod.Lot{
.{ .symbol = "CD123", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA", .security_type = .cd, .price = 10100.0, .price_date = Date.fromYmd(2026, 5, 1) },
};
const cp = portfolio_mod.Portfolio{ .lots = &head, .allocator = allocator };
const wp = portfolio_mod.Portfolio{ .lots = &work, .allocator = allocator };
var m = try findPriceDateMismatches(allocator, cp, wp, as_of);
defer m.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), m.items.len);
}
test "UpdateCadence label" {
try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
}
test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct A",
.symbol = "",
.security_type = .cash,
.value = 50_000.0,
.open_date = Date.fromYmd(2026, 5, 10),
};
try printLargeLotWarning(&writer, lot, false); // color=false -> no ANSI escapes
const output = writer.buffered();
// Header line with account + value + date.
try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
// Template line with the expected SRF shape.
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000.00,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}
test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct B",
.symbol = "SYM",
.security_type = .stock,
.value = 25_000.0,
.open_date = Date.fromYmd(2026, 5, 3),
};
try printLargeLotWarning(&writer, lot, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000.00,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}
test "printLargeLotWarning: cents are preserved in template" {
// Regression: previously the template rounded to whole dollars,
// so a $73,158.33 lot suggested `amount:num:73158`. Pasting that
// verbatim into transaction_log.srf records a fictitious amount
// and (with $1 matcher tolerance) only barely pairs. The fix
// prints two-decimal precision so the suggested record exactly
// describes the lot it's offering to attribute.
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Sample Trust",
.symbol = "",
.security_type = .cash,
.value = 73_158.33,
.open_date = Date.fromYmd(2026, 5, 20),
};
try printLargeLotWarning(&writer, lot, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158.33") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "amount:num:73158,") == null);
}
test "strLessThan: orders strings lexicographically" {
try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
try std.testing.expect(!strLessThan({}, "AAPL", "AAPL"));
try std.testing.expect(strLessThan({}, "AAPL", "AAPLE"));
}
test "lotToString: stock lot includes symbol, shares, date" {
const allocator = std.testing.allocator;
const lot = portfolio_mod.Lot{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 3, 15),
.open_price = 150.50,
};
const s = try lotToString(allocator, lot);
defer allocator.free(s);
try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, s, "100") != null);
try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null);
}
test "staleLessThan: orders by account, then symbol" {
const a = StaleManualPrice{ .account = "Sample IRA", .symbol = "AAPL", .note = null, .price = 1, .price_date = null, .age_days = null };
const b = StaleManualPrice{ .account = "Sample IRA", .symbol = "MSFT", .note = null, .price = 1, .price_date = null, .age_days = null };
const c = StaleManualPrice{ .account = "Sample Roth", .symbol = "AAA", .note = null, .price = 1, .price_date = null, .age_days = null };
// Same account -> symbol breaks the tie.
try std.testing.expect(staleLessThan({}, a, b));
try std.testing.expect(!staleLessThan({}, b, a));
// Different account -> account wins regardless of symbol.
try std.testing.expect(staleLessThan({}, b, c));
try std.testing.expect(!staleLessThan({}, c, b));
}
test "mismatchLessThan: orders by account, then symbol" {
const d = Date.fromYmd(2026, 1, 1);
const a = PriceDateMismatch{ .account = "Sample IRA", .symbol = "AAPL", .old_price = 1, .new_price = 2, .price_date = d };
const b = PriceDateMismatch{ .account = "Sample IRA", .symbol = "MSFT", .old_price = 1, .new_price = 2, .price_date = d };
const c = PriceDateMismatch{ .account = "Sample Roth", .symbol = "AAA", .old_price = 1, .new_price = 2, .price_date = d };
try std.testing.expect(mismatchLessThan({}, a, b));
try std.testing.expect(!mismatchLessThan({}, b, a));
try std.testing.expect(mismatchLessThan({}, b, c));
try std.testing.expect(!mismatchLessThan({}, c, b));
}
test "findPriceDateMismatches: duplicate HEAD identity collapses to one entry" {
const allocator = std.testing.allocator;
// Two committed lots share an identity key (same symbol/account/
// open_date/open_price) -> the second is ambiguous and skipped, but
// the first still anchors the comparison.
var committed_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 150, .price_date = Date.fromYmd(2026, 1, 1) },
};
const committed = portfolio_mod.Portfolio{ .lots = &committed_lots, .allocator = allocator };
// Working tree bumps the price but leaves price_date untouched.
var working_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price = 160, .price_date = Date.fromYmd(2026, 1, 1) },
};
const working = portfolio_mod.Portfolio{ .lots = &working_lots, .allocator = allocator };
var mismatches = try findPriceDateMismatches(allocator, committed, working, Date.fromYmd(2026, 6, 1));
defer mismatches.deinit(allocator);
// Dup HEAD identity collapsed to one; the price-without-date bump still flags.
try std.testing.expectEqual(@as(usize, 1), mismatches.items.len);
try std.testing.expectEqualStrings("AAPL", mismatches.items[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 150), mismatches.items[0].old_price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 160), mismatches.items[0].new_price, 0.01);
}
test "runHygieneCheck: Section 7 flags an un-opted-in symbol's split, not an opted-in one" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// NVDA: held across its 2024 split, NOT opted in -> should flag.
// AMZN: held across its 2022 split, opted in via metadata -> should not.
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data =
\\#!srfv1
\\symbol::NVDA,shares:num:100,open_date::2020-01-01,open_price:num:40.00,account::Sample Brokerage
\\symbol::AMZN,shares:num:30,open_date::2019-01-01,open_price:num:90.00,account::Sample Brokerage
\\
,
});
// accounts.srf must exist and parse (an empty map is fine).
try tmp.dir.writeFile(io, .{ .sub_path = "accounts.srf", .data = "#!srfv1\n" });
// Per-symbol opt-in: AMZN yes, NVDA absent (not opted in).
try tmp.dir.writeFile(io, .{
.sub_path = "metadata.srf",
.data = "#!srfv1\nsymbol::AMZN,splits_current_through::2024-01-01\n",
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
// Seed the split cache: NVDA 10:1 (2024-06-10), AMZN 20:1 (2022-06-06).
var store = zfin.cache.Store.init(io, allocator, dir);
var nvda = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2024, 6, 10), .numerator = 10, .denominator = 1 }};
store.write(zfin.Split, "NVDA", nvda[0..], .{ .seconds = zfin.cache.Ttl.splits });
var amzn = [_]zfin.Split{.{ .date = zfin.Date.fromYmd(2022, 6, 6), .numerator = 20, .denominator = 1 }};
store.write(zfin.Split, "AMZN", amzn[0..], .{ .seconds = zfin.cache.Ttl.splits });
// No API keys / no server -> hermetic (fetches can't reach a network).
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 1, 1), 1_767_225_600, false, .never, &aw.writer);
const output = aw.written();
try std.testing.expect(std.mem.indexOf(u8, output, "Portfolio hygiene") != null);
// Assert against Section 7 specifically (it's the last section, so
// slice from its header to end) - robust even if NVDA/AMZN surface
// in an earlier section. The un-opted-in NVDA is listed; the
// opted-in AMZN is not.
const sec6_start = std.mem.indexOf(u8, output, "Unhandled stock splits") orelse return error.Section6Missing;
const sec6 = output[sec6_start..];
try std.testing.expect(std.mem.indexOf(u8, sec6, "NVDA") != null);
try std.testing.expect(std.mem.indexOf(u8, sec6, "AMZN") == null);
}
/// Format a two-account portfolio.srf for the git-history test. Only
/// `shares` varies between revisions, which is enough for
/// findModifiedAccounts to flag the account.
fn testPortfolioSrf(buf: []u8, ira_shares: []const u8, roth_shares: []const u8) ![]const u8 {
return std.fmt.bufPrint(buf, "#!srfv1\n" ++
"symbol::VOO,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample IRA\n" ++
"symbol::BND,shares:num:{s},open_date::2026-01-01,open_price:num:1.00,account::Sample Roth\n", .{ ira_shares, roth_shares });
}
test "findLastUpdateTimestamps: resolves an account changed in a non-newest commit" {
const allocator = std.testing.allocator;
const io = std.testing.io;
// Skip if `git` isn't on PATH (CI sandbox without git).
if (!test_git.available(allocator)) return;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
var buf: [512]u8 = undefined;
// Commit A (oldest): IRA=100, Roth=50.
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "100", "50") });
try test_git.run(allocator, dir, null, &.{ "init", "-q" });
try test_git.run(allocator, dir, null, &.{ "config", "user.email", "test@example.com" });
try test_git.run(allocator, dir, null, &.{ "config", "user.name", "Test" });
try test_git.run(allocator, dir, null, &.{ "config", "commit.gpgsign", "false" });
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
try test_git.run(allocator, dir, "2026-01-10T12:00:00", &.{ "commit", "-q", "-m", "A" });
// Commit B: IRA changes to 200 (Roth unchanged). This is the change
// the old 2x-cadence lookback window could exclude, leaving IRA as
// "no update history found".
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "50") });
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
try test_git.run(allocator, dir, "2026-02-15T12:00:00", &.{ "commit", "-q", "-m", "B" });
// Commit C (newest): Roth changes to 60 (IRA unchanged).
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = try testPortfolioSrf(&buf, "200", "60") });
try test_git.run(allocator, dir, null, &.{ "add", "portfolio.srf" });
try test_git.run(allocator, dir, "2026-07-01T12:00:00", &.{ "commit", "-q", "-m", "C" });
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
const ri = try git.findRepo(io, allocator, &env, pf_path);
defer {
allocator.free(ri.root);
allocator.free(ri.rel_path);
}
// Authoritative committer timestamps, newest-first: [0]=C, [1]=B, [2]=A.
const commits = try git.listCommitsTouching(io, allocator, &env, ri.root, ri.rel_path, null);
defer git.freeCommitTouches(allocator, commits);
try std.testing.expectEqual(@as(usize, 3), commits.len);
var all_accounts = std.StringHashMap(void).init(allocator);
defer all_accounts.deinit();
try all_accounts.put("Sample IRA", {});
try all_accounts.put("Sample Roth", {});
var out = std.StringHashMap(i64).init(allocator);
defer out.deinit();
try findLastUpdateTimestamps(io, allocator, &env, ri, &all_accounts, &out);
// Both accounts resolve. Critically "Sample IRA" - last changed in
// commit B, NOT the newest commit - is found rather than absent.
const ira_ts = out.get("Sample IRA") orelse return error.IraUnresolved;
const roth_ts = out.get("Sample Roth") orelse return error.RothUnresolved;
// Each change attributed to the commit that actually made it.
try std.testing.expectEqual(commits[1].timestamp, ira_ts); // B
try std.testing.expectEqual(commits[0].timestamp, roth_ts); // C
try std.testing.expect(ira_ts < roth_ts);
}