nornir-testmatrix 0.2.1

Reusable, multi-aspect Rust test-matrix engine: wrap a repo's native cargo test/nextest plus build/clippy/fmt/audit/coverage/doctest aspects, parse the results into rows, and ship them to any TestSink. Pure std + serde — no iceberg, arrow, eframe. The portable core of nornir's `nornir test` matrix.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
//! # autonom coverage gate — the PURE verdict model (AUT2 / n-005)
//!
//! [`discover`](crate::discover) builds the testable [`Surface`] and
//! [`compute_gap`] differences it against the covered + allowlisted sets. This
//! module adds the **persistence + gate-verdict** shapes that nornir's iceberg
//! sink and the `nornir test coverage` CLI / `test_coverage` MCP tool / viz Test
//! pane all share — kept here, PURE (std + serde), so the whole gate is unit
//! testable by feeding sample rows and asserting the verdict.
//!
//! ```text
//! Surface  = discover::* enumerators                         (the testable surface)
//! Covered  = a surface node reached by an inject-assert test (call_edges / tools/list / registry)
//! Allowed  = a checked-in autonom-allow.toml entry           (excused, with a reason)
//! Gap      = Surface − Covered − Allowed
//! GATE: Gap == ∅  AND  no STALE allowlist entry              (HARD zero, not a ratchet)
//! ```
//!
//! The [`CoverageRow`] is the warehouse row (one per `surface × mode × workspace
//! × verdict`) — the same shape as `tests/mcp_tool_coverage.json` generalized to
//! the whole surface. nornir's `surface_coverage` iceberg table writes/reads it.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::discover::{Gap, Surface, SurfaceNode};

/// The verdict a single surface node earned this gate run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
    /// Reached by an inject-assert test — the gate is happy.
    Covered,
    /// Not covered, but excused by a checked-in `autonom-allow.toml` entry.
    Allowlisted,
    /// Not covered and not excused — this is what makes the gate RED.
    Missing,
}

impl Verdict {
    pub fn label(self) -> &'static str {
        match self {
            Verdict::Covered => "covered",
            Verdict::Allowlisted => "allowlisted",
            Verdict::Missing => "missing",
        }
    }

    pub fn parse(s: &str) -> Option<Verdict> {
        match s {
            "covered" => Some(Verdict::Covered),
            "allowlisted" => Some(Verdict::Allowlisted),
            "missing" => Some(Verdict::Missing),
            _ => None,
        }
    }
}

/// One persisted coverage row — the `surface_coverage` warehouse fact, mirroring
/// `tests/mcp_tool_coverage.json` but for the WHOLE discovered surface. One row
/// per `(surface_key, mode, workspace)` with its [`Verdict`].
///
/// `surface_key` is [`SurfaceNode::key_str`] (`"kind:id@mode"`) — the stable join
/// key the gate matched coverage on. `kind`/`id`/`mode` are split out as columns
/// so the warehouse / viz can filter without re-parsing the key.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageRow {
    /// The run that produced this row (groups one gate run's rows).
    pub run_id: String,
    /// The workspace this surface belongs to (so a multi-workspace warehouse
    /// keeps each workspace's surface distinct).
    pub workspace: String,
    /// `SurfaceNode::key_str()` — `"kind:id@mode"`. The stable identity.
    pub surface_key: String,
    /// The enumerator kind tag (`facett_component` / `viz_tab` / `cli_command` /
    /// `mcp_tool` / `function`).
    pub kind: String,
    /// The surface id within its kind (component / tab / cmd / tool / fn path).
    pub id: String,
    /// The thin/fat mode (`fat` / `thin` / `na`).
    pub mode: String,
    /// `covered` / `allowlisted` / `missing`.
    pub verdict: String,
    /// The allowlist reason (only set when `verdict == allowlisted`).
    #[serde(default)]
    pub reason: String,
    /// Row timestamp (micros). Shared across one run's rows.
    #[serde(default)]
    pub ts_micros: i64,
}

