ssg/audit/output/
junit.rs1use crate::audit::{AuditReport, Severity};
16use std::fmt::Write;
17
18#[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", 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('&', "&")
108 .replace('<', "<")
109 .replace('>', ">")
110 .replace('"', """)
111 .replace('\'', "'")
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 < b & c > d "e""));
188 }
189}