From 4ed3b91fcebd71c043fc2acd04e6608e5a024093 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 9 Jun 2026 12:40:58 -0700 Subject: [PATCH] add observations view to allow displaying checks --- src/views/observations_view.zig | 391 ++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 src/views/observations_view.zig diff --git a/src/views/observations_view.zig b/src/views/observations_view.zig new file mode 100644 index 0000000..da7497b --- /dev/null +++ b/src/views/observations_view.zig @@ -0,0 +1,391 @@ +//! 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 `JournalEntry` 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_mod = @import("../data/journal.zig"); + +const Observation = observations.Observation; +const Severity = observations.Severity; +const CheckPanel = observations.CheckPanel; +const Journal = journal_mod.Journal; +const JournalEntry = journal_mod.JournalEntry; + +/// 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 JournalEntry = 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 JournalEntry { + 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_mod.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(JournalEntry, 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(JournalEntry, 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); +}