Skip to main content

ssg/audit/output/
junit.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `JUnit` XML formatter for audit reports.
5//!
6//! One `<testsuite>` per gate; one `<testcase>` per finding. A gate
7//! with zero findings emits a single passing `<testcase>` so the
8//! suite isn't flagged as empty by CI parsers (GitLab, Jenkins).
9//!
10//! No external XML crate is pulled in — the document shape is too
11//! tightly constrained for tag-balancing bugs to slip in, and the
12//! existing `quick-xml` dep in the workspace is feature-gated to the
13//! optional `minify` build.
14
15use crate::audit::{AuditReport, Severity};
16use std::fmt::Write;
17
18/// Renders `report` as `JUnit` XML.
19///
20/// # Examples
21///
22/// ```
23/// use ssg::audit::AuditReport;
24/// use ssg::audit::output::junit::format;
25/// let report = AuditReport { gates: vec![] };
26/// let xml = format(&report);
27/// assert!(xml.contains("<testsuites>"));
28/// ```
29#[must_use]
30pub fn format(report: &AuditReport) -> String {
31    let mut out = String::with_capacity(2048);
32    let _ = writeln!(out, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
33    let _ = writeln!(out, "<testsuites>");
34
35    for gate in &report.gates {
36        let total = gate.findings.len();
37        let failures = gate.severity_counts.error;
38        let skipped = usize::from(gate.skipped);
39        let _ = writeln!(
40            out,
41            "  <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">",
42            escape(&gate.name),
43            total.max(1),
44            failures,
45            skipped
46        );
47        if gate.skipped {
48            let _ = writeln!(
49                out,
50                "    <testcase name=\"{}\" classname=\"{}\"><skipped message=\"{}\"/></testcase>",
51                escape(&gate.name),
52                escape(&gate.name),
53                escape(gate.skip_reason.as_deref().unwrap_or(""))
54            );
55        } else if gate.findings.is_empty() {
56            let _ = writeln!(
57                out,
58                "    <testcase name=\"{}\" classname=\"{}\"/>",
59                escape(&gate.name),
60                escape(&gate.name)
61            );
62        } else {
63            for f in &gate.findings {
64                let case_name = f.code.as_deref().unwrap_or(&gate.name);
65                let _ = writeln!(
66                    out,
67                    "    <testcase name=\"{}\" classname=\"{}\">",
68                    escape(case_name),
69                    escape(&gate.name)
70                );
71                let tag = match f.severity {
72                    Severity::Error => "failure",
73                    Severity::Warn => "failure", // warnings show as failures unless --fail-on lifts them
74                    Severity::Info => "system-out",
75                };
76                let path_attr = f
77                    .path
78                    .as_ref()
79                    .map(|p| format!(" file=\"{}\"", escape(p)))
80                    .unwrap_or_default();
81                if matches!(f.severity, Severity::Info) {
82                    let _ = writeln!(
83                        out,
84                        "      <{tag}>{}</{tag}>",
85                        escape(&f.message)
86                    );
87                } else {
88                    let _ = writeln!(
89                        out,
90                        "      <{tag} type=\"{}\"{}>{}</{tag}>",
91                        f.severity,
92                        path_attr,
93                        escape(&f.message)
94                    );
95                }
96                let _ = writeln!(out, "    </testcase>");
97            }
98        }
99        let _ = writeln!(out, "  </testsuite>");
100    }
101
102    let _ = writeln!(out, "</testsuites>");
103    out
104}
105
106fn escape(s: &str) -> String {
107    s.replace('&', "&amp;")
108        .replace('<', "&lt;")
109        .replace('>', "&gt;")
110        .replace('"', "&quot;")
111        .replace('\'', "&apos;")
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::audit::{Finding, GateResult, Severity, SeverityCounts};
118
119    fn one_gate_report() -> AuditReport {
120        AuditReport {
121            gates: vec![GateResult {
122                name: "g".to_string(),
123                skipped: false,
124                skip_reason: None,
125                severity_counts: SeverityCounts {
126                    info: 0,
127                    warn: 0,
128                    error: 1,
129                },
130                findings: vec![Finding::new("g", Severity::Error, "boom")
131                    .with_code("X")
132                    .with_path("a.html")],
133            }],
134        }
135    }
136
137    #[test]
138    fn well_formed_xml_header() {
139        let xml = format(&one_gate_report());
140        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
141        assert!(xml.contains("<testsuites>"));
142        assert!(xml.contains("</testsuites>"));
143    }
144
145    #[test]
146    fn one_testcase_per_finding() {
147        let xml = format(&one_gate_report());
148        assert_eq!(xml.matches("<testcase").count(), 1);
149        assert!(xml.contains("<failure"));
150    }
151
152    #[test]
153    fn skipped_gate_emits_skipped_testcase() {
154        let report = AuditReport {
155            gates: vec![GateResult {
156                name: "g".to_string(),
157                skipped: true,
158                skip_reason: Some("test".to_string()),
159                severity_counts: SeverityCounts::default(),
160                findings: vec![],
161            }],
162        };
163        let xml = format(&report);
164        assert!(xml.contains("<skipped"));
165    }
166
167    #[test]
168    fn xml_special_chars_escaped() {
169        let report = AuditReport {
170            gates: vec![GateResult {
171                name: "g".to_string(),
172                skipped: false,
173                skip_reason: None,
174                severity_counts: SeverityCounts {
175                    info: 0,
176                    warn: 0,
177                    error: 1,
178                },
179                findings: vec![Finding::new(
180                    "g",
181                    Severity::Error,
182                    "a < b & c > d \"e\"",
183                )],
184            }],
185        };
186        let xml = format(&report);
187        assert!(xml.contains("a &lt; b &amp; c &gt; d &quot;e&quot;"));
188    }
189}