use std::io::Write;
use std::path::{Path, PathBuf};
use super::runner::EndpointResult;
pub(crate) struct DocReport {
pub path: PathBuf,
pub rows: Vec<EndpointResult>,
pub doc_error: Option<String>,
}
pub(crate) struct ExpansionReport {
pub name: String,
pub error: String,
}
fn escape_xml(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\t' | '\n' | '\r' => out.push(c),
'\u{FFFE}' | '\u{FFFF}' => {}
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
fn first_line(text: &str) -> &str {
text.split('\n').next().unwrap_or(text)
}
fn write_line(file: &mut std::fs::File, path: &Path, text: &str) -> Result<(), String> {
writeln!(file, "{text}").map_err(|e| format!("{}: {e}", path.display()))
}
pub(crate) fn write_report(
path: &Path,
expansion: &[ExpansionReport],
reports: &[DocReport],
) -> Result<(), String> {
let mut file = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
struct SuiteStats {
tests: usize,
failures: usize,
errors: usize,
}
let suite_stats: Vec<SuiteStats> = reports
.iter()
.map(|report| SuiteStats {
tests: report.rows.len() + usize::from(report.doc_error.is_some()),
failures: report.rows.iter().filter(|r| r.outcome.is_err()).count(),
errors: usize::from(report.doc_error.is_some()),
})
.collect();
let mut tests: usize = suite_stats.iter().map(|s| s.tests).sum();
let failures: usize = suite_stats.iter().map(|s| s.failures).sum();
let mut errors: usize = suite_stats.iter().map(|s| s.errors).sum();
tests += expansion.len();
errors += expansion.len();
write_line(&mut file, path, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
write_line(
&mut file,
path,
&format!(r#"<testsuites tests="{tests}" failures="{failures}" errors="{errors}">"#),
)?;
for (report, stats) in reports.iter().zip(&suite_stats) {
let path_str = escape_xml(&report.path.display().to_string());
let suite_tests = stats.tests;
let suite_failures = stats.failures;
let suite_errors = stats.errors;
write_line(
&mut file,
path,
&format!(
r#"<testsuite name="{path_str}" tests="{suite_tests}" failures="{suite_failures}" errors="{suite_errors}">"#
),
)?;
for row in &report.rows {
let name = escape_xml(&row.endpoint);
match &row.outcome {
Ok(()) => write_line(
&mut file,
path,
&format!(r#"<testcase name="{name}" classname="{path_str}" />"#),
)?,
Err(detail) => {
let message = escape_xml(first_line(detail));
let body = escape_xml(detail);
write_line(
&mut file,
path,
&format!(
r#"<testcase name="{name}" classname="{path_str}"><failure message="{message}">{body}</failure></testcase>"#
),
)?;
}
}
}
if let Some(doc_error) = &report.doc_error {
let message = escape_xml(first_line(doc_error));
let body = escape_xml(doc_error);
write_line(
&mut file,
path,
&format!(
r#"<testcase name="<document>" classname="{path_str}"><error message="{message}">{body}</error></testcase>"#
),
)?;
}
write_line(&mut file, path, "</testsuite>")?;
}
for exp in expansion {
let name = escape_xml(&exp.name);
let message = escape_xml(first_line(&exp.error));
let body = escape_xml(&exp.error);
write_line(
&mut file,
path,
&format!(r#"<testsuite name="{name}" tests="1" failures="0" errors="1">"#),
)?;
write_line(
&mut file,
path,
&format!(
r#"<testcase name="<expansion>"><error message="{message}">{body}</error></testcase>"#
),
)?;
write_line(&mut file, path, "</testsuite>")?;
}
write_line(&mut file, path, "</testsuites>")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_xml_escapes_five_and_strips_controls() {
let input = "a<b>&\"'c\u{0001}\u{0007}d\te\nf\u{FFFE}\u{FFFF}";
assert_eq!(
escape_xml(input),
"a<b>&"'cd\te\nf",
"five entities escaped, controls and non-characters removed"
);
}
#[test]
fn write_report_all_pass_golden() {
let dir = tempfile::tempdir().expect("create tempdir"); let path = dir.path().join("report.xml");
let report = DocReport {
path: PathBuf::from("a.test.yaml"),
rows: vec![EndpointResult {
endpoint: "out".to_string(),
outcome: Ok(()),
}],
doc_error: None,
};
write_report(&path, &[], &[report]).expect("write report"); let bytes = std::fs::read(&path).expect("read report"); let expected = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuites tests=\"1\" failures=\"0\" errors=\"0\">\n<testsuite name=\"a.test.yaml\" tests=\"1\" failures=\"0\" errors=\"0\">\n<testcase name=\"out\" classname=\"a.test.yaml\" />\n</testsuite>\n</testsuites>\n";
assert_eq!(
String::from_utf8(bytes).expect("utf8"), expected,
"all-pass report must match the golden bytes exactly"
);
}
#[test]
fn write_report_failure_doc_error_expansion_golden() {
let dir = tempfile::tempdir().expect("create tempdir"); let path = dir.path().join("report.xml");
let report_a = DocReport {
path: PathBuf::from("a.test.yaml"),
rows: vec![EndpointResult {
endpoint: "<settle>".to_string(),
outcome: Err("mismatch: line1\nline2".to_string()),
}],
doc_error: None,
};
let report_b = DocReport {
path: PathBuf::from("b.test.yaml"),
rows: vec![],
doc_error: Some("boot failed\ncause".to_string()),
};
let expansion = vec![ExpansionReport {
name: "./empty".to_string(),
error: "no test documents found".to_string(),
}];
write_report(&path, &expansion, &[report_a, report_b]).expect("write report"); let text = std::fs::read_to_string(&path).expect("read report"); assert!(
text.contains("<testcase name=\"<settle>\" classname=\"a.test.yaml\"><failure message=\"mismatch: line1\">mismatch: line1\nline2</failure></testcase>"),
"failure row must carry first-line message and full detail: {text}"
);
assert!(
text.contains("<testcase name=\"<document>\" classname=\"b.test.yaml\"><error message=\"boot failed\">boot failed\ncause</error></testcase>"),
"doc-error row must render as an error testcase: {text}"
);
assert!(
text.contains("<testcase name=\"<expansion>\"><error message=\"no test documents found\">no test documents found</error></testcase>"),
"expansion suite must hold a single error testcase: {text}"
);
assert!(
text.contains("<testsuites tests=\"3\" failures=\"1\" errors=\"2\">"),
"root totals must count every testcase: {text}"
);
}
#[test]
fn write_report_write_failure_is_err() {
let dir = tempfile::tempdir().expect("create tempdir"); let path = dir.path().join("missing").join("report.xml");
let err = write_report(&path, &[], &[]).expect_err("write must fail"); assert!(err.contains("report.xml"), "err must name the path: {err}");
}
#[test]
fn write_report_escapes_doc_path() {
let dir = tempfile::tempdir().expect("create tempdir"); let path = dir.path().join("report.xml");
let report = DocReport {
path: PathBuf::from("tmp/a&b<c>.test.yaml"),
rows: vec![EndpointResult {
endpoint: "out".to_string(),
outcome: Ok(()),
}],
doc_error: None,
};
write_report(&path, &[], &[report]).expect("write report"); let text = std::fs::read_to_string(&path).expect("read report"); assert!(
text.contains(r#"name="tmp/a&b<c>.test.yaml""#),
"suite name attribute must escape &: {text}"
);
assert!(
text.contains(r#"classname="tmp/a&b<c>.test.yaml""#),
"classname attribute must escape path: {text}"
);
assert!(
!text.contains("a&b<c>"),
"raw path must not appear in XML: {text}"
);
}
}