1use std::collections::BTreeMap;
7
8use diffguard_types::{CheckReceipt, Finding, Severity};
9
10pub fn render_junit_for_receipt(receipt: &CheckReceipt) -> String {
18 let mut out = String::new();
19
20 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
22
23 let mut suites: BTreeMap<String, Vec<&Finding>> = BTreeMap::new();
25 for f in &receipt.findings {
26 suites.entry(f.rule_id.clone()).or_default().push(f);
27 }
28
29 let total_tests = receipt.findings.len();
31 let total_failures = receipt
32 .findings
33 .iter()
34 .filter(|f| matches!(f.severity, Severity::Error | Severity::Warn))
35 .count();
36
37 out.push_str(&format!(
39 "<testsuites name=\"diffguard\" tests=\"{}\" failures=\"{}\" errors=\"0\">\n",
40 total_tests, total_failures
41 ));
42
43 for (rule_id, findings) in &suites {
45 let suite_failures = findings
46 .iter()
47 .filter(|f| matches!(f.severity, Severity::Error | Severity::Warn))
48 .count();
49
50 out.push_str(&format!(
51 " <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"0\">\n",
52 escape_xml(rule_id),
53 findings.len(),
54 suite_failures
55 ));
56
57 for f in findings {
59 let classname = escape_xml(&f.path);
60 let name = format!("{}:{}", f.path, f.line);
61
62 out.push_str(&format!(
63 " <testcase classname=\"{}\" name=\"{}\">\n",
64 classname,
65 escape_xml(&name)
66 ));
67
68 if matches!(f.severity, Severity::Error | Severity::Warn) {
70 let failure_type = if matches!(f.severity, Severity::Error) {
71 "error"
72 } else {
73 "warning"
74 };
75
76 out.push_str(&format!(
77 " <failure type=\"{}\" message=\"{}\">\n",
78 failure_type,
79 escape_xml(&f.message)
80 ));
81 out.push_str(&format!(
82 "Rule: {}\nFile: {}\nLine: {}\nSnippet: {}\n",
83 f.rule_id, f.path, f.line, f.snippet
84 ));
85 out.push_str(" </failure>\n");
86 }
87
88 out.push_str(" </testcase>\n");
89 }
90
91 out.push_str(" </testsuite>\n");
92 }
93
94 if receipt.findings.is_empty() {
96 out.push_str(" <testsuite name=\"diffguard\" tests=\"1\" failures=\"0\" errors=\"0\">\n");
97 out.push_str(" <testcase classname=\"diffguard\" name=\"no_findings\">\n");
98 out.push_str(" </testcase>\n");
99 out.push_str(" </testsuite>\n");
100 }
101
102 out.push_str("</testsuites>\n");
103 out
104}
105
106fn escape_xml(s: &str) -> String {
108 let mut out = String::with_capacity(s.len());
109 for c in s.chars() {
110 match c {
111 '&' => out.push_str("&"),
112 '<' => out.push_str("<"),
113 '>' => out.push_str(">"),
114 '"' => out.push_str("""),
115 '\'' => out.push_str("'"),
116 _ => out.push(c),
117 }
118 }
119 out
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use diffguard_types::{
126 CHECK_SCHEMA_V1, CheckReceipt, DiffMeta, Finding, Scope, ToolMeta, Verdict, VerdictCounts,
127 VerdictStatus,
128 };
129
130 fn create_test_receipt_with_findings() -> CheckReceipt {
131 CheckReceipt {
132 schema: CHECK_SCHEMA_V1.to_string(),
133 tool: ToolMeta {
134 name: "diffguard".to_string(),
135 version: "0.1.0".to_string(),
136 },
137 diff: DiffMeta {
138 base: "origin/main".to_string(),
139 head: "HEAD".to_string(),
140 context_lines: 0,
141 scope: Scope::Added,
142 files_scanned: 3,
143 lines_scanned: 42,
144 },
145 findings: vec![
146 Finding {
147 rule_id: "rust.no_unwrap".to_string(),
148 severity: Severity::Error,
149 message: "Avoid unwrap/expect in production code.".to_string(),
150 path: "src/lib.rs".to_string(),
151 line: 15,
152 column: Some(10),
153 match_text: ".unwrap()".to_string(),
154 snippet: "let value = result.unwrap();".to_string(),
155 },
156 Finding {
157 rule_id: "rust.no_dbg".to_string(),
158 severity: Severity::Warn,
159 message: "Remove dbg!/println! before merging.".to_string(),
160 path: "src/main.rs".to_string(),
161 line: 23,
162 column: Some(5),
163 match_text: "dbg!".to_string(),
164 snippet: " dbg!(config);".to_string(),
165 },
166 Finding {
167 rule_id: "rust.no_unwrap".to_string(),
168 severity: Severity::Error,
169 message: "Avoid unwrap/expect in production code.".to_string(),
170 path: "src/other.rs".to_string(),
171 line: 8,
172 column: None,
173 match_text: ".unwrap()".to_string(),
174 snippet: "x.unwrap()".to_string(),
175 },
176 ],
177 verdict: Verdict {
178 status: VerdictStatus::Fail,
179 counts: VerdictCounts {
180 info: 0,
181 warn: 1,
182 error: 2,
183 ..Default::default()
184 },
185 reasons: vec![
186 "2 error-level findings".to_string(),
187 "1 warning-level finding".to_string(),
188 ],
189 },
190 timing: None,
191 }
192 }
193
194 fn create_test_receipt_empty() -> CheckReceipt {
195 CheckReceipt {
196 schema: CHECK_SCHEMA_V1.to_string(),
197 tool: ToolMeta {
198 name: "diffguard".to_string(),
199 version: "0.1.0".to_string(),
200 },
201 diff: DiffMeta {
202 base: "origin/main".to_string(),
203 head: "HEAD".to_string(),
204 context_lines: 0,
205 scope: Scope::Added,
206 files_scanned: 5,
207 lines_scanned: 120,
208 },
209 findings: vec![],
210 verdict: Verdict {
211 status: VerdictStatus::Pass,
212 counts: VerdictCounts::default(),
213 reasons: vec![],
214 },
215 timing: None,
216 }
217 }
218
219 #[test]
220 fn junit_xml_declaration() {
221 let receipt = create_test_receipt_empty();
222 let xml = render_junit_for_receipt(&receipt);
223 assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
224 }
225
226 #[test]
227 fn junit_has_testsuites_root() {
228 let receipt = create_test_receipt_with_findings();
229 let xml = render_junit_for_receipt(&receipt);
230 assert!(xml.contains("<testsuites name=\"diffguard\""));
231 assert!(xml.contains("</testsuites>"));
232 }
233
234 #[test]
235 fn junit_groups_by_rule_id() {
236 let receipt = create_test_receipt_with_findings();
237 let xml = render_junit_for_receipt(&receipt);
238 assert!(xml.contains("<testsuite name=\"rust.no_dbg\""));
240 assert!(xml.contains("<testsuite name=\"rust.no_unwrap\""));
241 }
242
243 #[test]
244 fn junit_has_correct_test_count() {
245 let receipt = create_test_receipt_with_findings();
246 let xml = render_junit_for_receipt(&receipt);
247 assert!(xml.contains("tests=\"3\""));
249 }
250
251 #[test]
252 fn junit_has_correct_failure_count() {
253 let receipt = create_test_receipt_with_findings();
254 let xml = render_junit_for_receipt(&receipt);
255 assert!(xml.contains("failures=\"3\""));
257 }
258
259 #[test]
260 fn junit_empty_receipt_has_pass_testcase() {
261 let receipt = create_test_receipt_empty();
262 let xml = render_junit_for_receipt(&receipt);
263 assert!(xml.contains("tests=\"1\""));
264 assert!(xml.contains("failures=\"0\""));
265 assert!(xml.contains("name=\"no_findings\""));
266 }
267
268 #[test]
269 fn junit_escapes_xml_special_chars() {
270 let mut receipt = create_test_receipt_with_findings();
271 receipt.findings[0].message = "Test <special> & \"chars\"".to_string();
272 let xml = render_junit_for_receipt(&receipt);
273 assert!(xml.contains("<special>"));
274 assert!(xml.contains("&"));
275 assert!(xml.contains("""));
276 }
277
278 #[test]
279 fn junit_failure_includes_details() {
280 let receipt = create_test_receipt_with_findings();
281 let xml = render_junit_for_receipt(&receipt);
282 assert!(xml.contains("<failure type=\"error\""));
283 assert!(xml.contains("<failure type=\"warning\""));
284 assert!(xml.contains("Rule: rust.no_unwrap"));
285 assert!(xml.contains("File: src/lib.rs"));
286 assert!(xml.contains("Line: 15"));
287 }
288
289 #[test]
290 fn junit_info_severity_does_not_emit_failure() {
291 let mut receipt = create_test_receipt_with_findings();
292 receipt.findings = vec![Finding {
293 rule_id: "docs.info".to_string(),
294 severity: Severity::Info,
295 message: "Just information".to_string(),
296 path: "README.md".to_string(),
297 line: 1,
298 column: None,
299 match_text: "info".to_string(),
300 snippet: "info".to_string(),
301 }];
302 receipt.verdict.counts = VerdictCounts {
303 info: 1,
304 warn: 0,
305 error: 0,
306 suppressed: 0,
307 };
308 receipt.verdict.status = VerdictStatus::Pass;
309
310 let xml = render_junit_for_receipt(&receipt);
311 assert!(!xml.contains("<failure type=\"error\""));
312 assert!(!xml.contains("<failure type=\"warning\""));
313 }
314
315 #[test]
317 fn snapshot_junit_with_findings() {
318 let receipt = create_test_receipt_with_findings();
319 let xml = render_junit_for_receipt(&receipt);
320 insta::assert_snapshot!(xml);
321 }
322
323 #[test]
325 fn snapshot_junit_no_findings() {
326 let receipt = create_test_receipt_empty();
327 let xml = render_junit_for_receipt(&receipt);
328 insta::assert_snapshot!(xml);
329 }
330
331 #[test]
332 fn escape_xml_handles_all_special_chars() {
333 assert_eq!(escape_xml("&"), "&");
334 assert_eq!(escape_xml("<"), "<");
335 assert_eq!(escape_xml(">"), ">");
336 assert_eq!(escape_xml("\""), """);
337 assert_eq!(escape_xml("'"), "'");
338 assert_eq!(escape_xml("normal text"), "normal text");
339 assert_eq!(escape_xml("<a & b>"), "<a & b>");
340 }
341}