impl CoverageRow {
    /// Build a row from a node + verdict (the writer's per-node mapping).
    pub fn from_node(
        run_id: &str,
        workspace: &str,
        node: &SurfaceNode,
        verdict: Verdict,
        reason: &str,
        ts_micros: i64,
    ) -> Self {
        CoverageRow {
            run_id: run_id.to_string(),
            workspace: workspace.to_string(),
            surface_key: node.key_str(),
            kind: node.kind.label().to_string(),
            id: node.id.clone(),
            mode: node.mode.label().to_string(),
            verdict: verdict.label().to_string(),
            reason: reason.to_string(),
            ts_micros,
        }
    }

    /// The parsed verdict (defaults to [`Verdict::Missing`] for an unknown tag —
    /// fail-safe: an unrecognized verdict counts against the gate, never for it).
    pub fn verdict(&self) -> Verdict {
        Verdict::parse(&self.verdict).unwrap_or(Verdict::Missing)
    }
}

/// One allowlist entry from the checked-in `autonom-allow.toml`. An entry
/// **excuses** a surface node from the gate — but it must carry a `reason`
/// (often a `TODO`/issue ref) so the excuse is visible and burns down by
/// deletion, never silently.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllowEntry {
    /// `SurfaceNode::key_str()` — `"kind:id@mode"`. The node this excuses.
    pub key: String,
    /// Why it's excused (a TODO / issue link). REQUIRED — a blank reason is a
    /// stale-ish smell the seeder fills with a placeholder.
    #[serde(default)]
    pub reason: String,
}

/// The parsed `autonom-allow.toml` — a flat list of [`AllowEntry`]. Serializes
/// to/from `[[allow]]` tables (the caller does the toml (de)serialize; this
/// stays serde-pure).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Allowlist {
    #[serde(default, rename = "allow")]
    pub entries: Vec<AllowEntry>,
}

impl Allowlist {
    pub fn new() -> Self {
        Self::default()
    }

    /// The set of excused keys — what [`compute_gap`](crate::discover::compute_gap)
    /// consumes as its `allowlist` argument.
    pub fn key_set(&self) -> BTreeSet<String> {
        self.entries.iter().map(|e| e.key.clone()).collect()
    }

    /// Map key → reason for annotating allowlisted rows.
    pub fn reasons(&self) -> BTreeMap<String, String> {
        self.entries.iter().map(|e| (e.key.clone(), e.reason.clone())).collect()
    }
}

/// SEED an allowlist with EVERY currently-uncovered surface node (`--seed-allowlist`).
///
/// Every node **not** in `covered` gets an entry with a TODO reason, so the gate
/// goes GREEN *now* and the allowlist burns down by deleting entries as tests are
/// wired. Already-covered nodes are NOT seeded (they don't need an excuse).
/// Existing entries' reasons are preserved (re-seeding doesn't clobber a hand
/// reason); new uncovered nodes are appended. Sorted by key for a stable file.
pub fn seed_allowlist(
    surface: &Surface,
    covered: &BTreeSet<String>,
    existing: &Allowlist,
) -> Allowlist {
    let prior: BTreeMap<String, String> = existing.reasons();
    let mut entries: Vec<AllowEntry> = Vec::new();
    for node in &surface.nodes {
        let key = node.key_str();
        if covered.contains(&key) {
            continue; // covered → no excuse needed
        }
        let reason = prior
            .get(&key)
            .filter(|r| !r.is_empty())
            .cloned()
            .unwrap_or_else(|| format!("TODO(autonom): wire an inject-assert test for {key}"));
        entries.push(AllowEntry { key, reason });
    }
    entries.sort_by(|a, b| a.key.cmp(&b.key));
    Allowlist { entries }
}

/// STALE allowlist entries: keys on the allowlist that are **no longer needed** —
/// either the node is now covered, or the node no longer exists in the surface.
/// A stale entry FAILS the gate (the allowlist must burn down, not rot): an
/// excuse outliving its surface is exactly the drift autonom kills.
///
/// Returns the offending [`AllowEntry`]s sorted by key.
pub fn stale_allowlist_entries(
    surface: &Surface,
    covered: &BTreeSet<String>,
    allowlist: &Allowlist,
) -> Vec<AllowEntry> {
    let surface_keys: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
    let mut stale: Vec<AllowEntry> = allowlist
        .entries
        .iter()
        .filter(|e| covered.contains(&e.key) || !surface_keys.contains(&e.key))
        .cloned()
        .collect();
    stale.sort_by(|a, b| a.key.cmp(&b.key));
    stale
}

