Skip to main content

diffguard_core/
junit.rs

1//! JUnit XML output renderer.
2//!
3//! Converts CheckReceipt to JUnit XML format for integration with
4//! CI systems that support JUnit test result reporting.
5
6use std::collections::BTreeMap;
7
8use diffguard_types::{CheckReceipt, Finding, Severity};
9
10/// Renders a CheckReceipt as a JUnit XML report.
11///
12/// The structure is:
13/// - `<testsuites>` - root element, one per receipt
14/// - `<testsuite>` - one per unique rule_id
15/// - `<testcase>` - one per finding
16/// - `<failure>` - present for error/warn severity findings
17pub fn render_junit_for_receipt(receipt: &CheckReceipt) -> String {
18    let mut out = String::new();
19
20    // XML declaration
21    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
22
23    // Group findings by rule_id using BTreeMap for deterministic ordering
24    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    // Calculate totals
30    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    // Root element
38    out.push_str(&format!(
39        "<testsuites name=\"diffguard\" tests=\"{}\" failures=\"{}\" errors=\"0\">\n",
40        total_tests, total_failures
41    ));
42
43    // Emit a testsuite per rule_id
44    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        // Emit a testcase per finding
58        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            // Add failure element for non-info severity
69            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 no findings, emit an empty pass testsuite
95    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
106/// Escapes special XML characters in a string.
107fn 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("&amp;"),
112            '<' => out.push_str("&lt;"),
113            '>' => out.push_str("&gt;"),
114            '"' => out.push_str("&quot;"),
115            '\'' => out.push_str("&apos;"),
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        // Should have 2 testsuites (rust.no_dbg and rust.no_unwrap)
239        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        // Total tests should be 3
248        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        // All 3 findings are errors/warnings, so 3 failures
256        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("&lt;special&gt;"));
274        assert!(xml.contains("&amp;"));
275        assert!(xml.contains("&quot;"));
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    /// Snapshot test for JUnit XML output with findings.
316    #[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    /// Snapshot test for JUnit XML output with no findings.
324    #[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("&"), "&amp;");
334        assert_eq!(escape_xml("<"), "&lt;");
335        assert_eq!(escape_xml(">"), "&gt;");
336        assert_eq!(escape_xml("\""), "&quot;");
337        assert_eq!(escape_xml("'"), "&apos;");
338        assert_eq!(escape_xml("normal text"), "normal text");
339        assert_eq!(escape_xml("<a & b>"), "&lt;a &amp; b&gt;");
340    }
341}