diff --git a/src/commands/audit.zig b/src/commands/audit.zig index b312692..47c5b64 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -205,7 +205,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); - try common.displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, "this export", out); } // Fidelity CSV @@ -237,7 +237,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); defer allocator.free(absent); - try common.displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, "this export", out); } // Schwab per-account CSV @@ -269,7 +269,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); - try common.displayAbsentAccounts(absent, color, out); + try common.displayAbsentAccounts(absent, color, "this export", out); } } diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index 521e52d..1e861f8 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -863,14 +863,19 @@ pub fn findAbsentAccounts( return results.toOwnedSlice(allocator); } -/// Render the "portfolio accounts not found in this export" advisory. +/// Render the "portfolio accounts not found in " advisory. /// Silent when `absent` is empty, so it composes cleanly after both /// the verbose audit table and the compact "no discrepancies" line. -pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void { +/// +/// `scope_phrase` names what the absence is relative to: the explicit +/// single-file command passes "this export"; the flagless audit, which +/// unions the present accounts across every discovered file before +/// computing absence, passes "any export". +pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_phrase: []const u8, out: *std.Io.Writer) !void { if (absent.len == 0) return; try out.print("\n", .{}); - try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{}); + try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in {s}", .{scope_phrase}); try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{}); for (absent) |a| { try out.print(" ", .{}); @@ -1652,7 +1657,7 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise // Empty -> no output. { var w = std.Io.Writer.fixed(&buf); - try displayAbsentAccounts(&.{}, false, &w); + try displayAbsentAccounts(&.{}, false, "this export", &w); try std.testing.expectEqual(@as(usize, 0), w.buffered().len); } @@ -1662,11 +1667,21 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise const absent = [_]AbsentAccount{ .{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 }, }; - try displayAbsentAccounts(&absent, false, &w); + try displayAbsentAccounts(&absent, false, "this export", &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null); try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null); try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null); } + + // The scope phrase is caller-supplied (flagless audit passes "any export"). + { + var w = std.Io.Writer.fixed(&buf); + const absent = [_]AbsentAccount{ + .{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 }, + }; + try displayAbsentAccounts(&absent, false, "any export", &w); + try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null); + } } diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 8f36e28..e428fd5 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -499,6 +499,27 @@ fn printLargeLotWarning( } } +/// 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, @@ -860,6 +881,23 @@ pub fn runHygieneCheck( 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); @@ -869,11 +907,6 @@ pub fn runHygieneCheck( const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue; defer allocator.free(results); - const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); - defer allocator.free(present); - const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); - defer allocator.free(absent); - if (verbose or schwab.hasSchwabDiscrepancies(results)) { try out.print("\n", .{}); try schwab.displaySchwabResults(results, color, out); @@ -889,7 +922,8 @@ pub fn runHygieneCheck( // non-zero delta that still deserves a nudge. try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); } - try common.displayAbsentAccounts(absent, 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 continue; @@ -898,11 +932,6 @@ pub fn runHygieneCheck( allocator.free(results); } - const present = try common.presentNumbers(allocator, common.AccountComparison, results); - defer allocator.free(present); - const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); - defer allocator.free(absent); - if (verbose or common.hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try common.displayResults(results, color, out); @@ -912,7 +941,8 @@ pub fn runHygieneCheck( // Always show ratio suggestions even in compact mode try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } - try common.displayAbsentAccounts(absent, 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 continue; @@ -921,11 +951,6 @@ pub fn runHygieneCheck( allocator.free(results); } - const present = try common.presentNumbers(allocator, common.AccountComparison, results); - defer allocator.free(present); - const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); - defer allocator.free(absent); - if (verbose or common.hasAccountDiscrepancies(results)) { try out.print("\n", .{}); try common.displayResults(results, color, out); @@ -934,10 +959,27 @@ pub fn runHygieneCheck( try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len}); try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); } - try common.displayAbsentAccounts(absent, 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 5: Large new lots - confirm source ── @@ -970,6 +1012,53 @@ pub fn runHygieneCheck( // ── 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 "detectBrokerFileKind: fidelity csv" { const fidelity_header = "Account Number,Account Name,Symbol,Description"; try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);