/// The full gate verdict for one run — the thing the CLI prints, the viz Test
/// pane shows, and the release gate fails on. Combines the [`Gap`] (missing /
/// allowlisted / covered counts) with the STALE-allowlist check.
///
/// `is_green()` is the HARD-zero verdict: **no missing surface AND no stale
/// allowlist entry**.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateReport {
    pub run_id: String,
    pub workspace: String,
    /// The differenced gap (missing + allowlisted + covered/total counts).
    pub gap: Gap,
    /// Allowlist entries that are no longer needed (covered or surface-gone).
    pub stale: Vec<AllowEntry>,
}

impl GateReport {
    /// Build the report by differencing the surface against covered + allowlist,
    /// then checking for stale allowlist entries.
    pub fn compute(
        run_id: &str,
        workspace: &str,
        surface: &Surface,
        covered: &BTreeSet<String>,
        allowlist: &Allowlist,
    ) -> GateReport {
        let gap = crate::discover::compute_gap(surface, covered, &allowlist.key_set());
        let stale = stale_allowlist_entries(surface, covered, allowlist);
        GateReport {
            run_id: run_id.to_string(),
            workspace: workspace.to_string(),
            gap,
            stale,
        }
    }

    /// GREEN ⟺ no missing surface AND no stale allowlist entry. The HARD-zero
    /// gate: `Gap == ∅` and the allowlist is fully justified.
    pub fn is_green(&self) -> bool {
        self.gap.is_clean() && self.stale.is_empty()
    }

    /// One-line human summary for the CLI.
    pub fn summary(&self) -> String {
        format!(
            "{} · {} stale allowlist entr{}{}",
            self.gap.summary(),
            self.stale.len(),
            if self.stale.len() == 1 { "y" } else { "ies" },
            if self.is_green() { "GREEN" } else { "RED" },
        )
    }

    /// The persisted rows for this report (covered are NOT in the gap, so they're
    /// reconstructed from `surface − missing − allowlisted` by the writer; here we
    /// emit the missing + allowlisted rows which carry the actionable verdicts).
    /// The caller passes the full surface + the covered set to also emit covered
    /// rows; see [`rows_for`].
    pub fn actionable_rows(&self, ts_micros: i64) -> Vec<CoverageRow> {
        let reasons: BTreeMap<String, String> = BTreeMap::new(); // gap has no reasons
        let mut rows = Vec::new();
        for node in &self.gap.missing {
            rows.push(CoverageRow::from_node(
                &self.run_id,
                &self.workspace,
                node,
                Verdict::Missing,
                "",
                ts_micros,
            ));
        }
        for node in &self.gap.allowlisted {
            let reason = reasons.get(&node.key_str()).cloned().unwrap_or_default();
            rows.push(CoverageRow::from_node(
                &self.run_id,
                &self.workspace,
                node,
                Verdict::Allowlisted,
                &reason,
                ts_micros,
            ));
        }
        rows
    }
}

/// Build the FULL per-node coverage rows (covered + allowlisted + missing) for
/// persistence — one row per surface node. This is the writer's source: a row
/// for every discovered surface node, tagged with its verdict (and an allowlist
/// reason where applicable). The gate joins these back on `surface_key`.
pub fn rows_for(
    run_id: &str,
    workspace: &str,
    surface: &Surface,
    covered: &BTreeSet<String>,
    allowlist: &Allowlist,
    ts_micros: i64,
) -> Vec<CoverageRow> {
    let allow_keys = allowlist.key_set();
    let reasons = allowlist.reasons();
    let mut rows: Vec<CoverageRow> = surface
        .nodes
        .iter()
        .map(|node| {
            let key = node.key_str();
            let (verdict, reason) = if covered.contains(&key) {
                (Verdict::Covered, String::new())
            } else if allow_keys.contains(&key) {
                (Verdict::Allowlisted, reasons.get(&key).cloned().unwrap_or_default())
            } else {
                (Verdict::Missing, String::new())
            };
            CoverageRow::from_node(run_id, workspace, node, verdict, &reason, ts_micros)
        })
        .collect();
    rows.sort_by(|a, b| a.surface_key.cmp(&b.surface_key));
    rows
}

