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 {
202                range: 100..110,
203                replacement: "## Heading".to_string(),
204            }),
205        }];
206
207        let output = formatter.format_warnings(&warnings, "README.md");
208
209        // JUnit format doesn't indicate fixable issues
210        assert!(output.contains("<failure type=\"MD001\""));
211        assert!(!output.contains("fixable"));
212    }
213
214    #[test]
215    fn test_format_multiple_warnings() {
216        let formatter = JunitFormatter::new();
217        let warnings = vec![
218            LintWarning {
219                line: 5,
220                column: 1,
221                end_line: 5,
222                end_column: 10,
223                rule_name: Some("MD001".to_string()),
224                message: "First warning".to_string(),
225                severity: Severity::Warning,
226                fix: None,
227            },
228            LintWarning {
229                line: 10,
230                column: 3,
231                end_line: 10,
232                end_column: 20,
233                rule_name: Some("MD013".to_string()),
234                message: "Second warning".to_string(),
235                severity: Severity::Error,
236                fix: None,
237            },
238        ];
239
240        let output = formatter.format_warnings(&warnings, "test.md");
241
242        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"2\" errors=\"0\" time=\"0.000\">"));
243        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"2\" errors=\"0\" time=\"0.000\">"));
244        assert!(output.contains("<failure type=\"MD001\" message=\"First warning\">"));
245        assert!(output.contains("at line 5, column 1</failure>"));
246        assert!(output.contains("<failure type=\"MD013\" message=\"Second warning\">"));
247        assert!(output.contains("at line 10, column 3</failure>"));
248    }
249
250    #[test]
251    fn test_format_warning_unknown_rule() {
252        let formatter = JunitFormatter::new();
253        let warnings = vec![LintWarning {
254            line: 1,
255            column: 1,
256            end_line: 1,
257            end_column: 5,
258            rule_name: None,
259            message: "Unknown rule warning".to_string(),
260            severity: Severity::Warning,
261            fix: None,
262        }];
263
264        let output = formatter.format_warnings(&warnings, "file.md");
265
266        assert!(output.contains("<failure type=\"unknown\" message=\"Unknown rule warning\">"));
267    }
268
269    #[test]
270    fn test_xml_escape() {
271        assert_eq!(xml_escape("normal text"), "normal text");
272        assert_eq!(xml_escape("text with & ampersand"), "text with &amp; ampersand");
273        assert_eq!(xml_escape("text with < and >"), "text with &lt; and &gt;");
274        assert_eq!(xml_escape("text with \" quotes"), "text with &quot; quotes");
275        assert_eq!(xml_escape("text with ' apostrophe"), "text with &apos; apostrophe");
276        assert_eq!(xml_escape("all: < > & \" '"), "all: &lt; &gt; &amp; &quot; &apos;");
277    }
278
279    #[test]
280    fn test_junit_report_empty() {
281        let warnings = vec![];
282        let output = format_junit_report(&warnings, 1234);
283
284        assert!(output.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
285        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"0\" failures=\"0\" errors=\"0\" time=\"1.234\">"));
286        assert!(output.ends_with("</testsuites>\n"));
287    }
288
289    #[test]
290    fn test_junit_report_single_file() {
291        let warnings = vec![(
292            "test.md".to_string(),
293            vec![LintWarning {
294                line: 10,
295                column: 5,
296                end_line: 10,
297                end_column: 15,
298                rule_name: Some("MD001".to_string()),
299                message: "Test warning".to_string(),
300                severity: Severity::Warning,
301                fix: None,
302            }],
303        )];
304
305        let output = format_junit_report(&warnings, 500);
306
307        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.500\">"));
308        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">"));
309    }
310
311    #[test]
312    fn test_junit_report_multiple_files() {
313        let warnings = vec![
314            (
315                "file1.md".to_string(),
316                vec![LintWarning {
317                    line: 1,
318                    column: 1,
319                    end_line: 1,
320                    end_column: 5,
321                    rule_name: Some("MD001".to_string()),
322                    message: "Warning in file 1".to_string(),
323                    severity: Severity::Warning,
324                    fix: None,
325                }],
326            ),
327            (
328                "file2.md".to_string(),
329                vec![
330                    LintWarning {
331                        line: 5,
332                        column: 1,
333                        end_line: 5,
334                        end_column: 10,
335                        rule_name: Some("MD013".to_string()),
336                        message: "Warning 1 in file 2".to_string(),
337                        severity: Severity::Warning,
338                        fix: None,
339                    },
340                    LintWarning {
341                        line: 10,
342                        column: 1,
343                        end_line: 10,
344                        end_column: 10,
345                        rule_name: Some("MD022".to_string()),
346                        message: "Warning 2 in file 2".to_string(),
347                        severity: Severity::Error,
348                        fix: None,
349                    },
350                ],
351            ),
352        ];
353
354        let output = format_junit_report(&warnings, 2500);
355
356        // Total: 2 files, 3 failures
357        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"2\" failures=\"3\" errors=\"0\" time=\"2.500\">"));
358        assert!(output.contains("<testsuite name=\"file1.md\" tests=\"1\" failures=\"1\""));
359        assert!(output.contains("<testsuite name=\"file2.md\" tests=\"1\" failures=\"2\""));
360    }
361
362    #[test]
363    fn test_special_characters_in_message() {
364        let formatter = JunitFormatter::new();
365        let warnings = vec![LintWarning {
366            line: 1,
367            column: 1,
368            end_line: 1,
369            end_column: 5,
370            rule_name: Some("MD001".to_string()),
371            message: "Warning with < > & \" ' special chars".to_string(),
372            severity: Severity::Warning,
373            fix: None,
374        }];
375
376        let output = formatter.format_warnings(&warnings, "test.md");
377
378        assert!(output.contains("message=\"Warning with &lt; &gt; &amp; &quot; &apos; special chars\""));
379        assert!(output.contains(">Warning with &lt; &gt; &amp; &quot; &apos; special chars at line"));
380    }
381
382    #[test]
383    fn test_special_characters_in_file_path() {
384        let formatter = JunitFormatter::new();
385        let warnings = vec![LintWarning {
386            line: 1,
387            column: 1,
388            end_line: 1,
389            end_column: 5,
390            rule_name: Some("MD001".to_string()),
391            message: "Test".to_string(),
392            severity: Severity::Warning,
393            fix: None,
394        }];
395
396        let output = formatter.format_warnings(&warnings, "path/with<special>&chars.md");
397
398        assert!(output.contains("<testsuite name=\"path/with&lt;special&gt;&amp;chars.md\""));
399        assert!(output.contains("<testcase name=\"Lint path/with&lt;special&gt;&amp;chars.md\""));
400    }
401
402    #[test]
403    fn test_xml_structure() {
404        let formatter = JunitFormatter::new();
405        let warnings = vec![LintWarning {
406            line: 1,
407            column: 1,
408            end_line: 1,
409            end_column: 5,
410            rule_name: Some("MD001".to_string()),
411            message: "Test".to_string(),
412            severity: Severity::Warning,
413            fix: None,
414        }];
415
416        let output = formatter.format_warnings(&warnings, "test.md");
417
418        // Verify XML structure is properly nested
419        let lines: Vec<&str> = output.lines().collect();
420        assert_eq!(lines[0], "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
421        assert!(lines[1].starts_with("<testsuites"));
422        assert!(lines[2].starts_with("  <testsuite"));
423        assert!(lines[3].starts_with("    <testcase"));
424        assert!(lines[4].starts_with("      <failure"));
425        assert_eq!(lines[5], "    </testcase>");
426        assert_eq!(lines[6], "  </testsuite>");
427        assert_eq!(lines[7], "</testsuites>");
428    }
429
430    #[test]
431    fn test_duration_formatting() {
432        let warnings = vec![(
433            "test.md".to_string(),
434            vec![LintWarning {
435                line: 1,
436                column: 1,
437                end_line: 1,
438                end_column: 5,
439                rule_name: Some("MD001".to_string()),
440                message: "Test".to_string(),
441                severity: Severity::Warning,
442                fix: None,
443            }],
444        )];
445
446        // Test various duration values
447        let output1 = format_junit_report(&warnings, 1234);
448        assert!(output1.contains("time=\"1.234\""));
449
450        let output2 = format_junit_report(&warnings, 500);
451        assert!(output2.contains("time=\"0.500\""));
452
453        let output3 = format_junit_report(&warnings, 12345);
454        assert!(output3.contains("time=\"12.345\""));
455    }
456}