Skip to main content

dev_report/
junit.rs

1//! JUnit XML export for [`Report`] and [`MultiReport`].
2//!
3//! Conforms to the common Jenkins/Surefire JUnit XML schema. Every
4//! [`CheckResult`] becomes a `<testcase>`; fail verdicts get a
5//! `<failure>` child, skip verdicts get a `<skipped/>` child.
6//!
7//! ### Verdict → element mapping
8//!
9//! | [`Verdict`]       | XML form                                  |
10//! |-------------------|-------------------------------------------|
11//! | `Pass`            | `<testcase ... />`                        |
12//! | `Warn`            | `<testcase ... />` (no child)             |
13//! | `Fail`            | `<testcase ...><failure .../></testcase>` |
14//! | `Skip`            | `<testcase ...><skipped/></testcase>`     |
15//!
16//! `Warn` has no native JUnit representation; it is emitted as a passing
17//! testcase. Producers that want warns surfaced as findings should use
18//! the SARIF exporter (see [`crate::sarif`]) instead. `<failure>` carries
19//! a `type` attribute derived from [`Severity`] (`Error`, `Critical`).
20//!
21//! For a [`MultiReport`], each constituent [`Report`] becomes one
22//! `<testsuite>`; the producer's name becomes the testsuite name and the
23//! testcase `classname`.
24//!
25//! Available with the `junit` feature.
26//!
27//! [`Verdict`]: crate::Verdict
28//! [`Severity`]: crate::Severity
29//! [`CheckResult`]: crate::CheckResult
30
31use std::fmt::Write;
32
33use crate::{CheckResult, MultiReport, Report, Severity, Verdict};
34
35/// Render `report` as a JUnit XML document.
36///
37/// # Example
38///
39/// ```
40/// use dev_report::{CheckResult, Report};
41///
42/// let mut r = Report::new("crate", "0.1.0").with_producer("dev-bench");
43/// r.push(CheckResult::pass("compile"));
44///
45/// let xml = dev_report::junit::to_junit_xml(&r);
46/// assert!(xml.starts_with("<?xml"));
47/// assert!(xml.contains("<testsuite"));
48/// assert!(xml.contains("name=\"compile\""));
49/// ```
50pub 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
60/// Render `multi` as a JUnit XML document with one `<testsuite>` per
61/// constituent [`Report`].
62///
63/// # Example
64///
65/// ```
66/// use dev_report::{CheckResult, MultiReport, Report};
67///
68/// let mut bench = Report::new("crate", "0.1.0").with_producer("dev-bench");
69/// bench.push(CheckResult::pass("hot"));
70/// let mut multi = MultiReport::new("crate", "0.1.0");
71/// multi.push(bench);
72///
73/// let xml = dev_report::junit::multi_to_junit_xml(&multi);
74/// assert!(xml.contains("<testsuite"));
75/// ```
76pub 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("&amp;"),
180            '<' => out.push_str("&lt;"),
181            '>' => out.push_str("&gt;"),
182            '"' => out.push_str("&quot;"),
183            '\'' => out.push_str("&apos;"),
184            '\n' => out.push_str("&#10;"),
185            '\r' => out.push_str("&#13;"),
186            '\t' => out.push_str("&#9;"),
187            // Strip characters XML 1.0 does not allow at all.
188            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("&amp;"),
198            '<' => out.push_str("&lt;"),
199            '>' => out.push_str("&gt;"),
200            c if is_xml_illegal(c) => {}
201            c => out.push(c),
202        }
203    }
204}
205
206/// `true` for characters outside the XML 1.0 `Char` production: C0
207/// control characters other than tab, newline and carriage return, and
208/// the noncharacters U+FFFE and U+FFFF. A parser rejects the whole
209/// document if any of them appears, even escaped.
210fn 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        // No <failure> element; warn is a passing testcase in JUnit.
277        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&lt;&gt;&amp;&quot;&apos;\""));
290        assert!(xml.contains("message=\"oh no: &lt;bad&gt; &amp; &quot;quotes&quot;\""));
291        // Inside the text body, &, <, > are escaped; " is not.
292        assert!(xml.contains("oh no: &lt;bad&gt; &amp; \"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&#9;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}