Skip to main content

rumdl_lib/output/formatters/
junit.rs

1//! JUnit XML output format
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5
6/// JUnit XML formatter for CI systems
7pub struct JunitFormatter;
8
9impl Default for JunitFormatter {
10    fn default() -> Self {
11        Self
12    }
13}
14
15impl JunitFormatter {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl OutputFormatter for JunitFormatter {
22    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
23        // Format warnings for a single file as a minimal JUnit XML document
24        let mut xml = String::new();
25        xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
26        xml.push('\n');
27
28        let escaped_file = xml_escape(file_path);
29
30        xml.push_str(&format!(
31            r#"<testsuites name="rumdl" tests="1" failures="{}" errors="0" time="0.000">"#,
32            warnings.len()
33        ));
34        xml.push('\n');
35
36        xml.push_str(&format!(
37            r#"  <testsuite name="{}" tests="1" failures="{}" errors="0" time="0.000">"#,
38            escaped_file,
39            warnings.len()
40        ));
41        xml.push('\n');
42
43        xml.push_str(&format!(
44            r#"    <testcase name="Lint {escaped_file}" classname="rumdl" time="0.000">"#
45        ));
46        xml.push('\n');
47
48        // Add failures for each warning
49        for warning in warnings {
50            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
51            let message = xml_escape(&warning.message);
52
53            xml.push_str(&format!(
54                r#"      <failure type="{}" message="{}">{} at line {}, column {}</failure>"#,
55                rule_name, message, message, warning.line, warning.column
56            ));
57            xml.push('\n');
58        }
59
60        xml.push_str("    </testcase>\n");
61        xml.push_str("  </testsuite>\n");
62        xml.push_str("</testsuites>\n");
63
64        xml
65    }
66}
67
68/// Format all warnings as JUnit XML report
69pub fn format_junit_report(all_warnings: &[(String, Vec<LintWarning>)], duration_ms: u64) -> String {
70    let mut xml = String::new();
71    xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
72    xml.push('\n');
73
74    // Count total issues
75    let total_issues: usize = all_warnings.iter().map(|(_, w)| w.len()).sum();
76    let files_with_issues = all_warnings.len();
77
78    // Convert duration to seconds
79    let duration_secs = duration_ms as f64 / 1000.0;
80
81    xml.push_str(&format!(
82        r#"<testsuites name="rumdl" tests="{files_with_issues}" failures="{total_issues}" errors="0" time="{duration_secs:.3}">"#
83    ));
84    xml.push('\n');
85
86    // Group warnings by file
87    for (file_path, warnings) in all_warnings {
88        let escaped_file = xml_escape(file_path);
89
90        xml.push_str(&format!(
91            r#"  <testsuite name="{}" tests="1" failures="{}" errors="0" time="0.000">"#,
92            escaped_file,
93            warnings.len()
94        ));
95        xml.push('\n');
96
97        // Create a test case for the file
98        xml.push_str(&format!(
99            r#"    <testcase name="Lint {escaped_file}" classname="rumdl" time="0.000">"#
100        ));
101        xml.push('\n');
102
103        // Add failures for each warning
104        for warning in warnings {
105            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
106            let message = xml_escape(&warning.message);
107
108            xml.push_str(&format!(
109                r#"      <failure type="{}" message="{}">{} at line {}, column {}</failure>"#,
110                rule_name, message, message, warning.line, warning.column
111            ));
112            xml.push('\n');
113        }
114
115        xml.push_str("    </testcase>\n");
116        xml.push_str("  </testsuite>\n");
117    }
118
119    xml.push_str("</testsuites>\n");
120    xml
121}
122
123/// Escape special XML characters
124fn xml_escape(s: &str) -> String {
125    s.replace('&', "&amp;")
126        .replace('<', "&lt;")
127        .replace('>', "&gt;")
128        .replace('"', "&quot;")
129        .replace('\'', "&apos;")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::rule::{Fix, Severity};
136
137    #[test]
138    fn test_junit_formatter_default() {
139        let _formatter = JunitFormatter;
140        // No fields to test, just ensure it constructs
141    }
142
143    #[test]
144    fn test_junit_formatter_new() {
145        let _formatter = JunitFormatter::new();
146        // No fields to test, just ensure it constructs
147    }
148
149    #[test]
150    fn test_format_warnings_empty() {
151        let formatter = JunitFormatter::new();
152        let warnings = vec![];
153        let output = formatter.format_warnings(&warnings, "test.md");
154
155        assert!(output.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
156        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"0\" errors=\"0\" time=\"0.000\">"));
157        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"0\" errors=\"0\" time=\"0.000\">"));
158        assert!(output.contains("<testcase name=\"Lint test.md\" classname=\"rumdl\" time=\"0.000\">"));
159        assert!(output.contains("</testcase>"));
160        assert!(output.contains("</testsuite>"));
161        assert!(output.contains("</testsuites>"));
162    }
163
164    #[test]
165    fn test_format_single_warning() {
166        let formatter = JunitFormatter::new();
167        let warnings = vec![LintWarning {
168            line: 10,
169            column: 5,
170            end_line: 10,
171            end_column: 15,
172            rule_name: Some("MD001".to_string()),
173            message: "Heading levels should only increment by one level at a time".to_string(),
174            severity: Severity::Warning,
175            fix: None,
176        }];
177
178        let output = formatter.format_warnings(&warnings, "README.md");
179
180        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">"));
181        assert!(
182            output.contains("<testsuite name=\"README.md\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">")
183        );
184        assert!(output.contains(
185            "<failure type=\"MD001\" message=\"Heading levels should only increment by one level at a time\">"
186        ));
187        assert!(output.contains("at line 10, column 5</failure>"));
188    }
189
190    #[test]
191    fn test_format_single_warning_with_fix() {
192        let formatter = JunitFormatter::new();
193        let warnings = vec![LintWarning {
194            line: 10,
195            column: 5,
196            end_line: 10,
197            end_column: 15,
198            rule_name: Some("MD001".to_string()),
199            message: "Heading levels should only increment by one level at a time".to_string(),
200            severity: Severity::Warning,
201            fix: Some(Fix::new(100..110, "## Heading".to_string())),
202        }];
203
204        let output = formatter.format_warnings(&warnings, "README.md");
205
206        // JUnit format doesn't indicate fixable issues
207        assert!(output.contains("<failure type=\"MD001\""));
208        assert!(!output.contains("fixable"));
209    }
210
211    #[test]
212    fn test_format_multiple_warnings() {
213        let formatter = JunitFormatter::new();
214        let warnings = vec![
215            LintWarning {
216                line: 5,
217                column: 1,
218                end_line: 5,
219                end_column: 10,
220                rule_name: Some("MD001".to_string()),
221                message: "First warning".to_string(),
222                severity: Severity::Warning,
223                fix: None,
224            },
225            LintWarning {
226                line: 10,
227                column: 3,
228                end_line: 10,
229                end_column: 20,
230                rule_name: Some("MD013".to_string()),
231                message: "Second warning".to_string(),
232                severity: Severity::Error,
233                fix: None,
234            },
235        ];
236
237        let output = formatter.format_warnings(&warnings, "test.md");
238
239        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"2\" errors=\"0\" time=\"0.000\">"));
240        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"2\" errors=\"0\" time=\"0.000\">"));
241        assert!(output.contains("<failure type=\"MD001\" message=\"First warning\">"));
242        assert!(output.contains("at line 5, column 1</failure>"));
243        assert!(output.contains("<failure type=\"MD013\" message=\"Second warning\">"));
244        assert!(output.contains("at line 10, column 3</failure>"));
245    }
246
247    #[test]
248    fn test_format_warning_unknown_rule() {
249        let formatter = JunitFormatter::new();
250        let warnings = vec![LintWarning {
251            line: 1,
252            column: 1,
253            end_line: 1,
254            end_column: 5,
255            rule_name: None,
256            message: "Unknown rule warning".to_string(),
257            severity: Severity::Warning,
258            fix: None,
259        }];
260
261        let output = formatter.format_warnings(&warnings, "file.md");
262
263        assert!(output.contains("<failure type=\"unknown\" message=\"Unknown rule warning\">"));
264    }
265
266    #[test]
267    fn test_xml_escape() {
268        assert_eq!(xml_escape("normal text"), "normal text");
269        assert_eq!(xml_escape("text with & ampersand"), "text with &amp; ampersand");
270        assert_eq!(xml_escape("text with < and >"), "text with &lt; and &gt;");
271        assert_eq!(xml_escape("text with \" quotes"), "text with &quot; quotes");
272        assert_eq!(xml_escape("text with ' apostrophe"), "text with &apos; apostrophe");
273        assert_eq!(xml_escape("all: < > & \" '"), "all: &lt; &gt; &amp; &quot; &apos;");
274    }
275
276    #[test]
277    fn test_junit_report_empty() {
278        let warnings = vec![];
279        let output = format_junit_report(&warnings, 1234);
280
281        assert!(output.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
282        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"0\" failures=\"0\" errors=\"0\" time=\"1.234\">"));
283        assert!(output.ends_with("</testsuites>\n"));
284    }
285
286    #[test]
287    fn test_junit_report_single_file() {
288        let warnings = vec![(
289            "test.md".to_string(),
290            vec![LintWarning {
291                line: 10,
292                column: 5,
293                end_line: 10,
294                end_column: 15,
295                rule_name: Some("MD001".to_string()),
296                message: "Test warning".to_string(),
297                severity: Severity::Warning,
298                fix: None,
299            }],
300        )];
301
302        let output = format_junit_report(&warnings, 500);
303
304        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.500\">"));
305        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">"));
306    }
307
308    #[test]
309    fn test_junit_report_multiple_files() {
310        let warnings = vec![
311            (
312                "file1.md".to_string(),
313                vec![LintWarning {
314                    line: 1,
315                    column: 1,
316                    end_line: 1,
317                    end_column: 5,
318                    rule_name: Some("MD001".to_string()),
319                    message: "Warning in file 1".to_string(),
320                    severity: Severity::Warning,
321                    fix: None,
322                }],
323            ),
324            (
325                "file2.md".to_string(),
326                vec![
327                    LintWarning {
328                        line: 5,
329                        column: 1,
330                        end_line: 5,
331                        end_column: 10,
332                        rule_name: Some("MD013".to_string()),
333                        message: "Warning 1 in file 2".to_string(),
334                        severity: Severity::Warning,
335                        fix: None,
336                    },
337                    LintWarning {
338                        line: 10,
339                        column: 1,
340                        end_line: 10,
341                        end_column: 10,
342                        rule_name: Some("MD022".to_string()),
343                        message: "Warning 2 in file 2".to_string(),
344                        severity: Severity::Error,
345                        fix: None,
346                    },
347                ],
348            ),
349        ];
350
351        let output = format_junit_report(&warnings, 2500);
352
353        // Total: 2 files, 3 failures
354        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"2\" failures=\"3\" errors=\"0\" time=\"2.500\">"));
355        assert!(output.contains("<testsuite name=\"file1.md\" tests=\"1\" failures=\"1\""));
356        assert!(output.contains("<testsuite name=\"file2.md\" tests=\"1\" failures=\"2\""));
357    }
358
359    #[test]
360    fn test_special_characters_in_message() {
361        let formatter = JunitFormatter::new();
362        let warnings = vec![LintWarning {
363            line: 1,
364            column: 1,
365            end_line: 1,
366            end_column: 5,
367            rule_name: Some("MD001".to_string()),
368            message: "Warning with < > & \" ' special chars".to_string(),
369            severity: Severity::Warning,
370            fix: None,
371        }];
372
373        let output = formatter.format_warnings(&warnings, "test.md");
374
375        assert!(output.contains("message=\"Warning with &lt; &gt; &amp; &quot; &apos; special chars\""));
376        assert!(output.contains(">Warning with &lt; &gt; &amp; &quot; &apos; special chars at line"));
377    }
378
379    #[test]
380    fn test_special_characters_in_file_path() {
381        let formatter = JunitFormatter::new();
382        let warnings = vec![LintWarning {
383            line: 1,
384            column: 1,
385            end_line: 1,
386            end_column: 5,
387            rule_name: Some("MD001".to_string()),
388            message: "Test".to_string(),
389            severity: Severity::Warning,
390            fix: None,
391        }];
392
393        let output = formatter.format_warnings(&warnings, "path/with<special>&chars.md");
394
395        assert!(output.contains("<testsuite name=\"path/with&lt;special&gt;&amp;chars.md\""));
396        assert!(output.contains("<testcase name=\"Lint path/with&lt;special&gt;&amp;chars.md\""));
397    }
398
399    #[test]
400    fn test_xml_structure() {
401        let formatter = JunitFormatter::new();
402        let warnings = vec![LintWarning {
403            line: 1,
404            column: 1,
405            end_line: 1,
406            end_column: 5,
407            rule_name: Some("MD001".to_string()),
408            message: "Test".to_string(),
409            severity: Severity::Warning,
410            fix: None,
411        }];
412
413        let output = formatter.format_warnings(&warnings, "test.md");
414
415        // Verify XML structure is properly nested
416        let lines: Vec<&str> = output.lines().collect();
417        assert_eq!(lines[0], "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
418        assert!(lines[1].starts_with("<testsuites"));
419        assert!(lines[2].starts_with("  <testsuite"));
420        assert!(lines[3].starts_with("    <testcase"));
421        assert!(lines[4].starts_with("      <failure"));
422        assert_eq!(lines[5], "    </testcase>");
423        assert_eq!(lines[6], "  </testsuite>");
424        assert_eq!(lines[7], "</testsuites>");
425    }
426
427    #[test]
428    fn test_duration_formatting() {
429        let warnings = vec![(
430            "test.md".to_string(),
431            vec![LintWarning {
432                line: 1,
433                column: 1,
434                end_line: 1,
435                end_column: 5,
436                rule_name: Some("MD001".to_string()),
437                message: "Test".to_string(),
438                severity: Severity::Warning,
439                fix: None,
440            }],
441        )];
442
443        // Test various duration values
444        let output1 = format_junit_report(&warnings, 1234);
445        assert!(output1.contains("time=\"1.234\""));
446
447        let output2 = format_junit_report(&warnings, 500);
448        assert!(output2.contains("time=\"0.500\""));
449
450        let output3 = format_junit_report(&warnings, 12345);
451        assert!(output3.contains("time=\"12.345\""));
452    }
453}