IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
299 lines
10 KiB
Zig
299 lines
10 KiB
Zig
//! Staleness nags for hand-maintained data sources.
|
|
//!
|
|
//! Some datasets in this codebase require periodic human maintenance
|
|
//! (e.g. appending the latest year's T-bill rate, replacing Shiller's
|
|
//! `ie_data.csv`). Each such source declares a `pub const <name>_last_updated: Date`
|
|
//! constant which a human bumps when the refresh happens.
|
|
//!
|
|
//! This module registers those sources and prints a warning to stderr
|
|
//! on every `zfin` invocation once the annual refresh window opens
|
|
//! and the data is still stale. The refresh window is expressed as a
|
|
//! single `(month, day)` per year — the earliest date by which fresh
|
|
//! upstream data is expected to be available — not as a rolling
|
|
//! day-count.
|
|
//!
|
|
//! ### Nagging semantics
|
|
//!
|
|
//! Given today's date and an entry's `(due_month, due_day, last_updated)`:
|
|
//!
|
|
//! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`.
|
|
//! 2. If `today < this_years_due` — not yet nag season, silent.
|
|
//! 3. If `last_updated >= this_years_due` — already refreshed this cycle, silent.
|
|
//! 4. Otherwise — nag.
|
|
//!
|
|
//! The nag keeps firing every invocation until the human bumps
|
|
//! `last_updated` past `this_years_due`.
|
|
//!
|
|
//! ### Adding a new source
|
|
//!
|
|
//! 1. Add `pub const <name>_last_updated: Date = Date.fromYmd(YYYY, MM, DD);`
|
|
//! to the module that owns the data.
|
|
//! 2. Append a new `StaleEntry` to `entries` below.
|
|
//! 3. Document the refresh procedure in the owning module's doc
|
|
//! comment (or a `TODO` at the top).
|
|
|
|
const std = @import("std");
|
|
const Date = @import("../models/date.zig").Date;
|
|
const risk = @import("../analytics/risk.zig");
|
|
const shiller = @import("shiller.zig");
|
|
|
|
/// A hand-maintained data source that nags once a year if it hasn't
|
|
/// been refreshed by its annual `(due_month, due_day)`.
|
|
pub const StaleEntry = struct {
|
|
/// Human-readable name surfaced in the warning.
|
|
name: []const u8,
|
|
/// The last time a human refreshed this data. Bumped by hand.
|
|
last_updated: Date,
|
|
/// Calendar month (1-12) by which fresh upstream data is expected.
|
|
due_month: u8,
|
|
/// Calendar day (1-31) within `due_month`.
|
|
due_day: u8,
|
|
/// Source file to point the user at for refresh instructions.
|
|
source_file: []const u8,
|
|
};
|
|
|
|
/// Registry of hand-maintained data sources in this codebase. Passed
|
|
/// to `check` from `main.zig`; tests construct their own slices.
|
|
pub const entries = [_]StaleEntry{
|
|
.{
|
|
.name = "T-bill risk-free rate table",
|
|
.last_updated = risk.tbill_rates_last_updated,
|
|
.due_month = 1,
|
|
.due_day = 31,
|
|
.source_file = "src/analytics/risk.zig",
|
|
},
|
|
.{
|
|
.name = "Shiller annual returns (ie_data.csv)",
|
|
.last_updated = shiller.ie_data_last_updated,
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/data/shiller.zig",
|
|
},
|
|
};
|
|
|
|
/// Write a warning line for each entry in `entries` that is overdue
|
|
/// for refresh as of `as_of`. Writes nothing when every entry is
|
|
/// fresh. Entries are processed in order; multiple warnings are
|
|
/// separated by a blank line.
|
|
pub fn check(
|
|
writer: *std.Io.Writer,
|
|
as_of: Date,
|
|
list: []const StaleEntry,
|
|
) !void {
|
|
var wrote_any = false;
|
|
for (list) |entry| {
|
|
const this_years_due = Date.fromYmd(
|
|
as_of.year(),
|
|
entry.due_month,
|
|
entry.due_day,
|
|
);
|
|
// Not yet nag season.
|
|
if (as_of.lessThan(this_years_due)) continue;
|
|
// Already refreshed this cycle.
|
|
if (!entry.last_updated.lessThan(this_years_due)) continue;
|
|
|
|
if (wrote_any) try writer.writeAll("\n");
|
|
wrote_any = true;
|
|
|
|
var due_buf: [10]u8 = undefined;
|
|
var upd_buf: [10]u8 = undefined;
|
|
const due_str = this_years_due.format(&due_buf);
|
|
const upd_str = entry.last_updated.format(&upd_buf);
|
|
|
|
try writer.print(
|
|
"warning: {s} is overdue for refresh.\n" ++
|
|
" Expected by {s}; last updated {s}.\n" ++
|
|
" See {s} for refresh instructions.\n",
|
|
.{ entry.name, due_str, upd_str, entry.source_file },
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "silent before due date" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2025, 3, 15),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 3, 31), &list);
|
|
try std.testing.expectEqualStrings("", w.buffered());
|
|
}
|
|
|
|
test "nag on due date when stale" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2025, 3, 15),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 4, 1), &list);
|
|
const expected =
|
|
"warning: Test data is overdue for refresh.\n" ++
|
|
" Expected by 2026-04-01; last updated 2025-03-15.\n" ++
|
|
" See src/test.zig for refresh instructions.\n";
|
|
try std.testing.expectEqualStrings(expected, w.buffered());
|
|
}
|
|
|
|
test "nag after due date when stale" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2025, 3, 15),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 6, 15), &list);
|
|
const expected =
|
|
"warning: Test data is overdue for refresh.\n" ++
|
|
" Expected by 2026-04-01; last updated 2025-03-15.\n" ++
|
|
" See src/test.zig for refresh instructions.\n";
|
|
try std.testing.expectEqualStrings(expected, w.buffered());
|
|
}
|
|
|
|
test "silent when already refreshed this cycle" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2026, 4, 2),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 6, 15), &list);
|
|
try std.testing.expectEqualStrings("", w.buffered());
|
|
}
|
|
|
|
test "silent when last_updated equals this year's due date" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2026, 4, 1),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 4, 1), &list);
|
|
try std.testing.expectEqualStrings("", w.buffered());
|
|
}
|
|
|
|
test "two-cycle stale still fires" {
|
|
var buf: [1024]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2024, 2, 1),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 6, 15), &list);
|
|
const expected =
|
|
"warning: Test data is overdue for refresh.\n" ++
|
|
" Expected by 2026-04-01; last updated 2024-02-01.\n" ++
|
|
" See src/test.zig for refresh instructions.\n";
|
|
try std.testing.expectEqualStrings(expected, w.buffered());
|
|
}
|
|
|
|
test "empty list is a no-op" {
|
|
var buf: [16]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
try check(&w, Date.fromYmd(2030, 12, 31), &[_]StaleEntry{});
|
|
try std.testing.expectEqualStrings("", w.buffered());
|
|
}
|
|
|
|
test "multiple stale entries separated by blank line" {
|
|
var buf: [2048]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{
|
|
.{
|
|
.name = "Alpha",
|
|
.last_updated = Date.fromYmd(2024, 12, 1),
|
|
.due_month = 1,
|
|
.due_day = 31,
|
|
.source_file = "src/alpha.zig",
|
|
},
|
|
.{
|
|
.name = "Beta",
|
|
.last_updated = Date.fromYmd(2025, 3, 15),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/beta.zig",
|
|
},
|
|
};
|
|
try check(&w, Date.fromYmd(2026, 6, 15), &list);
|
|
const expected =
|
|
"warning: Alpha is overdue for refresh.\n" ++
|
|
" Expected by 2026-01-31; last updated 2024-12-01.\n" ++
|
|
" See src/alpha.zig for refresh instructions.\n" ++
|
|
"\n" ++
|
|
"warning: Beta is overdue for refresh.\n" ++
|
|
" Expected by 2026-04-01; last updated 2025-03-15.\n" ++
|
|
" See src/beta.zig for refresh instructions.\n";
|
|
try std.testing.expectEqualStrings(expected, w.buffered());
|
|
}
|
|
|
|
test "fresh entry does not emit blank line before later stale entry" {
|
|
var buf: [2048]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{
|
|
.{
|
|
.name = "Alpha",
|
|
.last_updated = Date.fromYmd(2026, 2, 1), // fresh
|
|
.due_month = 1,
|
|
.due_day = 31,
|
|
.source_file = "src/alpha.zig",
|
|
},
|
|
.{
|
|
.name = "Beta",
|
|
.last_updated = Date.fromYmd(2025, 3, 15), // stale
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/beta.zig",
|
|
},
|
|
};
|
|
try check(&w, Date.fromYmd(2026, 6, 15), &list);
|
|
const expected =
|
|
"warning: Beta is overdue for refresh.\n" ++
|
|
" Expected by 2026-04-01; last updated 2025-03-15.\n" ++
|
|
" See src/beta.zig for refresh instructions.\n";
|
|
try std.testing.expectEqualStrings(expected, w.buffered());
|
|
}
|
|
|
|
test "silent when today is one day before due" {
|
|
var buf: [256]u8 = undefined;
|
|
var w = std.Io.Writer.fixed(&buf);
|
|
const list = [_]StaleEntry{.{
|
|
.name = "Test data",
|
|
.last_updated = Date.fromYmd(2020, 1, 1),
|
|
.due_month = 4,
|
|
.due_day = 1,
|
|
.source_file = "src/test.zig",
|
|
}};
|
|
try check(&w, Date.fromYmd(2026, 3, 31), &list);
|
|
try std.testing.expectEqualStrings("", w.buffered());
|
|
}
|
|
|
|
test "real registry compiles and is non-empty" {
|
|
// Guard that the registry stays wired up; doesn't assert any
|
|
// particular nag behavior (real dates drift over time).
|
|
try std.testing.expect(entries.len >= 2);
|
|
for (entries) |e| {
|
|
try std.testing.expect(e.name.len > 0);
|
|
try std.testing.expect(e.source_file.len > 0);
|
|
try std.testing.expect(e.due_month >= 1 and e.due_month <= 12);
|
|
try std.testing.expect(e.due_day >= 1 and e.due_day <= 31);
|
|
}
|
|
}
|