1use std::fmt::Write;
32
33use crate::{CheckResult, MultiReport, Report, Severity, Verdict};
34
35pub fn to_junit_xml(report: &Report) -> String {
51 let mut out = String::new();
52 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
53 let reports = std::slice::from_ref(report);
54 write_root_open(&mut out, &report.subject, reports);
55 write_testsuite(&mut out, report);
56 out.push_str("</testsuites>\n");
57 out
58}
59
60pub fn multi_to_junit_xml(multi: &MultiReport) -> String {
77 let mut out = String::new();
78 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
79 write_root_open(&mut out, &multi.subject, &multi.reports);
80 for r in &multi.reports {
81 write_testsuite(&mut out, r);
82 }
83 out.push_str("</testsuites>\n");
84 out
85}
86
87fn write_root_open(out: &mut String, suite_name: &str, reports: &[Report]) {
88 let (pass, fail, warn, skip) = aggregate_counts(reports);
89 let total = pass + fail + warn + skip;
90 out.push_str("<testsuites");
91 write_attr(out, "name", suite_name);
92 write_attr(out, "tests", &total.to_string());
93 write_attr(out, "failures", &fail.to_string());
94 write_attr(out, "skipped", &skip.to_string());
95 out.push_str(">\n");
96}
97
98fn aggregate_counts(reports: &[Report]) -> (usize, usize, usize, usize) {
99 let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
100 for r in reports {
101 let (rp, rf, rw, rs) = r.verdict_counts();
102 p += rp;
103 f += rf;
104 w += rw;
105 s += rs;
106 }
107 (p, f, w, s)
108}
109
110fn write_testsuite(out: &mut String, report: &Report) {
111 let producer = report.producer.as_deref().unwrap_or("unknown");
112 let (pass, fail, warn, skip) = report.verdict_counts();
113 let total = pass + fail + warn + skip;
114 let total_ms: u64 = report.checks.iter().filter_map(|c| c.duration_ms).sum();
115
116 out.push_str(" <testsuite");
117 write_attr(out, "name", producer);
118 write_attr(out, "tests", &total.to_string());
119 write_attr(out, "failures", &fail.to_string());
120 write_attr(out, "skipped", &skip.to_string());
121 write_attr(out, "time", &format_seconds(total_ms));
122 out.push_str(">\n");
123
124 for c in &report.checks {
125 write_testcase(out, producer, c);
126 }
127 out.push_str(" </testsuite>\n");
128}
129
130fn write_testcase(out: &mut String, classname: &str, c: &CheckResult) {
131 let time = c.duration_ms.unwrap_or(0);
132 out.push_str(" <testcase");
133 write_attr(out, "name", &c.name);
134 write_attr(out, "classname", classname);
135 write_attr(out, "time", &format_seconds(time));
136 match c.verdict {
137 Verdict::Pass | Verdict::Warn => {
138 out.push_str("/>\n");
139 }
140 Verdict::Skip => {
141 out.push_str(">\n <skipped");
142 if let Some(d) = &c.detail {
143 write_attr(out, "message", d);
144 }
145 out.push_str("/>\n </testcase>\n");
146 }
147 Verdict::Fail => {
148 let kind = match c.severity {
149 Some(Severity::Critical) => "Critical",
150 Some(Severity::Error) => "Error",
151 Some(Severity::Warning) => "Warning",
152 Some(Severity::Info) => "Info",
153 None => "Failure",
154 };
155 out.push_str(">\n <failure");
156 write_attr(out, "type", kind);
157 let message = c.detail.as_deref().unwrap_or(&c.name);
158 write_attr(out, "message", message);
159 out.push('>');
160 write_text(out, message);
161 out.push_str("</failure>\n </testcase>\n");
162 }
163 }
164}
165
166fn format_seconds(ms: u64) -> String {
167 format!("{:.3}", ms as f64 / 1000.0)
168}
169
170fn write_attr(out: &mut String, name: &str, value: &str) {
171 write!(out, " {}=\"", name).expect("write to String never fails");
172 escape_attr(out, value);
173 out.push('"');
174}
175
176fn escape_attr(out: &mut String, s: &str) {
177 for ch in s.chars() {
178 match ch {
179 '&' => out.push_str("&"),
180 '<' => out.push_str("<"),
181 '>' => out.push_str(">"),
182 '"' => out.push_str("""),
183 '\'' => out.push_str("'"),
184 '\n' => out.push_str(" "),
185 '\r' => out.push_str(" "),
186 '\t' => out.push_str("	"),
187 c if is_xml_illegal(c) => {}
189 c => out.push(c),
190 }
191 }
192}
193
194fn write_text(out: &mut String, s: &str) {
195 for ch in s.chars() {
196 match ch {
197 '&' => out.push_str("&"),
198 '<' => out.push_str("<"),
199 '>' => out.push_str(">"),
200 c if is_xml_illegal(c) => {}
201 c => out.push(c),
202 }
203 }
204}
205
206fn is_xml_illegal(c: char) -> bool {
211 matches!(
212 c,
213 '\u{0}'..='\u{8}' | '\u{B}' | '\u{C}' | '\u{E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}'
214 )
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn empty_report_emits_well_formed_envelope() {
223 let r = Report::new("c", "0.1.0").with_producer("p");
224 let xml = to_junit_xml(&r);
225 assert!(xml.starts_with("<?xml"));
226 assert!(xml.contains("<testsuites"));
227 assert!(xml.contains("<testsuite"));
228 assert!(xml.contains("tests=\"0\""));
229 assert!(xml.ends_with("</testsuites>\n"));
230 }
231
232 #[test]
233 fn verdict_counts_appear_on_testsuite() {
234 let mut r = Report::new("c", "0.1.0").with_producer("p");
235 r.push(CheckResult::pass("a"));
236 r.push(CheckResult::pass("b"));
237 r.push(CheckResult::fail("c", Severity::Error));
238 r.push(CheckResult::warn("d", Severity::Warning));
239 r.push(CheckResult::skip("e"));
240 let xml = to_junit_xml(&r);
241 assert!(xml.contains("tests=\"5\""));
242 assert!(xml.contains("failures=\"1\""));
243 assert!(xml.contains("skipped=\"1\""));
244 }
245
246 #[test]
247 fn pass_emits_self_closing_testcase() {
248 let mut r = Report::new("c", "0.1.0").with_producer("p");
249 r.push(CheckResult::pass("compile"));
250 let xml = to_junit_xml(&r);
251 assert!(xml.contains("<testcase name=\"compile\" classname=\"p\" time=\"0.000\"/>"));
252 }
253
254 #[test]
255 fn fail_emits_failure_child_with_type_and_message() {
256 let mut r = Report::new("c", "0.1.0").with_producer("p");
257 r.push(CheckResult::fail("oops", Severity::Critical).with_detail("the reason"));
258 let xml = to_junit_xml(&r);
259 assert!(xml.contains("<failure type=\"Critical\" message=\"the reason\">"));
260 assert!(xml.contains("the reason</failure>"));
261 }
262
263 #[test]
264 fn skip_emits_skipped_child() {
265 let mut r = Report::new("c", "0.1.0").with_producer("p");
266 r.push(CheckResult::skip("network").with_detail("no net"));
267 let xml = to_junit_xml(&r);
268 assert!(xml.contains("<skipped message=\"no net\"/>"));
269 }
270
271 #[test]
272 fn warn_emits_passing_testcase() {
273 let mut r = Report::new("c", "0.1.0").with_producer("p");
274 r.push(CheckResult::warn("flaky", Severity::Warning));
275 let xml = to_junit_xml(&r);
276 assert!(!xml.contains("<failure"));
278 assert!(xml.contains("<testcase name=\"flaky\""));
279 }
280
281 #[test]
282 fn xml_escapes_special_chars_in_attribute_values() {
283 let mut r = Report::new("c", "0.1.0").with_producer("p");
284 r.push(
285 CheckResult::fail("name<>&\"'", Severity::Error)
286 .with_detail("oh no: <bad> & \"quotes\""),
287 );
288 let xml = to_junit_xml(&r);
289 assert!(xml.contains("name=\"name<>&"'\""));
290 assert!(xml.contains("message=\"oh no: <bad> & "quotes"\""));
291 assert!(xml.contains("oh no: <bad> & \"quotes\"</failure>"));
293 }
294
295 #[test]
296 fn characters_illegal_in_xml_are_stripped() {
297 let mut r = Report::new("c", "0.1.0").with_producer("p");
298 r.push(
299 CheckResult::fail("bad\u{1}name\u{FFFE}", Severity::Error)
300 .with_detail("bell\u{7} nul\u{0} end\u{FFFF}\tok"),
301 );
302 let xml = to_junit_xml(&r);
303 for bad in ['\u{0}', '\u{1}', '\u{7}', '\u{FFFE}', '\u{FFFF}'] {
304 assert!(!xml.contains(bad), "{bad:?} leaked into {xml}");
305 }
306 assert!(xml.contains("name=\"badname\""));
307 assert!(xml.contains("message=\"bell nul end	ok\""));
308 assert!(xml.contains(">bell nul end\tok</failure>"));
309 }
310
311 #[test]
312 fn duration_ms_becomes_seconds_with_three_decimals() {
313 let mut r = Report::new("c", "0.1.0").with_producer("p");
314 r.push(CheckResult::pass("a").with_duration_ms(1500));
315 r.push(CheckResult::pass("b").with_duration_ms(7));
316 let xml = to_junit_xml(&r);
317 assert!(xml.contains("time=\"1.500\""));
318 assert!(xml.contains("time=\"0.007\""));
319 }
320
321 #[test]
322 fn multi_emits_one_testsuite_per_producer() {
323 let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
324 bench.push(CheckResult::pass("hot"));
325 let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
326 chaos.push(CheckResult::fail("recover", Severity::Error));
327 let mut multi = MultiReport::new("c", "0.1.0");
328 multi.push(bench);
329 multi.push(chaos);
330
331 let xml = multi_to_junit_xml(&multi);
332 let n_suites = xml.matches("<testsuite ").count();
333 assert_eq!(n_suites, 2);
334 assert!(xml.contains("name=\"dev-bench\""));
335 assert!(xml.contains("name=\"dev-chaos\""));
336 }
337
338 #[test]
339 fn output_is_deterministic() {
340 let mut r = Report::new("c", "0.1.0").with_producer("p");
341 r.push(CheckResult::pass("a"));
342 r.push(CheckResult::fail("b", Severity::Error).with_detail("bad"));
343 let x1 = to_junit_xml(&r);
344 let x2 = to_junit_xml(&r);
345 assert_eq!(x1, x2);
346 }
347
348 #[test]
349 fn report_without_producer_uses_unknown_name() {
350 let mut r = Report::new("c", "0.1.0");
351 r.push(CheckResult::pass("a"));
352 let xml = to_junit_xml(&r);
353 assert!(xml.contains("<testsuite name=\"unknown\""));
354 assert!(xml.contains("classname=\"unknown\""));
355 }
356
357 #[test]
358 fn fail_without_detail_uses_name_as_message() {
359 let mut r = Report::new("c", "0.1.0").with_producer("p");
360 r.push(CheckResult::fail("the_check", Severity::Error));
361 let xml = to_junit_xml(&r);
362 assert!(xml.contains("message=\"the_check\""));
363 assert!(xml.contains(">the_check</failure>"));
364 }
365}