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            c if (c as u32) < 0x20 => {} // strip other control chars
188            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("&amp;"),
197            '<' => out.push_str("&lt;"),
198            '>' => out.push_str("&gt;"),
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        // No <failure> element; warn is a passing testcase in JUnit.
265        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&lt;&gt;&amp;&quot;&apos;\""));
278        assert!(xml.contains("message=\"oh no: &lt;bad&gt; &amp; &quot;quotes&quot;\""));
279        // Inside the text body, &, <, > are escaped; " is not.
280        assert!(xml.contains("oh no: &lt;bad&gt; &amp; \"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}