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 (c as u32) < 0x20 => {} c => out.push(c),
189 }
190 }
191}
192
193fn write_text(out: &mut String, s: &str) {
194 for ch in s.chars() {
195 match ch {
196 '&' => out.push_str("&"),
197 '<' => out.push_str("<"),
198 '>' => out.push_str(">"),
199 c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {}
200 c => out.push(c),
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn empty_report_emits_well_formed_envelope() {
211 let r = Report::new("c", "0.1.0").with_producer("p");
212 let xml = to_junit_xml(&r);
213 assert!(xml.starts_with("<?xml"));
214 assert!(xml.contains("<testsuites"));
215 assert!(xml.contains("<testsuite"));
216 assert!(xml.contains("tests=\"0\""));
217 assert!(xml.ends_with("</testsuites>\n"));
218 }
219
220 #[test]
221 fn verdict_counts_appear_on_testsuite() {
222 let mut r = Report::new("c", "0.1.0").with_producer("p");
223 r.push(CheckResult::pass("a"));
224 r.push(CheckResult::pass("b"));
225 r.push(CheckResult::fail("c", Severity::Error));
226 r.push(CheckResult::warn("d", Severity::Warning));
227 r.push(CheckResult::skip("e"));
228 let xml = to_junit_xml(&r);
229 assert!(xml.contains("tests=\"5\""));
230 assert!(xml.contains("failures=\"1\""));
231 assert!(xml.contains("skipped=\"1\""));
232 }
233
234 #[test]
235 fn pass_emits_self_closing_testcase() {
236 let mut r = Report::new("c", "0.1.0").with_producer("p");
237 r.push(CheckResult::pass("compile"));
238 let xml = to_junit_xml(&r);
239 assert!(xml.contains("<testcase name=\"compile\" classname=\"p\" time=\"0.000\"/>"));
240 }
241
242 #[test]
243 fn fail_emits_failure_child_with_type_and_message() {
244 let mut r = Report::new("c", "0.1.0").with_producer("p");
245 r.push(CheckResult::fail("oops", Severity::Critical).with_detail("the reason"));
246 let xml = to_junit_xml(&r);
247 assert!(xml.contains("<failure type=\"Critical\" message=\"the reason\">"));
248 assert!(xml.contains("the reason</failure>"));
249 }
250
251 #[test]
252 fn skip_emits_skipped_child() {
253 let mut r = Report::new("c", "0.1.0").with_producer("p");
254 r.push(CheckResult::skip("network").with_detail("no net"));
255 let xml = to_junit_xml(&r);
256 assert!(xml.contains("<skipped message=\"no net\"/>"));
257 }
258
259 #[test]
260 fn warn_emits_passing_testcase() {
261 let mut r = Report::new("c", "0.1.0").with_producer("p");
262 r.push(CheckResult::warn("flaky", Severity::Warning));
263 let xml = to_junit_xml(&r);
264 assert!(!xml.contains("<failure"));
266 assert!(xml.contains("<testcase name=\"flaky\""));
267 }
268
269 #[test]
270 fn xml_escapes_special_chars_in_attribute_values() {
271 let mut r = Report::new("c", "0.1.0").with_producer("p");
272 r.push(
273 CheckResult::fail("name<>&\"'", Severity::Error)
274 .with_detail("oh no: <bad> & \"quotes\""),
275 );
276 let xml = to_junit_xml(&r);
277 assert!(xml.contains("name=\"name<>&"'\""));
278 assert!(xml.contains("message=\"oh no: <bad> & "quotes"\""));
279 assert!(xml.contains("oh no: <bad> & \"quotes\"</failure>"));
281 }
282
283 #[test]
284 fn duration_ms_becomes_seconds_with_three_decimals() {
285 let mut r = Report::new("c", "0.1.0").with_producer("p");
286 r.push(CheckResult::pass("a").with_duration_ms(1500));
287 r.push(CheckResult::pass("b").with_duration_ms(7));
288 let xml = to_junit_xml(&r);
289 assert!(xml.contains("time=\"1.500\""));
290 assert!(xml.contains("time=\"0.007\""));
291 }
292
293 #[test]
294 fn multi_emits_one_testsuite_per_producer() {
295 let mut bench = Report::new("c", "0.1.0").with_producer("dev-bench");
296 bench.push(CheckResult::pass("hot"));
297 let mut chaos = Report::new("c", "0.1.0").with_producer("dev-chaos");
298 chaos.push(CheckResult::fail("recover", Severity::Error));
299 let mut multi = MultiReport::new("c", "0.1.0");
300 multi.push(bench);
301 multi.push(chaos);
302
303 let xml = multi_to_junit_xml(&multi);
304 let n_suites = xml.matches("<testsuite ").count();
305 assert_eq!(n_suites, 2);
306 assert!(xml.contains("name=\"dev-bench\""));
307 assert!(xml.contains("name=\"dev-chaos\""));
308 }
309
310 #[test]
311 fn output_is_deterministic() {
312 let mut r = Report::new("c", "0.1.0").with_producer("p");
313 r.push(CheckResult::pass("a"));
314 r.push(CheckResult::fail("b", Severity::Error).with_detail("bad"));
315 let x1 = to_junit_xml(&r);
316 let x2 = to_junit_xml(&r);
317 assert_eq!(x1, x2);
318 }
319
320 #[test]
321 fn report_without_producer_uses_unknown_name() {
322 let mut r = Report::new("c", "0.1.0");
323 r.push(CheckResult::pass("a"));
324 let xml = to_junit_xml(&r);
325 assert!(xml.contains("<testsuite name=\"unknown\""));
326 assert!(xml.contains("classname=\"unknown\""));
327 }
328
329 #[test]
330 fn fail_without_detail_uses_name_as_message() {
331 let mut r = Report::new("c", "0.1.0").with_producer("p");
332 r.push(CheckResult::fail("the_check", Severity::Error));
333 let xml = to_junit_xml(&r);
334 assert!(xml.contains("message=\"the_check\""));
335 assert!(xml.contains(">the_check</failure>"));
336 }
337}