/// Summarize persisted [`CoverageRow`]s back into a compact verdict for the viz
/// Test pane (`state_json["test"]["coverage"]`) and the `test_coverage` tool.
/// Counts by verdict + lists the missing keys (the actionable gap).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageSummary {
    pub run_id: String,
    pub workspace: String,
    pub total: usize,
    pub covered: usize,
    pub allowlisted: usize,
    pub gap: usize,
    /// The missing surface keys (`"kind:id@mode"`), sorted — the burn-down list.
    pub missing: Vec<String>,
    /// GREEN ⟺ gap == 0.
    pub green: bool,
}

impl CoverageSummary {
    /// Roll persisted rows (one run) into the viz/CLI summary.
    pub fn from_rows(rows: &[CoverageRow]) -> CoverageSummary {
        let run_id = rows.first().map(|r| r.run_id.clone()).unwrap_or_default();
        let workspace = rows.first().map(|r| r.workspace.clone()).unwrap_or_default();
        let mut covered = 0;
        let mut allowlisted = 0;
        let mut missing: Vec<String> = Vec::new();
        for r in rows {
            match r.verdict() {
                Verdict::Covered => covered += 1,
                Verdict::Allowlisted => allowlisted += 1,
                Verdict::Missing => missing.push(r.surface_key.clone()),
            }
        }
        missing.sort();
        let gap = missing.len();
        CoverageSummary {
            run_id,
            workspace,
            total: rows.len(),
            covered,
            allowlisted,
            gap,
            green: gap == 0,
            missing,
        }
    }

    /// The JSON the viz Test pane nests under `state_json["test"]["coverage"]`.
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "run_id": self.run_id,
            "workspace": self.workspace,
            "total": self.total,
            "covered": self.covered,
            "allowlisted": self.allowlisted,
            "gap": self.gap,
            "green": self.green,
            "missing": self.missing,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::discover::{cli_commands, mcp_tools, viz_tabs};

    fn sample_surface() -> Surface {
        let mut s = Surface::new();
        s.extend(viz_tabs(["Test"])) // viz_tab:Test@fat, viz_tab:Test@thin
            .extend(mcp_tools(["search"])) // mcp_tool:search@na
            .extend(cli_commands(["doctor"])); // cli_command:doctor@na
        s
    }

    #[test]
    fn seed_excuses_only_uncovered_with_reasons() {
        let surface = sample_surface(); // 4 nodes
        // Only Test@fat is covered.
        let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
        let seeded = seed_allowlist(&surface, &covered, &Allowlist::new());
        // 3 uncovered nodes seeded; the covered one is NOT.
        assert_eq!(seeded.entries.len(), 3);
        assert!(seeded.entries.iter().all(|e| e.reason.contains("TODO(autonom)")));
        assert!(!seeded.entries.iter().any(|e| e.key == "viz_tab:Test@fat"));
        assert!(seeded.entries.iter().any(|e| e.key == "viz_tab:Test@thin"));

        // With the seeded allowlist, the gate is GREEN now (everything excused).
        let report = GateReport::compute("r1", "ws", &surface, &covered, &seeded);
        assert!(report.is_green(), "seeded allowlist makes the gate green now");
        assert_eq!(report.gap.covered, 1);
        assert_eq!(report.gap.allowlisted.len(), 3);
        assert_eq!(report.gap.missing.len(), 0);
    }

    #[test]
    fn reseed_preserves_existing_reasons() {
        let surface = sample_surface();
        let covered = BTreeSet::new();
        let existing = Allowlist {
            entries: vec![AllowEntry {
                key: "viz_tab:Test@thin".into(),
                reason: "hand-written reason #42".into(),
            }],
        };
        let seeded = seed_allowlist(&surface, &covered, &existing);
        let thin = seeded.entries.iter().find(|e| e.key == "viz_tab:Test@thin").unwrap();
        assert_eq!(thin.reason, "hand-written reason #42", "existing reason preserved");
        // New nodes still get the TODO placeholder.
        let other = seeded.entries.iter().find(|e| e.key == "mcp_tool:search@na").unwrap();
        assert!(other.reason.contains("TODO(autonom)"));
    }

