//! View model joining `analytics/observations` engine output with the //! `data/journal` acknowledgments file. Produces a flat list of //! `FindingRow`s ready for both the CLI findings table and the TUI //! review tab. //! //! ## Responsibility split //! //! - The **engine** (`analytics/observations.zig`) produces a //! `CheckPanel` whose `pending` slice has one entry per registered //! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`. //! - The **journal** (`data/Journal.zig`) holds the user's //! acknowledgments — durable records keyed by `(observation, target)`. //! - This **view** matches the two: every `Observation` in a //! `warn`/`flag` result becomes a `FindingRow`. The row is marked //! acked iff a `Journal.Entry` exists with `state == .acknowledged` //! and matching `(observation, target)`. //! //! ## Lifetime //! //! `FindingRow.text` and friends are **borrowed** from the panel and //! journal — the view does NOT copy strings. This keeps allocation //! cheap (one slice for the rows array) and is safe because callers //! always hold the panel and journal alive for the entire render //! frame. `FindingsView.deinit` only frees the rows slice. //! //! ## Filtering //! //! Resolved entries (state == `.resolved`) are never shown — by //! definition, the engine no longer emits the finding, so there's //! nothing to suppress. Only `acknowledged` rows can be filtered out //! via `show_acked = false`. const std = @import("std"); const observations = @import("../analytics/observations.zig"); const Journal = @import("../data/Journal.zig"); const Observation = observations.Observation; const Severity = observations.Severity; const CheckPanel = observations.CheckPanel; /// One displayable finding. Borrows all string data from the /// underlying `Observation` and (optionally) the matching journal /// entry. Lifetime is bounded by the panel + journal. pub const FindingRow = struct { severity: Severity, /// Borrowed from `Observation.kind`. kind: []const u8, /// Borrowed from `Observation.target`. target: []const u8, /// Borrowed from `Observation.text`. text: []const u8, /// True iff a journal entry matches `(kind, target)` with state /// `.acknowledged`. When true, `ack_entry` is non-null. is_acked: bool, /// The matching journal entry (when `is_acked == true`), so /// renderers can pull notes/acknowledged_at out without re-doing /// the lookup. Null when no ack matches. ack_entry: ?*const Journal.Entry = null, }; /// Result of `build`. Owns only the `rows` slice; all string data /// is borrowed. pub const FindingsView = struct { rows: []FindingRow, /// Number of un-acked findings (severity warn or flag, not /// suppressed). Independent of `show_acked` — counts the underlying /// engine output. total_active: usize, /// Number of findings whose journal entry is in `.acknowledged` /// state. total_acked: usize, /// Number of journal entries currently in `.resolved` state. These /// are never rendered as rows; surfaced only for the header line /// ("3 active, 1 acked, 2 resolved"). total_resolved: usize, pub fn deinit(self: *FindingsView, allocator: std.mem.Allocator) void { allocator.free(self.rows); self.* = undefined; } }; /// Build a `FindingsView` from a panel + journal. `show_acked` /// controls whether acked findings appear in `rows` (they're always /// counted in `total_acked`). pub fn build( allocator: std.mem.Allocator, panel: *const CheckPanel, journal: *const Journal, show_acked: bool, ) !FindingsView { var rows = std.ArrayList(FindingRow).empty; errdefer rows.deinit(allocator); var total_active: usize = 0; var total_acked: usize = 0; for (panel.pending) |pc| { const obs_slice: []const Observation = switch (pc.state) { .complete => |r| switch (r) { .warn => |o| o, .flag => |o| o, .pass, .skipped, .err => continue, }, }; for (obs_slice) |obs| { const ack = findAck(journal, obs.kind, obs.target); const is_acked = ack != null; if (is_acked) { total_acked += 1; } else { total_active += 1; } if (is_acked and !show_acked) continue; try rows.append(allocator, .{ .severity = obs.severity, .kind = obs.kind, .target = obs.target, .text = obs.text, .is_acked = is_acked, .ack_entry = ack, }); } } // Count resolved entries for the header. These never produce rows // — the engine's failure to re-emit the finding is what marks // them resolved — but we surface the count so the user knows // their journal still tracks them. var total_resolved: usize = 0; for (journal.entries) |*e| { if (e.ack.state == .resolved) total_resolved += 1; } return .{ .rows = try rows.toOwnedSlice(allocator), .total_active = total_active, .total_acked = total_acked, .total_resolved = total_resolved, }; } /// Look up a journal entry by `(observation, target)`, returning /// only entries currently in `.acknowledged` state. Returns null /// when no match. /// /// Linear scan is fine for realistic journal sizes (tens of entries). /// If a portfolio ever accumulates hundreds of acks we can index by /// (observation, target) at load time. fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const Journal.Entry { for (journal.entries) |*e| { if (e.ack.state != .acknowledged) continue; if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue; return e; } return null; } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; const Date = @import("../Date.zig"); fn makeObs(kind: []const u8, target: []const u8, text: []const u8) Observation { return .{ .severity = .warn, .kind = kind, .target = target, .text = text, }; } fn makePanel(allocator: std.mem.Allocator, obs_slice: []const Observation) !CheckPanel { // Allocate a single-check panel with the supplied observations as // a `warn` result. Test-only helper. Dupes all strings so that // `panel.deinit`'s `freeObservations` path (which calls // `allocator.free` on each string) operates on owned memory rather // than caller-supplied literals. const owned_obs = try allocator.alloc(Observation, obs_slice.len); errdefer allocator.free(owned_obs); for (obs_slice, 0..) |o, i| { owned_obs[i] = .{ .severity = o.severity, .kind = try allocator.dupe(u8, o.kind), .target = try allocator.dupe(u8, o.target), .text = try allocator.dupe(u8, o.text), }; } const check_singleton = struct { const c: observations.Check = .{ .name = "test_check", .label = "Test Check", // SAFETY: test-only Check; tests fabricate panels with // pre-baked CheckResults via `state = .{ .complete = ... }`, // so `runChecks` never dispatches via this fn pointer. .run = undefined, }; }; const pending = try allocator.alloc(observations.PendingCheck, 1); pending[0] = .{ .check = &check_singleton.c, .state = .{ .complete = .{ .warn = owned_obs } }, }; return .{ .allocator = allocator, .pending = pending }; } fn makeJournalWithAck( allocator: std.mem.Allocator, observation: []const u8, target: []const u8, state: Journal.State, ) !Journal { const obs_dup = try allocator.dupe(u8, observation); const tgt_dup = try allocator.dupe(u8, target); const empty_notes = try allocator.alloc([]const u8, 0); const entries = try allocator.alloc(Journal.Entry, 1); entries[0] = .{ .ack = .{ .observation = obs_dup, .target = tgt_dup, .acknowledged_at = Date.fromYmd(2026, 1, 1), .state = state, }, .notes = empty_notes, }; return .{ .allocator = allocator, .entries = entries }; } fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal { const entries = try allocator.alloc(Journal.Entry, 0); return .{ .allocator = allocator, .entries = entries }; } test "build: unacked finding produces a row, no ack_entry" { const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), }; var panel = try makePanel(testing.allocator, &obs); defer panel.deinit(); var journal = try makeEmptyJournal(testing.allocator); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, false); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), view.rows.len); try testing.expectEqual(@as(usize, 1), view.total_active); try testing.expectEqual(@as(usize, 0), view.total_acked); try testing.expect(!view.rows[0].is_acked); try testing.expect(view.rows[0].ack_entry == null); } test "build: acked finding suppressed when show_acked is false" { const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), }; var panel = try makePanel(testing.allocator, &obs); defer panel.deinit(); var journal = try makeJournalWithAck( testing.allocator, "position_concentration", "NVDA", .acknowledged, ); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, false); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), view.rows.len); try testing.expectEqual(@as(usize, 0), view.total_active); try testing.expectEqual(@as(usize, 1), view.total_acked); } test "build: acked finding rendered when show_acked is true" { const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), }; var panel = try makePanel(testing.allocator, &obs); defer panel.deinit(); var journal = try makeJournalWithAck( testing.allocator, "position_concentration", "NVDA", .acknowledged, ); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, true); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), view.rows.len); try testing.expect(view.rows[0].is_acked); try testing.expect(view.rows[0].ack_entry != null); } test "build: resolved entries don't filter findings" { // Engine emits a finding for NVDA. Journal has a *resolved* entry // for NVDA. The finding should NOT be suppressed — resolved means // "engine stopped emitting it last time", but here it's emitting // again. const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), }; var panel = try makePanel(testing.allocator, &obs); defer panel.deinit(); var journal = try makeJournalWithAck( testing.allocator, "position_concentration", "NVDA", .resolved, ); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, false); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), view.rows.len); try testing.expect(!view.rows[0].is_acked); try testing.expectEqual(@as(usize, 1), view.total_resolved); } test "build: target mismatch — different symbol doesn't match ack" { const obs = [_]Observation{ makeObs("position_concentration", "NVDA", "NVDA at 18%"), makeObs("position_concentration", "AAPL", "AAPL at 17%"), }; var panel = try makePanel(testing.allocator, &obs); defer panel.deinit(); // Ack only NVDA; AAPL should still appear. var journal = try makeJournalWithAck( testing.allocator, "position_concentration", "NVDA", .acknowledged, ); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, false); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), view.rows.len); try testing.expectEqualStrings("AAPL", view.rows[0].target); try testing.expectEqual(@as(usize, 1), view.total_active); try testing.expectEqual(@as(usize, 1), view.total_acked); } test "build: pass/skipped/err checks contribute no rows" { // Build a panel with a pass result by hand. const check_singleton = struct { const c: observations.Check = .{ .name = "pass_check", .label = "Pass Check", // SAFETY: test-only Check; the panel is built with a // pre-baked `.complete = .pass` result so `runChecks` // never dispatches through this pointer. .run = undefined, }; }; const pending = try testing.allocator.alloc(observations.PendingCheck, 1); pending[0] = .{ .check = &check_singleton.c, .state = .{ .complete = .pass }, }; var panel = CheckPanel{ .allocator = testing.allocator, .pending = pending }; defer panel.deinit(); var journal = try makeEmptyJournal(testing.allocator); defer journal.deinit(); var view = try build(testing.allocator, &panel, &journal, false); defer view.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), view.rows.len); try testing.expectEqual(@as(usize, 0), view.total_active); try testing.expectEqual(@as(usize, 0), view.total_acked); }