use std::collections::BTreeMap;
use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use crate::audit::{AuditReport, Trace};
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
}
pub fn render_text(report: &LivenessReport, audit: Option<&AuditReport>) -> 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(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 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")]
audit: Option<JsonAudit>,
}
pub fn render_json(report: &LivenessReport, audit: Option<&AuditReport>) -> 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(),
audit: audit.map(JsonAudit::from),
};
Ok(serde_json::to_string_pretty(&json)?)
}
pub fn render(
report: &LivenessReport,
audit: Option<&AuditReport>,
format: OutputFormat,
) -> Result<String> {
match format {
OutputFormat::Text => Ok(render_text(report, audit)),
OutputFormat::Json => render_json(report, audit),
}
}
pub fn exit_code(report: &LivenessReport, fail_on: FailOn) -> i32 {
match fail_on {
FailOn::Never => 0,
FailOn::Dead => i32::from(report.dead_count() > 0),
FailOn::DeadOrBlind => i32::from(report.dead_count() > 0 || report.total_blind() > 0),
}
}
#[cfg(test)]
mod tests {
use super::*;
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);
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).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);
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).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), 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));
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)).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).contains("Audit (dead-bucket trust)"));
let no_audit: serde_json::Value =
serde_json::from_str(&render_json(&r, None).unwrap()).unwrap();
assert!(no_audit.get("audit").is_none());
assert_eq!(exit_code(&r, FailOn::Dead), 1);
}
#[test]
fn exit_codes_follow_fail_on() {
let r = report(); assert_eq!(exit_code(&r, FailOn::Dead), 1);
assert_eq!(exit_code(&r, FailOn::Never), 0);
assert_eq!(exit_code(&r, FailOn::DeadOrBlind), 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), 0);
assert_eq!(exit_code(&clean, FailOn::DeadOrBlind), 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), 0);
assert_eq!(exit_code(&blindish, FailOn::DeadOrBlind), 1);
}
}