diff --git a/AGENTS.md b/AGENTS.md index 8776ea8..d88e1ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,65 @@ # AGENTS.md +## ⛔ ABSOLUTE PROHIBITIONS — READ FIRST ⛔ + +### NEVER delete or modify build caches. EVER. + +**This means:** + +- **NEVER** run `rm -rf .zig-cache` or `rm -rf .zig-cache/*` or any variant. +- **NEVER** run `rm -rf ~/.cache/zig` or touch anything under `~/.cache/zig/`. +- **NEVER** touch `~/.cache/zls/` or any other tool cache. +- **NEVER** suggest deleting the cache as a "fix" — it is not a fix, it is + damage. Deleting `.zig-cache` while ZLS or another `zig build` is running + creates a corrupt state where the build runner's expected cache entry + (`.zig-cache/o//build`) references a path that no longer exists, + producing the error `failed to spawn build runner .zig-cache/o//build: + FileNotFound`. Recovering from this on the affected machine requires + killing every concurrent `zig` process (including ZLS) and waiting for + filesystem state to re-stabilize — it is NOT a simple retry. +- **NEVER** touch the cache "just to force a rebuild". Zig's cache is + content-addressed. It does not get stale in a way that deletion fixes. + If a build result looks wrong, the bug is in the source, not the cache. + Use `touch src/somefile.zig` if you truly need to invalidate one file's + cache line. Do not nuke the whole directory. +- **NEVER** suggest a "different cache directory" as a workaround without + an explicit, specific reason and explicit user approval. `--cache-dir` + and `--global-cache-dir` flags exist; they are not toys. + +**If a test result seems wrong or cached incorrectly:** the answer is +ALWAYS to investigate the source code or build graph, not to delete +cache. See "Test discovery" below — 99% of the time the "cached wrong +result" is actually a test discovery problem, not a cache problem. + +**If you find yourself typing `rm -rf` anywhere near a cache path: STOP. +Ask the user instead.** + +### NEVER run destructive git operations without explicit permission. + +- No `git reset --hard`, `git clean -fdx`, `git push --force`, `git checkout .` + on files with uncommitted work, unless the user asks for that specific + operation by name. + +### NEVER run `git add`, `git commit`, or `git push`. EVER. + +- **The user commits. You do not.** Do not stage files. Do not create commits. + Do not amend commits. Do not push. Do not suggest running these commands + yourself "to save a step". This includes `git add -p`, `git add .`, + `git add `, `git commit -m ...`, `git commit --amend`, `git push`, + and any `gh pr create` that would auto-stage or auto-commit. +- If you are tempted to run any of these because "the work is done and it + seems logical to commit" — STOP. The user has a review-and-commit workflow. + Your job ends at a clean working tree with the changes ready to review. +- The ONLY exception is when the user says, verbatim in the current turn, + "commit this" / "make a commit" / "push it" / similar direct imperative. + Do not extrapolate from earlier intent, a plan that mentioned milestones, + or any indirect signal. If in doubt, ask — don't commit. +- When a milestone plan says "STOP POINT — user reviews and commits": you + stop. You do not commit. You do not prepare a commit. You hand off the + working tree and wait. + +--- + ## Commands ```bash @@ -103,6 +163,65 @@ All tests are inline (in `test` blocks within source files). There is a single t Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure). +### ⚠️ Test discovery — READ THIS BEFORE ADDING A NEW .zig FILE WITH TESTS ⚠️ + +**This gets fucked up every single session. Read it. Do what it says.** + +`zig build test` runs tests from `test` blocks in files that are part of the +test binary's compilation unit AND are reachable from `src/main.zig`'s +import graph in a way that `refAllDeclsRecursive` actually visits the file +struct itself (not just a type extracted from it). + +**The failure mode:** you add `src/models/foo.zig` with 20 tests. You wire +it into `src/service.zig` via `const foo = @import("models/foo.zig");` and +re-export a type from `root.zig` as `pub const foo = @import("models/foo.zig");`. +You run `zig build test` and the test count does NOT go up. The file +**compiles** (because `foo.Bar` is referenced as a function return type), +but the `test` blocks inside it are never run. + +**Why:** `root.zig` is imported into `main.zig` via +`const zfin = @import("root.zig");` — non-pub. `refAllDeclsRecursive` walks +`@typeInfo(@This()).@"struct".decls` at main.zig, which only surfaces some +of the decl graph. The `pub const foo = @import(...)` in root.zig is not +reliably traversed from main.zig's test root, so `foo.zig`'s test blocks +aren't collected even though the file is compiled. + +**How to verify a new file's tests are discovered:** + +1. Before relying on the test count, add a canary that MUST fail: + ```zig + test "CANARY_DISCOVERY_CHECK_REMOVE_ME" { + try std.testing.expect(false); + } + ``` +2. Run `zig build test --summary all 2>&1 | grep -E "tests passed|error:"`. +3. If the canary test appears in failures → discovery works, remove canary. +4. If the canary does NOT appear and total count is unchanged → see fix below. + +**Fix:** add an explicit import in the `test` block at the bottom of +`src/main.zig`: + +```zig +test { + std.testing.refAllDeclsRecursive(@This()); + _ = @import("models/foo.zig"); // ← new entry for each orphaned file +} +``` + +Adding it inside the `test` block (not at file scope as a `comptime` block) +keeps the non-test build unaffected while guaranteeing the test binary +sema-reaches the file and collects its test blocks. + +**Rule of thumb:** after adding ANY new `.zig` file under `src/` that contains +`test` blocks, run `zig build test --summary all 2>&1 | grep "tests passed"` +BEFORE and AFTER the change. If the delta doesn't match +`rg -c "^test " path/to/new_file.zig`, add the explicit import to main.zig's +test block. + +**Do NOT, under any circumstance, try to "fix" this by clearing the cache.** +The cache is not the problem. The import graph is the problem. Re-read +the prohibitions at the top of this file. + ### Adding a new CLI command 1. Create `src/commands/newcmd.zig` with a `pub fn run(allocator, *DataService, symbol, color, *Writer) !void` diff --git a/src/main.zig b/src/main.zig index a452ec9..87fd688 100644 --- a/src/main.zig +++ b/src/main.zig @@ -781,6 +781,16 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" { // Single test binary: all source is in one module (file imports, no module // boundaries), so refAllDeclsRecursive discovers every test in the tree. +// +// IMPORTANT: refAllDeclsRecursive only walks files reachable from main.zig's +// public decl graph. Files that are only reached indirectly (e.g. a module +// re-exported from root.zig as `pub const foo = @import("foo.zig")` where +// main.zig imports root.zig via a *non-pub* `const zfin = @import("root.zig")`) +// are compiled (because their types are referenced) but their `test` blocks +// are NOT collected. Add explicit `_ = @import("path/to/file.zig");` lines +// in the test block below for any such orphaned test files. +// See AGENTS.md → "Adding tests" for details. test { std.testing.refAllDeclsRecursive(@This()); + _ = @import("models/transaction_log.zig"); } diff --git a/src/models/transaction_log.zig b/src/models/transaction_log.zig new file mode 100644 index 0000000..b3486b4 --- /dev/null +++ b/src/models/transaction_log.zig @@ -0,0 +1,548 @@ +//! Transaction log — the wire format for `transaction_log.srf`. +//! +//! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf` +//! that declares real-world transactions which adjust interpretation of +//! the portfolio diff. In v1, the only record kind is `transfer::` — +//! used to mark money moving between accounts the user owns, so that +//! the contributions pipeline doesn't double-count transfers as new +//! contributions. +//! +//! ## Why this file exists +//! +//! `portfolio.srf` answers "what do I have" — state. But some events +//! that affect contribution attribution aren't state; they're +//! transactions. The biggest current gap: account transfers get +//! double-counted as contributions because the receiving side's +//! `new_*` lots count toward attribution and the sending side's +//! `lot_removed` is silently ignored. A six-figure transfer from one +//! account to another inflates reported "contributions" by that full +//! amount. Existing classifications can't tell a transfer from a real +//! external contribution/withdrawal just from the diff. +//! +//! ## One record per destination +//! +//! A transfer record pins exactly ONE destination — a specific lot (by +//! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial +//! investments are recorded as multiple records sharing +//! `(date, from, to)` but differing in `dest_lot`. This keeps each +//! record self-validating and avoids multi-lot allocation ordering +//! concerns. +//! +//! ## Example records +//! +//! ``` +//! # Simple cash deposit +//! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash +//! +//! # Partial attribution: pre-existing cash + transfer → single stock lot +//! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 +//! +//! # Sweep into basket + residual (two records, same date/from/to) +//! transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 +//! transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash +//! ``` +//! +//! ## v1 scope +//! +//! - Only `transfer::` records (no buys/sells/dividends — those stay +//! inferred from the portfolio diff). +//! - Only `type::cash` is wired downstream. `type::in_kind` parses +//! successfully but is rejected by the contributions matcher with +//! an "in-kind transfers not yet supported" message. +//! - No historical reconstruction — forward-looking only. +//! +//! See `REPORT.md` §5 for the full usage guide and +//! `src/commands/contributions.zig` for the classifier integration. + +const std = @import("std"); +const srf = @import("srf"); +const Date = @import("date.zig").Date; + +const logger = std.log.scoped(.transaction_log); + +/// Kind of transfer. Only `cash` is wired into the contributions +/// classifier in v1. `in_kind` parses successfully so the file format +/// is forward-compatible, but the matcher will reject records with +/// this type until per-symbol in-kind matching is implemented. +pub const TransferType = enum { + cash, + in_kind, +}; + +/// Where a transfer landed inside the destination account. +/// +/// Either a specific lot (identified by its symbol + open_date, which +/// together disambiguate lots within a single account in the common +/// case) or the literal token `cash` (the transfer ended up as cash +/// balance on the destination account). +pub const DestLot = union(enum) { + lot: LotRef, + cash: void, + + pub const LotRef = struct { + symbol: []const u8, + open_date: Date, + }; + + /// srf parser hook. Accepts `cash` (case-insensitive) or + /// `SYMBOL@YYYY-MM-DD`. Any other shape is rejected. + pub fn srfParse(str: []const u8) !DestLot { + if (std.ascii.eqlIgnoreCase(str, "cash")) return .{ .cash = {} }; + const at = std.mem.indexOfScalar(u8, str, '@') orelse return error.InvalidDestLot; + if (at == 0) return error.InvalidDestLot; + if (at + 1 >= str.len) return error.InvalidDestLot; + const sym = str[0..at]; + const date_str = str[at + 1 ..]; + const date = Date.parse(date_str) catch return error.InvalidDestLot; + return .{ .lot = .{ .symbol = sym, .open_date = date } }; + } + + /// srf serializer hook. Emits `cash` or `SYMBOL@YYYY-MM-DD`. + /// Allocates the output buffer via `allocator` — caller (the SRF + /// OwnedRecord machinery) manages the lifetime. + pub fn srfFormat( + self: DestLot, + allocator: std.mem.Allocator, + comptime field_name: []const u8, + ) !srf.Value { + _ = field_name; + return switch (self) { + .cash => .{ .string = try allocator.dupe(u8, "cash") }, + .lot => |l| blk: { + // SYMBOL up to ~20 chars + '@' + 10-char date. + const buf = try std.fmt.allocPrint(allocator, "{s}@", .{l.symbol}); + defer allocator.free(buf); + var out = try allocator.alloc(u8, buf.len + 10); + @memcpy(out[0..buf.len], buf); + _ = l.open_date.format(out[buf.len..][0..10]); + break :blk .{ .string = out }; + }, + }; + } + + /// Equality for tests and duplicate-dest_lot detection in the matcher. + pub fn eql(self: DestLot, other: DestLot) bool { + return switch (self) { + .cash => other == .cash, + .lot => |a| switch (other) { + .cash => false, + .lot => |b| a.open_date.days == b.open_date.days and std.mem.eql(u8, a.symbol, b.symbol), + }, + }; + } +}; + +/// One transfer record. All string fields are owned by the containing +/// `TransactionLog` when the record was produced by +/// `parseTransactionLogFile` — the log's allocator frees them on +/// `deinit`. Records constructed by hand for tests can use any +/// lifetime the caller prefers. +/// +/// The first field `transfer` is named to match the SRF on-wire record +/// tag — `transfer::,...`. SRF's `fields.to(T)` coerces fields +/// by name-matching against the struct, so `transfer: Date` maps the +/// record tag's value (the date) into this field. Other code refers to +/// it as `r.transfer` (reads as "the date this transfer is keyed by"). +pub const TransferRecord = struct { + transfer: Date, + type: TransferType = .cash, + amount: f64, + from: []const u8, + to: []const u8, + dest_lot: DestLot, + note: ?[]const u8 = null, +}; + +/// Parsed transaction log. `transfers` is allocator-owned; all string +/// fields on each record (including the symbol inside `dest_lot.lot`) +/// are also owned by the log's allocator. +pub const TransactionLog = struct { + transfers: []TransferRecord, + allocator: std.mem.Allocator, + + pub fn deinit(self: *TransactionLog) void { + for (self.transfers) |r| { + self.allocator.free(r.from); + self.allocator.free(r.to); + if (r.note) |n| self.allocator.free(n); + switch (r.dest_lot) { + .cash => {}, + .lot => |l| self.allocator.free(l.symbol), + } + } + self.allocator.free(self.transfers); + } + + /// Return transfers whose `transfer` date falls within `[start, end]` + /// inclusive. The returned slice is allocator-owned — caller must + /// free it. + /// + /// Works only because `parseTransactionLogFile` preserves file + /// order. If callers ever need chronological ordering regardless + /// of file layout, sort on the way out instead of on the way in — + /// file order is sometimes meaningful for a human reviewer + /// (grouping related records together). + pub fn transfersInWindow( + self: *const TransactionLog, + allocator: std.mem.Allocator, + start: Date, + end: Date, + ) ![]const TransferRecord { + var out: std.ArrayList(TransferRecord) = .empty; + errdefer out.deinit(allocator); + for (self.transfers) |r| { + if (r.transfer.days < start.days) continue; + if (r.transfer.days > end.days) continue; + try out.append(allocator, r); + } + return try out.toOwnedSlice(allocator); + } +}; + +/// Parse `data` (the contents of a `transaction_log.srf` file) into a +/// `TransactionLog`. String fields on each returned record are duped +/// into `allocator`, so `data` can be freed immediately after this +/// call returns successfully. +/// +/// Malformed records are silently skipped — matches the resilience +/// pattern in `parseAccountsFile` / `parseClassificationFile`. The +/// only hard errors are allocator failures and SRF-level parse errors +/// that prevent the iterator from starting at all. +/// +/// The SRF record layout is `transfer::,type::,amount:num:, +/// from::,to::,dest_lot::
[,note::]`. SRF's +/// `fields.to(TransferRecord)` does the coercion: each key matches a +/// struct field by name, defaults fill in elided optional fields, +/// `DestLot.srfParse` handles `dest_lot`, and `TransferType` gets its +/// enum value from the string. +pub fn parseTransactionLogFile( + allocator: std.mem.Allocator, + data: []const u8, +) !TransactionLog { + var out: std.ArrayList(TransferRecord) = .empty; + errdefer { + for (out.items) |r| { + allocator.free(r.from); + allocator.free(r.to); + if (r.note) |n| allocator.free(n); + switch (r.dest_lot) { + .cash => {}, + .lot => |l| allocator.free(l.symbol), + } + } + out.deinit(allocator); + } + + var reader = std.Io.Reader.fixed(data); + var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer it.deinit(); + + while (try it.next()) |fields| { + const parsed = fields.to(TransferRecord) catch |err| { + logger.warn("skipping malformed transfer record: {s}", .{@errorName(err)}); + continue; + }; + // String fields on `parsed` point into the iterator's internal + // buffer — dupe them before the next `it.next()` call. + const dest_lot_owned: DestLot = switch (parsed.dest_lot) { + .cash => .{ .cash = {} }, + .lot => |l| .{ .lot = .{ + .symbol = try allocator.dupe(u8, l.symbol), + .open_date = l.open_date, + } }, + }; + errdefer switch (dest_lot_owned) { + .cash => {}, + .lot => |l| allocator.free(l.symbol), + }; + try out.append(allocator, .{ + .transfer = parsed.transfer, + .type = parsed.type, + .amount = parsed.amount, + .from = try allocator.dupe(u8, parsed.from), + .to = try allocator.dupe(u8, parsed.to), + .dest_lot = dest_lot_owned, + .note = if (parsed.note) |n| try allocator.dupe(u8, n) else null, + }); + } + + return .{ + .transfers = try out.toOwnedSlice(allocator), + .allocator = allocator, + }; +} + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +test "DestLot.srfParse: cash token (lowercase)" { + const d = try DestLot.srfParse("cash"); + try testing.expect(d == .cash); +} + +test "DestLot.srfParse: cash token (mixed case)" { + const d = try DestLot.srfParse("Cash"); + try testing.expect(d == .cash); + const d2 = try DestLot.srfParse("CASH"); + try testing.expect(d2 == .cash); +} + +test "DestLot.srfParse: SYMBOL@DATE" { + const d = try DestLot.srfParse("SYM@2026-05-03"); + try testing.expect(d == .lot); + try testing.expectEqualStrings("SYM", d.lot.symbol); + try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, d.lot.open_date.days); +} + +test "DestLot.srfParse: multi-char symbol with hyphen" { + const d = try DestLot.srfParse("SYM-ABC@2026-05-03"); + try testing.expect(d == .lot); + try testing.expectEqualStrings("SYM-ABC", d.lot.symbol); +} + +test "DestLot.srfParse: missing @ rejected (non-cash)" { + try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM2026-05-03")); +} + +test "DestLot.srfParse: empty symbol rejected" { + try testing.expectError(error.InvalidDestLot, DestLot.srfParse("@2026-05-03")); +} + +test "DestLot.srfParse: missing date rejected" { + try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@")); +} + +test "DestLot.srfParse: malformed date rejected" { + try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@2026/05/03")); + try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@not-a-date")); +} + +test "DestLot.srfFormat: cash" { + var buf: [64]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const v = try (DestLot{ .cash = {} }).srfFormat(fba.allocator(), "dest_lot"); + try testing.expectEqualStrings("cash", v.string); +} + +test "DestLot.srfFormat: lot round-trip" { + var buf: [64]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buf); + const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + const v = try orig.srfFormat(fba.allocator(), "dest_lot"); + try testing.expectEqualStrings("SYM@2026-05-03", v.string); + // Round-trip back through parse + const parsed = try DestLot.srfParse(v.string); + try testing.expect(orig.eql(parsed)); +} + +test "DestLot.eql: cash vs cash" { + try testing.expect((DestLot{ .cash = {} }).eql(.{ .cash = {} })); +} + +test "DestLot.eql: cash vs lot" { + const c: DestLot = .{ .cash = {} }; + const l: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + try testing.expect(!c.eql(l)); + try testing.expect(!l.eql(c)); +} + +test "DestLot.eql: same lot" { + const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + try testing.expect(a.eql(b)); +} + +test "DestLot.eql: different symbol" { + const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + const b: DestLot = .{ .lot = .{ .symbol = "SYM2", .open_date = Date.fromYmd(2026, 5, 3) } }; + try testing.expect(!a.eql(b)); +} + +test "DestLot.eql: different date" { + const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } }; + const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 4) } }; + try testing.expect(!a.eql(b)); +} + +test "parseTransactionLogFile: empty file" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 0), log.transfers.len); +} + +test "parseTransactionLogFile: single cash transfer" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 1), log.transfers.len); + const r = log.transfers[0]; + try testing.expectEqual(Date.fromYmd(2026, 5, 2).days, r.transfer.days); + try testing.expectEqual(TransferType.cash, r.type); + try testing.expectEqual(@as(f64, 5000), r.amount); + try testing.expectEqualStrings("Acct A", r.from); + try testing.expectEqualStrings("Acct B", r.to); + try testing.expect(r.dest_lot == .cash); + try testing.expect(r.note == null); +} + +test "parseTransactionLogFile: single lot-destination transfer" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03,note::paycheck + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 1), log.transfers.len); + const r = log.transfers[0]; + try testing.expect(r.dest_lot == .lot); + try testing.expectEqualStrings("SYM", r.dest_lot.lot.symbol); + try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, r.dest_lot.lot.open_date.days); + try testing.expect(r.note != null); + try testing.expectEqualStrings("paycheck", r.note.?); +} + +test "parseTransactionLogFile: sweep encoded as two records" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 2), log.transfers.len); + + try testing.expectEqual(@as(f64, 145300), log.transfers[0].amount); + try testing.expect(log.transfers[0].dest_lot == .lot); + try testing.expectEqualStrings("SYM", log.transfers[0].dest_lot.lot.symbol); + + try testing.expectEqual(@as(f64, 4700), log.transfers[1].amount); + try testing.expect(log.transfers[1].dest_lot == .cash); + + // Sanity: both records preserved their (date, from, to) pairing. + try testing.expectEqual(log.transfers[0].transfer.days, log.transfers[1].transfer.days); + try testing.expectEqualStrings(log.transfers[0].from, log.transfers[1].from); + try testing.expectEqualStrings(log.transfers[0].to, log.transfers[1].to); +} + +test "parseTransactionLogFile: type defaults to cash when elided" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 1), log.transfers.len); + try testing.expectEqual(TransferType.cash, log.transfers[0].type); +} + +test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03 + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 1), log.transfers.len); + try testing.expectEqual(TransferType.in_kind, log.transfers[0].type); +} + +test "parseTransactionLogFile: malformed record skipped, subsequent record survives" { + // Resilience contract: one bad record doesn't wedge the parser. + // + // This is the only malformation shape we can test cleanly. The + // other two fail in ways that break the test runner itself: + // + // 1. Bad `dest_lot` value (e.g. `dest_lot::garbage-no-at`): + // `DestLot.srfParse` returns `error.InvalidDestLot`. SRF + // logs that at `err` level from inside `fields.to`, and the + // Zig test runner counts `log.err` calls BEFORE applying + // `std.testing.log_level` — so the test is marked "logged + // errors" regardless of how the test tries to suppress it. + // 2. Wrong value shape for a typed field (e.g. `amount::text` + // where `amount: f64` expects a `:num:` value): SRF's + // `coerce` panics on the union-tag mismatch + // (`@floatCast(val.?.number)` while `val.?` is `.string`). + // + // Missing-required-field is the one path SRF handles cleanly — + // `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue` + // (logged only at `debug` level). That's what we exercise here. + // + // TODO: upstream a per-call suppression knob on SRF (or downgrade + // the custom-parse log to `warn`) and re-enable a `dest_lot`- + // shape malformation test. Until then, `DestLot.srfParse`'s unit + // tests above cover that parser in isolation. + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,dest_lot::cash + \\transfer::2026-05-03,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + // First record missing `to` → skipped; second record survives. + try testing.expectEqual(@as(usize, 1), log.transfers.len); + try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount); +} + +test "transfersInWindow: inclusive on both ends" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-04-30,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-05-01,type::cash,amount:num:200,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-05-15,type::cash,amount:num:300,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-05-31,type::cash,amount:num:400,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-06-01,type::cash,amount:num:500,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + + const slice = try log.transfersInWindow( + testing.allocator, + Date.fromYmd(2026, 5, 1), + Date.fromYmd(2026, 5, 31), + ); + defer testing.allocator.free(slice); + try testing.expectEqual(@as(usize, 3), slice.len); + try testing.expectEqual(@as(f64, 200), slice[0].amount); + try testing.expectEqual(@as(f64, 300), slice[1].amount); + try testing.expectEqual(@as(f64, 400), slice[2].amount); +} + +test "transfersInWindow: empty window returns empty slice" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-01,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + + const slice = try log.transfersInWindow( + testing.allocator, + Date.fromYmd(2027, 1, 1), + Date.fromYmd(2027, 12, 31), + ); + defer testing.allocator.free(slice); + try testing.expectEqual(@as(usize, 0), slice.len); +} + +test "parseTransactionLogFile: preserves file order (not sorted)" { + var log = try parseTransactionLogFile(testing.allocator, + \\#!srfv1 + \\transfer::2026-05-15,type::cash,amount:num:3,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-05-01,type::cash,amount:num:1,from::Acct A,to::Acct B,dest_lot::cash + \\transfer::2026-05-08,type::cash,amount:num:2,from::Acct A,to::Acct B,dest_lot::cash + \\ + ); + defer log.deinit(); + try testing.expectEqual(@as(usize, 3), log.transfers.len); + // File order preserved — see `transfersInWindow` docstring for why. + try testing.expectEqual(@as(f64, 3), log.transfers[0].amount); + try testing.expectEqual(@as(f64, 1), log.transfers[1].amount); + try testing.expectEqual(@as(f64, 2), log.transfers[2].amount); +} diff --git a/src/service.zig b/src/service.zig index 8bda2b9..44c2ec0 100644 --- a/src/service.zig +++ b/src/service.zig @@ -22,6 +22,7 @@ const Config = @import("Config.zig"); const cache = @import("cache/store.zig"); const srf = @import("srf"); const analysis = @import("analytics/analysis.zig"); +const transaction_log = @import("models/transaction_log.zig"); const TwelveData = @import("providers/twelvedata.zig").TwelveData; const Polygon = @import("providers/polygon.zig").Polygon; const Fmp = @import("providers/fmp.zig").Fmp; @@ -1470,6 +1471,25 @@ pub const DataService = struct { return analysis.parseAccountsFile(self.allocator(), data) catch null; } + + /// Load and parse `transaction_log.srf` from the same directory as + /// the given portfolio path. Returns null if the file doesn't + /// exist or can't be parsed — the contributions pipeline falls + /// back to the pre-transaction-log behavior (no transfer netting) + /// when null is returned. + /// + /// Caller owns the returned `TransactionLog` and must call + /// `deinit()`. + pub fn loadTransferLog(self: *DataService, portfolio_path: []const u8) ?transaction_log.TransactionLog { + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const path = std.fmt.allocPrint(self.allocator(), "{s}transaction_log.srf", .{portfolio_path[0..dir_end]}) catch return null; + defer self.allocator().free(path); + + const data = std.fs.cwd().readFileAlloc(self.allocator(), path, 1024 * 1024) catch return null; + defer self.allocator().free(data); + + return transaction_log.parseTransactionLogFile(self.allocator(), data) catch null; + } }; // ── Tests ─────────────────────────────────────────────────────────