use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use super::backend::{ProofBackend, RenderContext};
use super::report::{ProofCapture, ProofError, ProofReport};
pub struct JunitBackend;
impl ProofBackend for JunitBackend {
fn render(&self, context: &RenderContext<'_>) -> Result<(), ProofError> {
let xml = build_junit(context.report);
std::fs::write(context.output, xml)?;
Ok(())
}
}
fn build_junit(report: &ProofReport) -> String {
let test_cases = collect_test_cases(report);
let total = test_cases.len();
let failures = test_cases
.iter()
.filter(|entry| matches!(entry.outcome, TestCaseOutcome::Failed(_)))
.count();
let skipped = test_cases
.iter()
.filter(|entry| matches!(entry.outcome, TestCaseOutcome::Skipped))
.count();
let suite_name = escape_xml_attr(&report.scenario_name);
let mut xml = String::with_capacity(256);
let _ = writeln!(xml, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
let _ = writeln!(
xml,
"<testsuites name=\"{suite_name}\" tests=\"{total}\" failures=\"{failures}\" \
skipped=\"{skipped}\">"
);
let _ = writeln!(
xml,
" <testsuite name=\"{suite_name}\" tests=\"{total}\" failures=\"{failures}\" \
skipped=\"{skipped}\">"
);
for entry in &test_cases {
write_test_case(&mut xml, &suite_name, entry);
}
let _ = writeln!(xml, " </testsuite>");
let _ = writeln!(xml, "</testsuites>");
xml
}
fn write_test_case(xml: &mut String, classname: &str, entry: &TestCaseEntry) {
let name = escape_xml_attr(&entry.name);
match &entry.outcome {
TestCaseOutcome::Passed => {
let _ = writeln!(
xml,
" <testcase name=\"{name}\" classname=\"{classname}\"/>"
);
}
TestCaseOutcome::Failed(failure) => {
let _ = writeln!(
xml,
" <testcase name=\"{name}\" classname=\"{classname}\">"
);
let _ = writeln!(
xml,
" <failure message=\"{}\">{}</failure>",
escape_xml_attr(&failure.summary),
escape_xml_text(&failure.detail)
);
let _ = writeln!(xml, " </testcase>");
}
TestCaseOutcome::Skipped => {
let _ = writeln!(
xml,
" <testcase name=\"{name}\" classname=\"{classname}\">"
);
let _ = writeln!(xml, " <skipped/>");
let _ = writeln!(xml, " </testcase>");
}
}
}
struct TestCaseEntry {
name: String,
outcome: TestCaseOutcome,
}
enum TestCaseOutcome {
Passed,
Failed(FailureEntry),
Skipped,
}
struct FailureEntry {
summary: String,
detail: String,
}
fn collect_test_cases(report: &ProofReport) -> Vec<TestCaseEntry> {
let mut test_cases: Vec<TestCaseEntry> = report
.captures
.iter()
.flat_map(capture_test_cases)
.collect();
disambiguate_names(&mut test_cases);
test_cases
}
fn capture_test_cases(capture: &ProofCapture) -> Vec<TestCaseEntry> {
if capture.assertions.is_empty() {
return vec![TestCaseEntry {
name: capture.label.clone(),
outcome: TestCaseOutcome::Skipped,
}];
}
capture
.assertions
.iter()
.map(|assertion| {
let outcome = if assertion.passed {
TestCaseOutcome::Passed
} else {
let detail = assertion.failure.as_deref().map_or_else(
|| assertion.description.clone(),
|failure| failure.message.clone(),
);
let summary = detail.lines().next().unwrap_or(&detail).to_string();
TestCaseOutcome::Failed(FailureEntry { summary, detail })
};
TestCaseEntry {
name: format!("{} / {}", capture.label, assertion.description),
outcome,
}
})
.collect()
}
fn disambiguate_names(test_cases: &mut [TestCaseEntry]) {
let mut counts: HashMap<&str, usize> = HashMap::new();
for entry in test_cases.iter() {
*counts.entry(entry.name.as_str()).or_insert(0) += 1;
}
let duplicates: HashSet<String> = counts
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(name, _)| name.to_string())
.collect();
let mut seen: HashMap<String, usize> = HashMap::new();
for entry in test_cases.iter_mut() {
if duplicates.contains(&entry.name) {
let index = seen.entry(entry.name.clone()).or_insert(0);
*index += 1;
entry.name = format!("{} #{index}", entry.name);
}
}
}
fn escape_xml_text(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn escape_xml_attr(text: &str) -> String {
escape_xml_text(text)
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assertion::{AssertionFailure, Expected};
use crate::frame::TerminalFrame;
fn report_with_structured_failure(failure: &AssertionFailure) -> ProofReport {
let frame = TerminalFrame::new(20, 3, b"Hello World");
let mut report = ProofReport::new("structured_failure");
report.add_capture("only", "Only capture", &frame);
report.record_soft_failure(failure);
report
}
#[test]
fn junit_starts_with_xml_declaration() {
let report = ProofReport::new("decl_scenario");
let xml = build_junit(&report);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
}
#[test]
fn junit_wraps_scenario_in_testsuite() {
let report = ProofReport::new("my_scenario");
let xml = build_junit(&report);
assert!(xml.contains("<testsuites name=\"my_scenario\""));
assert!(xml.contains("<testsuite name=\"my_scenario\""));
assert!(xml.contains("</testsuite>"));
assert!(xml.contains("</testsuites>"));
}
#[test]
fn junit_passing_assertion_has_testcase_without_failure() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("pass_scenario");
report.add_capture("check", "Verify state", &frame);
report.add_assertion("check", true, "text visible");
let xml = build_junit(&report);
assert!(xml.contains("<testcase name=\"check / text visible\""));
assert!(xml.contains("classname=\"pass_scenario\""));
assert!(!xml.contains("<failure"));
}
#[test]
fn junit_failing_assertion_emits_failure_element() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("fail_scenario");
report.add_capture("check", "Verify state", &frame);
report.add_assertion("check", false, "color match");
let xml = build_junit(&report);
assert!(xml.contains("<testcase name=\"check / color match\""));
assert!(xml.contains("<failure message=\"color match\""));
}
#[test]
fn junit_uses_structured_failure_message_in_body() {
let failure = AssertionFailure {
message: "first line summary\n detail line\n another detail".to_string(),
expected: Expected::TextInRegion {
needle: "missing".to_string(),
},
region: None,
matched_spans: Vec::new(),
frame_excerpt: String::new(),
};
let report = report_with_structured_failure(&failure);
let xml = build_junit(&report);
assert!(xml.contains("<failure message=\"first line summary\""));
assert!(xml.contains("detail line"));
assert!(xml.contains("another detail"));
}
#[test]
fn junit_counts_total_tests_and_failures() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("count_scenario");
report.add_capture("check", "Verify state", &frame);
report.add_assertion("check", true, "passes");
report.add_assertion("check", false, "fails");
let xml = build_junit(&report);
assert!(xml.contains("<testsuites name=\"count_scenario\" tests=\"2\" failures=\"1\""));
assert!(xml.contains("<testsuite name=\"count_scenario\" tests=\"2\" failures=\"1\""));
}
#[test]
fn junit_capture_without_assertions_is_skipped() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("step_scenario");
report.add_capture("launched", "App launched", &frame);
let xml = build_junit(&report);
assert!(xml.contains("<testcase name=\"launched\""));
assert!(xml.contains("<skipped/>"));
assert!(xml.contains("tests=\"1\" failures=\"0\" skipped=\"1\""));
assert!(!xml.contains("<failure"));
}
#[test]
fn junit_disambiguates_duplicate_testcase_names() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("dup_scenario");
report.add_capture("check", "Verify", &frame);
report.add_assertion("check", true, "same name");
report.add_assertion("check", true, "same name");
let xml = build_junit(&report);
assert!(xml.contains("<testcase name=\"check / same name #1\""));
assert!(xml.contains("<testcase name=\"check / same name #2\""));
}
#[test]
fn junit_keeps_unique_testcase_names_unsuffixed() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("unique_scenario");
report.add_capture("check", "Verify", &frame);
report.add_assertion("check", true, "first");
report.add_assertion("check", true, "second");
let xml = build_junit(&report);
assert!(xml.contains("<testcase name=\"check / first\""));
assert!(xml.contains("<testcase name=\"check / second\""));
assert!(!xml.contains(" #1\""));
}
#[test]
fn junit_escapes_xml_special_characters() {
let frame = TerminalFrame::new(20, 3, b"Test");
let mut report = ProofReport::new("a & b <tag>");
report.add_capture("check", "Verify", &frame);
report.add_assertion("check", false, "expected <x> & \"y\"");
let xml = build_junit(&report);
assert!(xml.contains("name=\"a & b <tag>\""));
assert!(xml.contains(""y""));
assert!(!xml.contains("<tag>"));
assert!(!xml.contains("expected <x>"));
}
#[test]
fn junit_backend_writes_file() {
let frame = TerminalFrame::new(20, 3, b"File");
let mut report = ProofReport::new("file_scenario");
report.add_capture("snap", "Snapshot", &frame);
report.add_assertion("snap", true, "content visible");
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let output_path = temp_dir.path().join("report.xml");
let backend = JunitBackend;
backend
.render(&RenderContext::new(&report, &output_path))
.expect("render should succeed");
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path).expect("failed to read");
assert!(content.contains("<testsuite name=\"file_scenario\""));
}
#[test]
fn escape_xml_text_escapes_core_metacharacters() {
assert_eq!(escape_xml_text("a & b"), "a & b");
assert_eq!(escape_xml_text("<tag>"), "<tag>");
}
#[test]
fn escape_xml_attr_escapes_quotes_and_metacharacters() {
assert_eq!(escape_xml_attr("\"q\""), ""q"");
assert_eq!(escape_xml_attr("a'b"), "a'b");
assert_eq!(escape_xml_attr("x & <y>"), "x & <y>");
}
}