use std::collections::BTreeMap;
use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use crate::audit::{AuditReport, Trace};
use crate::budget::DeadBudget;
use crate::config::{FailOn, OutputFormat};
use crate::liveness::{AliveVia, LivenessReport, Status};
use crate::po::PoKey;
pub const SCOPE_CAVEAT: &str = "scope = scanned source roots only; external consumers (DB/config, other services, email/cron templates) are invisible — verify in your TMS before deleting.";
fn alive_via_str(via: AliveVia) -> &'static str {
match via {
AliveVia::Literal => "literal",
AliveVia::Guard => "guard",
AliveVia::Whitelist => "whitelist",
}
}
fn sorted_dead(report: &LivenessReport) -> Vec<&PoKey> {
let mut dead: Vec<&PoKey> = report.dead().map(|v| &v.key).collect();
dead.sort_by(|a, b| (&a.msgctxt, &a.msgid).cmp(&(&b.msgctxt, &b.msgid)));
dead
}
fn sorted_suspect(report: &LivenessReport) -> Vec<&PoKey> {
let mut suspect: Vec<&PoKey> = report.suspect().map(|v| &v.key).collect();
suspect.sort_by(|a, b| (&a.msgctxt, &a.msgid).cmp(&(&b.msgctxt, &b.msgid)));
suspect
}
fn display_key(key: &PoKey) -> String {
let mut s = String::new();
if let Some(ctxt) = &key.msgctxt {
s.push_str(&format!("[{ctxt}] "));
}
s.push_str(&key.msgid);
if let Some(plural) = &key.msgid_plural {
s.push_str(&format!(" / {plural}"));
}
s
}
fn blind_line(blind: &BTreeMap<String, usize>) -> String {
if blind.is_empty() {
return "Blind spots: none".to_string();
}
let parts: Vec<String> = blind.iter().map(|(k, v)| format!("{k}={v}")).collect();
format!(
"Blind spots (unverifiable call sites): {}",
parts.join(", ")
)
}
fn trace_str(trace: Trace) -> &'static str {
match trace {
Trace::Substring => "substring",
Trace::Skeleton => "skeleton",
Trace::None => "none",
}
}
fn audit_block(audit: &AuditReport) -> String {
let mut s = String::new();
s.push_str(&format!(
"Audit (dead-bucket trust): {} dead — {} no trace (high-confidence), {} substring, {} skeleton — recheck before deleting.\n",
audit.dead_total, audit.no_trace, audit.substring, audit.skeleton,
));
if !audit.traced.is_empty() {
s.push_str(
&format!(
" {} dead keys still trace to source — see --format json for the list.",
audit.traced.len()
)
.dimmed()
.to_string(),
);
s.push('\n');
}
s
}
fn budget_line(dead: usize, total: usize, budget: DeadBudget) -> Option<String> {
if !budget.is_set() {
return None;
}
let over = budget.is_exceeded(dead, total);
let body = match budget {
DeadBudget::Count(max) => {
if over {
format!(
"Budget: {dead} / {max} dead allowed — OVER by {}.",
dead - max
)
} else {
format!(
"Budget: {dead} / {max} dead allowed — within budget ({} headroom).",
max - dead
)
}
}
DeadBudget::Ratio(r) => {
let pct = if total > 0 {
dead as f64 / total as f64 * 100.0
} else {
0.0
};
let cap = r * 100.0;
if over {
format!("Budget: {pct:.1}% / {cap:.1}% dead ratio — OVER.")
} else {
format!("Budget: {pct:.1}% / {cap:.1}% dead ratio — within budget.")
}
}
};
Some(if over {
body.red().to_string()
} else {
body.green().to_string()
})
}
pub fn render_text(
report: &LivenessReport,
audit: Option<&AuditReport>,
budget: DeadBudget,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"{} {}\n\n",
"dead-poets".bold(),
SCOPE_CAVEAT.dimmed()
));
let dead = sorted_dead(report);
if dead.is_empty() {
out.push_str(&"No dead keys found.\n".green().to_string());
} else {
out.push_str(&format!(
"{}\n",
format!("Dead keys ({}):", dead.len()).bold()
));
for key in &dead {
out.push_str(&format!(" {}\n", display_key(key).red()));
}
}
let suspect = sorted_suspect(report);
if !suspect.is_empty() {
out.push('\n');
out.push_str(&format!(
"{} {}\n",
format!("Suspect ({}):", suspect.len()).bold(),
"literal present in source but no modeled call — verify, don't delete".dimmed(),
));
for key in &suspect {
out.push_str(&format!(" {}\n", display_key(key).yellow()));
}
}
out.push('\n');
out.push_str(&blind_line(&report.blind));
out.push('\n');
out.push_str(&format!(
"Summary: {} keys, {} alive, {} suspect, {} dead, {} blind\n",
report.verdicts.len(),
report.alive_count(),
report.suspect_count(),
report.dead_count(),
report.total_blind(),
));
if let Some(line) = budget_line(report.dead_count(), report.verdicts.len(), budget) {
out.push_str(&line);
out.push('\n');
}
if let Some(audit) = audit {
out.push('\n');
out.push_str(&audit_block(audit));
}
out
}
#[derive(Serialize)]
struct JsonKey {
#[serde(skip_serializing_if = "Option::is_none")]
msgctxt: Option<String>,
msgid: String,
#[serde(skip_serializing_if = "Option::is_none")]
msgid_plural: Option<String>,
}
impl From<&PoKey> for JsonKey {
fn from(k: &PoKey) -> Self {
JsonKey {
msgctxt: k.msgctxt.clone(),
msgid: k.msgid.clone(),
msgid_plural: k.msgid_plural.clone(),
}
}
}
#[derive(Serialize)]
struct JsonAliveKey {
#[serde(flatten)]
key: JsonKey,
alive_via: &'static str,
}
#[derive(Serialize)]
struct JsonSummary {
total: usize,
alive: usize,
suspect: usize,
dead: usize,
blind: usize,
}
#[derive(Serialize)]
struct JsonTracedKey {
#[serde(flatten)]
key: JsonKey,
trace: &'static str,
}
#[derive(Serialize)]
struct JsonAudit {
dead_total: usize,
no_trace: usize,
substring: usize,
skeleton: usize,
traced: Vec<JsonTracedKey>,
}
impl From<&AuditReport> for JsonAudit {
fn from(a: &AuditReport) -> Self {
JsonAudit {
dead_total: a.dead_total,
no_trace: a.no_trace,
substring: a.substring,
skeleton: a.skeleton,
traced: a
.traced
.iter()
.map(|(k, t)| JsonTracedKey {
key: JsonKey::from(k),
trace: trace_str(*t),
})
.collect(),
}
}
}
#[derive(Serialize)]
struct JsonBudget {
kind: &'static str,
limit: f64,
dead: usize,
over: bool,
}
fn json_budget(dead: usize, total: usize, budget: DeadBudget) -> Option<JsonBudget> {
if !budget.is_set() {
return None;
}
let over = budget.is_exceeded(dead, total);
Some(match budget {
DeadBudget::Count(max) => JsonBudget {
kind: "count",
limit: max as f64,
dead,
over,
},
DeadBudget::Ratio(r) => JsonBudget {
kind: "ratio",
limit: r,
dead,
over,
},
})
}
#[derive(Serialize)]
struct JsonReport {
scope_caveat: &'static str,
summary: JsonSummary,
dead: Vec<JsonKey>,
suspect: Vec<JsonKey>,
alive: Vec<JsonAliveKey>,
blind: BTreeMap<String, usize>,
#[serde(skip_serializing_if = "Option::is_none")]
budget: Option<JsonBudget>,
#[serde(skip_serializing_if = "Option::is_none")]
audit: Option<JsonAudit>,
}
pub fn render_json(
report: &LivenessReport,
audit: Option<&AuditReport>,
budget: DeadBudget,
) -> Result<String> {
let dead: Vec<JsonKey> = sorted_dead(report).into_iter().map(JsonKey::from).collect();
let suspect: Vec<JsonKey> = sorted_suspect(report)
.into_iter()
.map(JsonKey::from)
.collect();
let alive: Vec<JsonAliveKey> = report
.verdicts
.iter()
.filter_map(|v| match v.status {
Status::Alive(via) => Some(JsonAliveKey {
key: JsonKey::from(&v.key),
alive_via: alive_via_str(via),
}),
Status::Suspect | Status::Dead => None,
})
.collect();
let json = JsonReport {
scope_caveat: SCOPE_CAVEAT,
summary: JsonSummary {
total: report.verdicts.len(),
alive: report.alive_count(),
suspect: report.suspect_count(),
dead: report.dead_count(),
blind: report.total_blind(),
},
dead,
suspect,
alive,
blind: report.blind.clone(),
budget: json_budget(report.dead_count(), report.verdicts.len(), budget),
audit: audit.map(JsonAudit::from),
};
Ok(serde_json::to_string_pretty(&json)?)
}
pub fn render(
report: &LivenessReport,
audit: Option<&AuditReport>,
budget: DeadBudget,
format: OutputFormat,
) -> Result<String> {
match format {
OutputFormat::Text => Ok(render_text(report, audit, budget)),
OutputFormat::Json => render_json(report, audit, budget),
}
}
pub fn exit_code(report: &LivenessReport, fail_on: FailOn, budget: DeadBudget) -> i32 {
let dead_over = budget.is_exceeded(report.dead_count(), report.verdicts.len());
match fail_on {
FailOn::Never => 0,
FailOn::Dead => i32::from(dead_over),
FailOn::DeadOrBlind => i32::from(dead_over || report.total_blind() > 0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::budget::DeadBudget;
use crate::liveness::{KeyVerdict, LivenessReport, Status};
fn key(msgid: &str) -> PoKey {
PoKey {
msgctxt: None,
msgid: msgid.to_string(),
msgid_plural: None,
}
}
fn report() -> LivenessReport {
let mut blind = BTreeMap::new();
blind.insert("js".to_string(), 2);
blind.insert("twig".to_string(), 1);
LivenessReport {
verdicts: vec![
KeyVerdict {
key: key("alive_lit"),
status: Status::Alive(AliveVia::Literal),
},
KeyVerdict {
key: key("alive_grd"),
status: Status::Alive(AliveVia::Guard),
},
KeyVerdict {
key: key("dead_two"),
status: Status::Dead,
},
KeyVerdict {
key: key("dead_one"),
status: Status::Dead,
},
],
blind,
}
}
#[test]
fn text_prints_only_dead_and_scope_header() {
let text = render_text(&report(), None, DeadBudget::default());
assert!(text.contains("external consumers"));
let one = text.find("dead_one").unwrap();
let two = text.find("dead_two").unwrap();
assert!(one < two, "dead keys are sorted");
assert!(!text.contains("alive_lit"));
assert!(!text.contains("alive_grd"));
assert!(text.contains("js=2"));
assert!(text.contains("twig=1"));
}
#[test]
fn json_is_valid_and_dead_count_matches_text() {
let r = report();
let json = render_json(&r, None, DeadBudget::default()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let dead = parsed["dead"].as_array().unwrap();
assert_eq!(dead.len(), r.dead_count());
assert_eq!(dead.len(), sorted_dead(&r).len());
let alive = parsed["alive"].as_array().unwrap();
assert_eq!(alive.len(), 2);
let vias: Vec<&str> = alive
.iter()
.map(|a| a["alive_via"].as_str().unwrap())
.collect();
assert!(vias.contains(&"literal"));
assert!(vias.contains(&"guard"));
assert_eq!(parsed["blind"]["js"], 2);
assert_eq!(parsed["summary"]["dead"], 2);
}
#[test]
fn suspect_rendered_and_exit_neutral() {
let report = LivenessReport {
verdicts: vec![
KeyVerdict {
key: key("suspect_key"),
status: Status::Suspect,
},
KeyVerdict {
key: key("dead_key"),
status: Status::Dead,
},
],
blind: BTreeMap::new(),
};
let text = render_text(&report, None, DeadBudget::default());
assert!(text.contains("Suspect (1)"));
assert!(text.contains("suspect_key"));
assert!(text.contains("1 suspect"));
let parsed: serde_json::Value =
serde_json::from_str(&render_json(&report, None, DeadBudget::default()).unwrap())
.unwrap();
assert_eq!(parsed["summary"]["suspect"], 1);
assert_eq!(parsed["suspect"][0]["msgid"], "suspect_key");
assert!(parsed["alive"].as_array().unwrap().is_empty());
let suspect_only = LivenessReport {
verdicts: vec![KeyVerdict {
key: key("s"),
status: Status::Suspect,
}],
blind: BTreeMap::new(),
};
assert_eq!(
exit_code(&suspect_only, FailOn::Dead, DeadBudget::default()),
0
);
}
#[test]
fn audit_rendered_and_exit_neutral() {
let r = report(); let audit = AuditReport {
dead_total: 2,
no_trace: 1,
substring: 1,
skeleton: 0,
traced: vec![(key("dead_one"), Trace::Substring)],
};
let text = render_text(&r, Some(&audit), DeadBudget::default());
assert!(text.contains("Audit (dead-bucket trust): 2 dead"));
assert!(text.contains("1 no trace"));
assert!(text.contains("1 substring"));
let parsed: serde_json::Value =
serde_json::from_str(&render_json(&r, Some(&audit), DeadBudget::default()).unwrap())
.unwrap();
assert_eq!(parsed["audit"]["dead_total"], 2);
assert_eq!(parsed["audit"]["no_trace"], 1);
assert_eq!(parsed["audit"]["substring"], 1);
assert_eq!(parsed["audit"]["traced"][0]["msgid"], "dead_one");
assert_eq!(parsed["audit"]["traced"][0]["trace"], "substring");
assert!(
!render_text(&r, None, DeadBudget::default()).contains("Audit (dead-bucket trust)")
);
let no_audit: serde_json::Value =
serde_json::from_str(&render_json(&r, None, DeadBudget::default()).unwrap()).unwrap();
assert!(no_audit.get("audit").is_none());
assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::default()), 1);
}
#[test]
fn exit_codes_follow_fail_on() {
let d = DeadBudget::default();
let r = report(); assert_eq!(exit_code(&r, FailOn::Dead, d), 1);
assert_eq!(exit_code(&r, FailOn::Never, d), 0);
assert_eq!(exit_code(&r, FailOn::DeadOrBlind, d), 1);
let clean = LivenessReport {
verdicts: vec![KeyVerdict {
key: key("ok"),
status: Status::Alive(AliveVia::Literal),
}],
blind: BTreeMap::new(),
};
assert_eq!(exit_code(&clean, FailOn::Dead, d), 0);
assert_eq!(exit_code(&clean, FailOn::DeadOrBlind, d), 0);
let mut blind = BTreeMap::new();
blind.insert("js".to_string(), 1);
let blindish = LivenessReport {
verdicts: vec![],
blind,
};
assert_eq!(exit_code(&blindish, FailOn::Dead, d), 0);
assert_eq!(exit_code(&blindish, FailOn::DeadOrBlind, d), 1);
}
#[test]
fn budget_count_relaxes_dead_gate() {
let r = report(); assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Count(2)), 0);
assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Count(3)), 0);
assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Count(1)), 1);
assert_eq!(exit_code(&r, FailOn::DeadOrBlind, DeadBudget::Count(2)), 1);
assert_eq!(exit_code(&r, FailOn::Never, DeadBudget::Count(0)), 0);
}
#[test]
fn budget_ratio_uses_universe_share() {
let r = report(); assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Ratio(0.5)), 0); assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Ratio(0.6)), 0);
assert_eq!(exit_code(&r, FailOn::Dead, DeadBudget::Ratio(0.4)), 1);
let empty = LivenessReport {
verdicts: vec![],
blind: BTreeMap::new(),
};
assert_eq!(exit_code(&empty, FailOn::Dead, DeadBudget::Ratio(0.0)), 0);
}
#[test]
fn budget_reported_only_when_set() {
let r = report();
let text = render_text(&r, None, DeadBudget::Count(5));
assert!(text.contains("Budget: 2 / 5 dead allowed"));
assert!(text.contains("within budget (3 headroom)"));
let over = render_text(&r, None, DeadBudget::Count(1));
assert!(over.contains("OVER by 1"));
assert!(!render_text(&r, None, DeadBudget::default()).contains("Budget:"));
let parsed: serde_json::Value =
serde_json::from_str(&render_json(&r, None, DeadBudget::Count(5)).unwrap()).unwrap();
assert_eq!(parsed["budget"]["kind"], "count");
assert_eq!(parsed["budget"]["limit"], 5.0);
assert_eq!(parsed["budget"]["dead"], 2);
assert_eq!(parsed["budget"]["over"], false);
let ratio: serde_json::Value =
serde_json::from_str(&render_json(&r, None, DeadBudget::Ratio(0.4)).unwrap()).unwrap();
assert_eq!(ratio["budget"]["kind"], "ratio");
assert_eq!(ratio["budget"]["over"], true);
let default: serde_json::Value =
serde_json::from_str(&render_json(&r, None, DeadBudget::default()).unwrap()).unwrap();
assert!(default.get("budget").is_none());
}
}