use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::discover::{Gap, Surface, SurfaceNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Covered,
Allowlisted,
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,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageRow {
pub run_id: String,
pub workspace: String,
pub surface_key: String,
pub kind: String,
pub id: String,
pub mode: String,
pub verdict: String,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub ts_micros: i64,
}
impl CoverageRow {
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,
}
}
pub fn verdict(&self) -> Verdict {
Verdict::parse(&self.verdict).unwrap_or(Verdict::Missing)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllowEntry {
pub key: String,
#[serde(default)]
pub reason: String,
}
#[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()
}
pub fn key_set(&self) -> BTreeSet<String> {
self.entries.iter().map(|e| e.key.clone()).collect()
}
pub fn reasons(&self) -> BTreeMap<String, String> {
self.entries.iter().map(|e| (e.key.clone(), e.reason.clone())).collect()
}
}
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; }
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 }
}
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
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateReport {
pub run_id: String,
pub workspace: String,
pub gap: Gap,
pub stale: Vec<AllowEntry>,
}
impl GateReport {
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,
}
}
pub fn is_green(&self) -> bool {
self.gap.is_clean() && self.stale.is_empty()
}
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" },
)
}
pub fn actionable_rows(&self, ts_micros: i64) -> Vec<CoverageRow> {
let reasons: BTreeMap<String, String> = BTreeMap::new(); 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
}
}
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
}
#[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,
pub missing: Vec<String>,
pub green: bool,
}
impl CoverageSummary {
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,
}
}
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"])) .extend(mcp_tools(["search"])) .extend(cli_commands(["doctor"])); s
}
#[test]
fn seed_excuses_only_uncovered_with_reasons() {
let surface = sample_surface(); let covered: BTreeSet<String> = ["viz_tab:Test@fat".to_string()].into_iter().collect();
let seeded = seed_allowlist(&surface, &covered, &Allowlist::new());
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"));
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");
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();
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);
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());
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();
let covered: BTreeSet<String> = surface.nodes.iter().map(|n| n.key_str()).collect();
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);
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");
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);
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");
let json = serde_json::to_string(&rows).unwrap();
let back: Vec<CoverageRow> = serde_json::from_str(&json).unwrap();
assert_eq!(back, rows);
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);
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")));
}
}