    #[test]
    fn unreached_makes_gap_reachable_does_not_allowlisted_excused() {
        let surface = sample_surface();
        // Cover everything EXCEPT Test@thin; allowlist Test@thin.
        let covered: BTreeSet<String> = [
            "viz_tab:Test@fat",
            "mcp_tool:search@na",
            "cli_command:doctor@na",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        let allowlist = Allowlist {
            entries: vec![AllowEntry {
                key: "viz_tab:Test@thin".into(),
                reason: "RPC wiring tracked in n-006".into(),
            }],
        };
        let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
        // Reachable (covered) → NOT in gap. Allowlisted → excused, not missing.
        assert!(report.is_green(), "all covered or excused → green");
        assert_eq!(report.gap.covered, 3);
        assert_eq!(report.gap.allowlisted.len(), 1);
        assert!(report.stale.is_empty());

        // Now REMOVE the cover for the cli command and DON'T allowlist it → RED.
        let covered2: BTreeSet<String> =
            ["viz_tab:Test@fat", "mcp_tool:search@na"].iter().map(|s| s.to_string()).collect();
        let report2 = GateReport::compute("r1", "ws", &surface, &covered2, &allowlist);
        assert!(!report2.is_green(), "an uncovered, un-allowlisted node makes it RED");
        assert_eq!(report2.gap.missing.len(), 1);
        assert_eq!(report2.gap.missing[0].key_str(), "cli_command:doctor@na");
    }

    #[test]
    fn stale_allowlist_entry_fails_the_gate() {
        let surface = sample_surface();
        // Everything covered.
        let covered: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
        // But the allowlist still excuses a now-COVERED node (stale) AND a
        // node that no longer exists in the surface (also stale).
        let allowlist = Allowlist {
            entries: vec![
                AllowEntry { key: "viz_tab:Test@thin".into(), reason: "old".into() },
                AllowEntry { key: "viz_tab:Ghost@fat".into(), reason: "deleted tab".into() },
            ],
        };
        let stale = stale_allowlist_entries(&surface, &covered, &allowlist);
        assert_eq!(stale.len(), 2, "both a now-covered and a surface-gone entry are stale");
        let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
        // Gap itself is empty (all covered) BUT the stale entries make it RED.
        assert!(report.gap.is_clean(), "no missing surface");
        assert!(!report.is_green(), "stale allowlist entries fail the HARD-zero gate");
        assert!(report.summary().contains("RED"));
    }

    #[test]
    fn rows_and_summary_round_trip_through_serde() {
        let surface = sample_surface();
        let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
        let allowlist = Allowlist {
            entries: vec![AllowEntry {
                key: "viz_tab:Test@thin".into(),
                reason: "excused".into(),
            }],
        };
        let rows = rows_for("r1", "ws", &surface, &covered, &allowlist, 123);
        assert_eq!(rows.len(), 4, "one row per surface node");
        // Verdicts: 1 covered, 1 allowlisted, 2 missing.
        let by_verdict = |v: Verdict| rows.iter().filter(|r| r.verdict() == v).count();
        assert_eq!(by_verdict(Verdict::Covered), 1);
        assert_eq!(by_verdict(Verdict::Allowlisted), 1);
        assert_eq!(by_verdict(Verdict::Missing), 2);
        // The allowlisted row carries the reason.
        let allow_row = rows.iter().find(|r| r.verdict() == Verdict::Allowlisted).unwrap();
        assert_eq!(allow_row.reason, "excused");
        assert_eq!(allow_row.surface_key, "viz_tab:Test@thin");

        // Each row round-trips through serde (the warehouse row shape).
        let json = serde_json::to_string(&rows).unwrap();
        let back: Vec<CoverageRow> = serde_json::from_str(&json).unwrap();
        assert_eq!(back, rows);

        // The summary rolls the rows up for the viz/CLI.
        let summary = CoverageSummary::from_rows(&rows);
        assert_eq!(summary.total, 4);
        assert_eq!(summary.covered, 1);
        assert_eq!(summary.allowlisted, 1);
        assert_eq!(summary.gap, 2);
        assert!(!summary.green);
        assert_eq!(summary.missing.len(), 2);
        // The viz JSON shape carries the burn-down list.
        let vj = summary.to_json();
        assert_eq!(vj["gap"], 2);
        assert_eq!(vj["green"], false);
        assert!(vj["missing"].as_array().unwrap().contains(&serde_json::json!("mcp_tool:search@na")));
    }
}