1use std::collections::{BTreeMap, BTreeSet};
23
24use serde::{Deserialize, Serialize};
25
26use crate::discover::{Gap, Surface, SurfaceNode};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Verdict {
32 Covered,
34 Allowlisted,
36 Missing,
38}
39
40impl Verdict {
41 pub fn label(self) -> &'static str {
42 match self {
43 Verdict::Covered => "covered",
44 Verdict::Allowlisted => "allowlisted",
45 Verdict::Missing => "missing",
46 }
47 }
48
49 pub fn parse(s: &str) -> Option<Verdict> {
50 match s {
51 "covered" => Some(Verdict::Covered),
52 "allowlisted" => Some(Verdict::Allowlisted),
53 "missing" => Some(Verdict::Missing),
54 _ => None,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CoverageRow {
68 pub run_id: String,
70 pub workspace: String,
73 pub surface_key: String,
75 pub kind: String,
78 pub id: String,
80 pub mode: String,
82 pub verdict: String,
84 #[serde(default)]
86 pub reason: String,
87 #[serde(default)]
89 pub ts_micros: i64,
90}
91
92impl CoverageRow {
93 pub fn from_node(
95 run_id: &str,
96 workspace: &str,
97 node: &SurfaceNode,
98 verdict: Verdict,
99 reason: &str,
100 ts_micros: i64,
101 ) -> Self {
102 CoverageRow {
103 run_id: run_id.to_string(),
104 workspace: workspace.to_string(),
105 surface_key: node.key_str(),
106 kind: node.kind.label().to_string(),
107 id: node.id.clone(),
108 mode: node.mode.label().to_string(),
109 verdict: verdict.label().to_string(),
110 reason: reason.to_string(),
111 ts_micros,
112 }
113 }
114
115 pub fn verdict(&self) -> Verdict {
118 Verdict::parse(&self.verdict).unwrap_or(Verdict::Missing)
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct AllowEntry {
128 pub key: String,
130 #[serde(default)]
133 pub reason: String,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Allowlist {
141 #[serde(default, rename = "allow")]
142 pub entries: Vec<AllowEntry>,
143}
144
145impl Allowlist {
146 pub fn new() -> Self {
147 Self::default()
148 }
149
150 pub fn key_set(&self) -> BTreeSet<String> {
153 self.entries.iter().map(|e| e.key.clone()).collect()
154 }
155
156 pub fn reasons(&self) -> BTreeMap<String, String> {
158 self.entries.iter().map(|e| (e.key.clone(), e.reason.clone())).collect()
159 }
160}
161
162pub fn seed_allowlist(
170 surface: &Surface,
171 covered: &BTreeSet<String>,
172 existing: &Allowlist,
173) -> Allowlist {
174 let prior: BTreeMap<String, String> = existing.reasons();
175 let mut entries: Vec<AllowEntry> = Vec::new();
176 for node in &surface.nodes {
177 let key = node.key_str();
178 if covered.contains(&key) {
179 continue; }
181 let reason = prior
182 .get(&key)
183 .filter(|r| !r.is_empty())
184 .cloned()
185 .unwrap_or_else(|| format!("TODO(autonom): wire an inject-assert test for {key}"));
186 entries.push(AllowEntry { key, reason });
187 }
188 entries.sort_by(|a, b| a.key.cmp(&b.key));
189 Allowlist { entries }
190}
191
192pub fn stale_allowlist_entries(
199 surface: &Surface,
200 covered: &BTreeSet<String>,
201 allowlist: &Allowlist,
202) -> Vec<AllowEntry> {
203 let surface_keys: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
204 let mut stale: Vec<AllowEntry> = allowlist
205 .entries
206 .iter()
207 .filter(|e| covered.contains(&e.key) || !surface_keys.contains(&e.key))
208 .cloned()
209 .collect();
210 stale.sort_by(|a, b| a.key.cmp(&b.key));
211 stale
212}
213
214#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
221pub struct GateReport {
222 pub run_id: String,
223 pub workspace: String,
224 pub gap: Gap,
226 pub stale: Vec<AllowEntry>,
228}
229
230impl GateReport {
231 pub fn compute(
234 run_id: &str,
235 workspace: &str,
236 surface: &Surface,
237 covered: &BTreeSet<String>,
238 allowlist: &Allowlist,
239 ) -> GateReport {
240 let gap = crate::discover::compute_gap(surface, covered, &allowlist.key_set());
241 let stale = stale_allowlist_entries(surface, covered, allowlist);
242 GateReport {
243 run_id: run_id.to_string(),
244 workspace: workspace.to_string(),
245 gap,
246 stale,
247 }
248 }
249
250 pub fn is_green(&self) -> bool {
253 self.gap.is_clean() && self.stale.is_empty()
254 }
255
256 pub fn summary(&self) -> String {
258 format!(
259 "{} · {} stale allowlist entr{} — {}",
260 self.gap.summary(),
261 self.stale.len(),
262 if self.stale.len() == 1 { "y" } else { "ies" },
263 if self.is_green() { "GREEN" } else { "RED" },
264 )
265 }
266
267 pub fn actionable_rows(&self, ts_micros: i64) -> Vec<CoverageRow> {
273 let reasons: BTreeMap<String, String> = BTreeMap::new(); let mut rows = Vec::new();
275 for node in &self.gap.missing {
276 rows.push(CoverageRow::from_node(
277 &self.run_id,
278 &self.workspace,
279 node,
280 Verdict::Missing,
281 "",
282 ts_micros,
283 ));
284 }
285 for node in &self.gap.allowlisted {
286 let reason = reasons.get(&node.key_str()).cloned().unwrap_or_default();
287 rows.push(CoverageRow::from_node(
288 &self.run_id,
289 &self.workspace,
290 node,
291 Verdict::Allowlisted,
292 &reason,
293 ts_micros,
294 ));
295 }
296 rows
297 }
298}
299
300pub fn rows_for(
305 run_id: &str,
306 workspace: &str,
307 surface: &Surface,
308 covered: &BTreeSet<String>,
309 allowlist: &Allowlist,
310 ts_micros: i64,
311) -> Vec<CoverageRow> {
312 let allow_keys = allowlist.key_set();
313 let reasons = allowlist.reasons();
314 let mut rows: Vec<CoverageRow> = surface
315 .nodes
316 .iter()
317 .map(|node| {
318 let key = node.key_str();
319 let (verdict, reason) = if covered.contains(&key) {
320 (Verdict::Covered, String::new())
321 } else if allow_keys.contains(&key) {
322 (Verdict::Allowlisted, reasons.get(&key).cloned().unwrap_or_default())
323 } else {
324 (Verdict::Missing, String::new())
325 };
326 CoverageRow::from_node(run_id, workspace, node, verdict, &reason, ts_micros)
327 })
328 .collect();
329 rows.sort_by(|a, b| a.surface_key.cmp(&b.surface_key));
330 rows
331}
332
333#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337pub struct CoverageSummary {
338 pub run_id: String,
339 pub workspace: String,
340 pub total: usize,
341 pub covered: usize,
342 pub allowlisted: usize,
343 pub gap: usize,
344 pub missing: Vec<String>,
346 pub green: bool,
348}
349
350impl CoverageSummary {
351 pub fn from_rows(rows: &[CoverageRow]) -> CoverageSummary {
353 let run_id = rows.first().map(|r| r.run_id.clone()).unwrap_or_default();
354 let workspace = rows.first().map(|r| r.workspace.clone()).unwrap_or_default();
355 let mut covered = 0;
356 let mut allowlisted = 0;
357 let mut missing: Vec<String> = Vec::new();
358 for r in rows {
359 match r.verdict() {
360 Verdict::Covered => covered += 1,
361 Verdict::Allowlisted => allowlisted += 1,
362 Verdict::Missing => missing.push(r.surface_key.clone()),
363 }
364 }
365 missing.sort();
366 let gap = missing.len();
367 CoverageSummary {
368 run_id,
369 workspace,
370 total: rows.len(),
371 covered,
372 allowlisted,
373 gap,
374 green: gap == 0,
375 missing,
376 }
377 }
378
379 pub fn to_json(&self) -> serde_json::Value {
381 serde_json::json!({
382 "run_id": self.run_id,
383 "workspace": self.workspace,
384 "total": self.total,
385 "covered": self.covered,
386 "allowlisted": self.allowlisted,
387 "gap": self.gap,
388 "green": self.green,
389 "missing": self.missing,
390 })
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::discover::{cli_commands, mcp_tools, viz_tabs};
398
399 fn sample_surface() -> Surface {
400 let mut s = Surface::new();
401 s.extend(viz_tabs(["Test"])) .extend(mcp_tools(["search"])) .extend(cli_commands(["doctor"])); s
405 }
406
407 #[test]
408 fn seed_excuses_only_uncovered_with_reasons() {
409 let surface = sample_surface(); let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
412 let seeded = seed_allowlist(&surface, &covered, &Allowlist::new());
413 assert_eq!(seeded.entries.len(), 3);
415 assert!(seeded.entries.iter().all(|e| e.reason.contains("TODO(autonom)")));
416 assert!(!seeded.entries.iter().any(|e| e.key == "viz_tab:Test@fat"));
417 assert!(seeded.entries.iter().any(|e| e.key == "viz_tab:Test@thin"));
418
419 let report = GateReport::compute("r1", "ws", &surface, &covered, &seeded);
421 assert!(report.is_green(), "seeded allowlist makes the gate green now");
422 assert_eq!(report.gap.covered, 1);
423 assert_eq!(report.gap.allowlisted.len(), 3);
424 assert_eq!(report.gap.missing.len(), 0);
425 }
426
427 #[test]
428 fn reseed_preserves_existing_reasons() {
429 let surface = sample_surface();
430 let covered = BTreeSet::new();
431 let existing = Allowlist {
432 entries: vec![AllowEntry {
433 key: "viz_tab:Test@thin".into(),
434 reason: "hand-written reason #42".into(),
435 }],
436 };
437 let seeded = seed_allowlist(&surface, &covered, &existing);
438 let thin = seeded.entries.iter().find(|e| e.key == "viz_tab:Test@thin").unwrap();
439 assert_eq!(thin.reason, "hand-written reason #42", "existing reason preserved");
440 let other = seeded.entries.iter().find(|e| e.key == "mcp_tool:search@na").unwrap();
442 assert!(other.reason.contains("TODO(autonom)"));
443 }
444
445 #[test]
446 fn unreached_makes_gap_reachable_does_not_allowlisted_excused() {
447 let surface = sample_surface();
448 let covered: BTreeSet<String> = [
450 "viz_tab:Test@fat",
451 "mcp_tool:search@na",
452 "cli_command:doctor@na",
453 ]
454 .iter()
455 .map(|s| s.to_string())
456 .collect();
457 let allowlist = Allowlist {
458 entries: vec![AllowEntry {
459 key: "viz_tab:Test@thin".into(),
460 reason: "RPC wiring tracked in n-006".into(),
461 }],
462 };
463 let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
464 assert!(report.is_green(), "all covered or excused → green");
466 assert_eq!(report.gap.covered, 3);
467 assert_eq!(report.gap.allowlisted.len(), 1);
468 assert!(report.stale.is_empty());
469
470 let covered2: BTreeSet<String> =
472 ["viz_tab:Test@fat", "mcp_tool:search@na"].iter().map(|s| s.to_string()).collect();
473 let report2 = GateReport::compute("r1", "ws", &surface, &covered2, &allowlist);
474 assert!(!report2.is_green(), "an uncovered, un-allowlisted node makes it RED");
475 assert_eq!(report2.gap.missing.len(), 1);
476 assert_eq!(report2.gap.missing[0].key_str(), "cli_command:doctor@na");
477 }
478
479 #[test]
480 fn stale_allowlist_entry_fails_the_gate() {
481 let surface = sample_surface();
482 let covered: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
484 let allowlist = Allowlist {
487 entries: vec![
488 AllowEntry { key: "viz_tab:Test@thin".into(), reason: "old".into() },
489 AllowEntry { key: "viz_tab:Ghost@fat".into(), reason: "deleted tab".into() },
490 ],
491 };
492 let stale = stale_allowlist_entries(&surface, &covered, &allowlist);
493 assert_eq!(stale.len(), 2, "both a now-covered and a surface-gone entry are stale");
494 let report = GateReport::compute("r1", "ws", &surface, &covered, &allowlist);
495 assert!(report.gap.is_clean(), "no missing surface");
497 assert!(!report.is_green(), "stale allowlist entries fail the HARD-zero gate");
498 assert!(report.summary().contains("RED"));
499 }
500
501 #[test]
502 fn rows_and_summary_round_trip_through_serde() {
503 let surface = sample_surface();
504 let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
505 let allowlist = Allowlist {
506 entries: vec![AllowEntry {
507 key: "viz_tab:Test@thin".into(),
508 reason: "excused".into(),
509 }],
510 };
511 let rows = rows_for("r1", "ws", &surface, &covered, &allowlist, 123);
512 assert_eq!(rows.len(), 4, "one row per surface node");
513 let by_verdict = |v: Verdict| rows.iter().filter(|r| r.verdict() == v).count();
515 assert_eq!(by_verdict(Verdict::Covered), 1);
516 assert_eq!(by_verdict(Verdict::Allowlisted), 1);
517 assert_eq!(by_verdict(Verdict::Missing), 2);
518 let allow_row = rows.iter().find(|r| r.verdict() == Verdict::Allowlisted).unwrap();
520 assert_eq!(allow_row.reason, "excused");
521 assert_eq!(allow_row.surface_key, "viz_tab:Test@thin");
522
523 let json = serde_json::to_string(&rows).unwrap();
525 let back: Vec<CoverageRow> = serde_json::from_str(&json).unwrap();
526 assert_eq!(back, rows);
527
528 let summary = CoverageSummary::from_rows(&rows);
530 assert_eq!(summary.total, 4);
531 assert_eq!(summary.covered, 1);
532 assert_eq!(summary.allowlisted, 1);
533 assert_eq!(summary.gap, 2);
534 assert!(!summary.green);
535 assert_eq!(summary.missing.len(), 2);
536 let vj = summary.to_json();
538 assert_eq!(vj["gap"], 2);
539 assert_eq!(vj["green"], false);
540 assert!(vj["missing"].as_array().unwrap().contains(&serde_json::json!("mcp_tool:search@na")));
541 }
542}