use std::collections::BTreeMap;
use diffguard_types::{CheckReceipt, Finding, Severity};
pub fn render_junit_for_receipt(receipt: &CheckReceipt) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let mut suites: BTreeMap<String, Vec<&Finding>> = BTreeMap::new();
for f in &receipt.findings {
suites.entry(f.rule_id.clone()).or_default().push(f);
}
let total_tests = receipt.findings.len();
let total_failures = receipt
.findings
.iter()
.filter(|f| matches!(f.severity, Severity::Error | Severity::Warn))
.count();
out.push_str(&format!(
"<testsuites name=\"diffguard\" tests=\"{}\" failures=\"{}\" errors=\"0\">\n",
total_tests, total_failures
));
for (rule_id, findings) in &suites {
let suite_failures = findings
.iter()
.filter(|f| matches!(f.severity, Severity::Error | Severity::Warn))
.count();
out.push_str(&format!(
" <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"0\">\n",
escape_xml(rule_id),
findings.len(),
suite_failures
));
for f in findings {
let classname = escape_xml(&f.path);
let name = format!("{}:{}", f.path, f.line);
out.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\">\n",
classname,
escape_xml(&name)
));
if matches!(f.severity, Severity::Error | Severity::Warn) {
let failure_type = if matches!(f.severity, Severity::Error) {
"error"
} else {
"warning"
};
out.push_str(&format!(
" <failure type=\"{}\" message=\"{}\">\n",
failure_type,
escape_xml(&f.message)
));
out.push_str(&format!(
"Rule: {}\nFile: {}\nLine: {}\nSnippet: {}\n",
f.rule_id, f.path, f.line, f.snippet
));
out.push_str(" </failure>\n");
}
out.push_str(" </testcase>\n");
}
out.push_str(" </testsuite>\n");
}
if receipt.findings.is_empty() {
out.push_str(" <testsuite name=\"diffguard\" tests=\"1\" failures=\"0\" errors=\"0\">\n");
out.push_str(" <testcase classname=\"diffguard\" name=\"no_findings\">\n");
out.push_str(" </testcase>\n");
out.push_str(" </testsuite>\n");
}
out.push_str("</testsuites>\n");
out
}
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use diffguard_types::{
CHECK_SCHEMA_V1, CheckReceipt, DiffMeta, Finding, Scope, ToolMeta, Verdict, VerdictCounts,
VerdictStatus,
};
fn create_test_receipt_with_findings() -> CheckReceipt {
CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 3,
lines_scanned: 42,
},
findings: vec![
Finding {
rule_id: "rust.no_unwrap".to_string(),
severity: Severity::Error,
message: "Avoid unwrap/expect in production code.".to_string(),
path: "src/lib.rs".to_string(),
line: 15,
column: Some(10),
match_text: ".unwrap()".to_string(),
snippet: "let value = result.unwrap();".to_string(),
},
Finding {
rule_id: "rust.no_dbg".to_string(),
severity: Severity::Warn,
message: "Remove dbg!/println! before merging.".to_string(),
path: "src/main.rs".to_string(),
line: 23,
column: Some(5),
match_text: "dbg!".to_string(),
snippet: " dbg!(config);".to_string(),
},
Finding {
rule_id: "rust.no_unwrap".to_string(),
severity: Severity::Error,
message: "Avoid unwrap/expect in production code.".to_string(),
path: "src/other.rs".to_string(),
line: 8,
column: None,
match_text: ".unwrap()".to_string(),
snippet: "x.unwrap()".to_string(),
},
],
verdict: Verdict {
status: VerdictStatus::Fail,
counts: VerdictCounts {
info: 0,
warn: 1,
error: 2,
..Default::default()
},
reasons: vec![
"2 error-level findings".to_string(),
"1 warning-level finding".to_string(),
],
},
timing: None,
}
}
fn create_test_receipt_empty() -> CheckReceipt {
CheckReceipt {
schema: CHECK_SCHEMA_V1.to_string(),
tool: ToolMeta {
name: "diffguard".to_string(),
version: "0.1.0".to_string(),
},
diff: DiffMeta {
base: "origin/main".to_string(),
head: "HEAD".to_string(),
context_lines: 0,
scope: Scope::Added,
files_scanned: 5,
lines_scanned: 120,
},
findings: vec![],
verdict: Verdict {
status: VerdictStatus::Pass,
counts: VerdictCounts::default(),
reasons: vec![],
},
timing: None,
}
}
#[test]
fn junit_xml_declaration() {
let receipt = create_test_receipt_empty();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
}
#[test]
fn junit_has_testsuites_root() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("<testsuites name=\"diffguard\""));
assert!(xml.contains("</testsuites>"));
}
#[test]
fn junit_groups_by_rule_id() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("<testsuite name=\"rust.no_dbg\""));
assert!(xml.contains("<testsuite name=\"rust.no_unwrap\""));
}
#[test]
fn junit_has_correct_test_count() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("tests=\"3\""));
}
#[test]
fn junit_has_correct_failure_count() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("failures=\"3\""));
}
#[test]
fn junit_empty_receipt_has_pass_testcase() {
let receipt = create_test_receipt_empty();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("tests=\"1\""));
assert!(xml.contains("failures=\"0\""));
assert!(xml.contains("name=\"no_findings\""));
}
#[test]
fn junit_escapes_xml_special_chars() {
let mut receipt = create_test_receipt_with_findings();
receipt.findings[0].message = "Test <special> & \"chars\"".to_string();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("<special>"));
assert!(xml.contains("&"));
assert!(xml.contains("""));
}
#[test]
fn junit_failure_includes_details() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
assert!(xml.contains("<failure type=\"error\""));
assert!(xml.contains("<failure type=\"warning\""));
assert!(xml.contains("Rule: rust.no_unwrap"));
assert!(xml.contains("File: src/lib.rs"));
assert!(xml.contains("Line: 15"));
}
#[test]
fn junit_info_severity_does_not_emit_failure() {
let mut receipt = create_test_receipt_with_findings();
receipt.findings = vec![Finding {
rule_id: "docs.info".to_string(),
severity: Severity::Info,
message: "Just information".to_string(),
path: "README.md".to_string(),
line: 1,
column: None,
match_text: "info".to_string(),
snippet: "info".to_string(),
}];
receipt.verdict.counts = VerdictCounts {
info: 1,
warn: 0,
error: 0,
suppressed: 0,
};
receipt.verdict.status = VerdictStatus::Pass;
let xml = render_junit_for_receipt(&receipt);
assert!(!xml.contains("<failure type=\"error\""));
assert!(!xml.contains("<failure type=\"warning\""));
}
#[test]
fn snapshot_junit_with_findings() {
let receipt = create_test_receipt_with_findings();
let xml = render_junit_for_receipt(&receipt);
insta::assert_snapshot!(xml);
}
#[test]
fn snapshot_junit_no_findings() {
let receipt = create_test_receipt_empty();
let xml = render_junit_for_receipt(&receipt);
insta::assert_snapshot!(xml);
}
#[test]
fn escape_xml_handles_all_special_chars() {
assert_eq!(escape_xml("&"), "&");
assert_eq!(escape_xml("<"), "<");
assert_eq!(escape_xml(">"), ">");
assert_eq!(escape_xml("\""), """);
assert_eq!(escape_xml("'"), "'");
assert_eq!(escape_xml("normal text"), "normal text");
assert_eq!(escape_xml("<a & b>"), "<a & b>");
}
}