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        // A single file is one testcase that passes (no warnings) or fails.
24        format_junit_report(
25            &[(file_path.to_string(), warnings.to_vec())],
26            &[file_path.to_string()],
27            0,
28        )
29    }
30}
31
32/// Format a JUnit XML report covering every checked file.
33///
34/// Each checked file is one `<testcase>` ("Lint <file>"): a clean file passes (no
35/// `<failure>` child), a file with warnings fails and carries one `<failure>` per
36/// warning. `all_files` lists every file that was checked (clean and dirty);
37/// `all_warnings` holds only the files that have warnings. Reporting all checked
38/// files means a clean run lists its passing files rather than emitting an empty
39/// report. Counts stay JUnit-consistent: `tests` is the number of checked files and
40/// `failures` the number of files with issues, so `failures <= tests` always holds.
41pub fn format_junit_report(
42    all_warnings: &[(String, Vec<LintWarning>)],
43    all_files: &[String],
44    duration_ms: u64,
45) -> String {
46    use std::collections::HashMap;
47    use std::collections::HashSet;
48
49    let warnings_by_file: HashMap<&str, &[LintWarning]> =
50        all_warnings.iter().map(|(p, w)| (p.as_str(), w.as_slice())).collect();
51
52    // Report every checked file once, in order; defensively include any warning-
53    // bearing file that is not in all_files so a warning is never dropped.
54    let mut files: Vec<&str> = Vec::with_capacity(all_files.len());
55    let mut seen: HashSet<&str> = HashSet::new();
56    for path in all_files {
57        if seen.insert(path.as_str()) {
58            files.push(path.as_str());
59        }
60    }
61    for (path, _) in all_warnings {
62        if seen.insert(path.as_str()) {
63            files.push(path.as_str());
64        }
65    }
66
67    let warnings_for = |file: &str| -> &[LintWarning] { warnings_by_file.get(file).copied().unwrap_or(&[]) };
68
69    let total_tests = files.len();
70    let files_with_issues = files.iter().filter(|f| !warnings_for(f).is_empty()).count();
71    let duration_secs = duration_ms as f64 / 1000.0;
72
73    let mut xml = String::new();
74    xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
75    xml.push('\n');
76
77    xml.push_str(&format!(
78        r#"<testsuites name="rumdl" tests="{total_tests}" failures="{files_with_issues}" errors="0" time="{duration_secs:.3}">"#
79    ));
80    xml.push('\n');
81
82    for file in files {
83        let warnings = warnings_for(file);
84        let failed = usize::from(!warnings.is_empty());
85        let escaped_file = xml_escape(file);
86
87        xml.push_str(&format!(
88            r#"  <testsuite name="{escaped_file}" tests="1" failures="{failed}" errors="0" time="0.000">"#
89        ));
90        xml.push('\n');
91
92        xml.push_str(&format!(
93            r#"    <testcase name="Lint {escaped_file}" classname="rumdl" time="0.000">"#
94        ));
95        xml.push('\n');
96
97        // A clean file's testcase has no <failure> children, so it passes.
98        for warning in warnings {
99            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
100            let message = xml_escape(&warning.message);
101
102            xml.push_str(&format!(
103                r#"      <failure type="{}" message="{}">{} at line {}, column {}</failure>"#,
104                rule_name, message, message, warning.line, warning.column
105            ));
106            xml.push('\n');
107        }
108
109        xml.push_str("    </testcase>\n");
110        xml.push_str("  </testsuite>\n");
111    }
112
113    xml.push_str("</testsuites>\n");
114    xml
115}
116
117/// Escape special XML characters
118fn xml_escape(s: &str) -> String {
119    let mut escaped = String::with_capacity(s.len());
120    for character in s.chars() {
121        match character {
122            '&' => escaped.push_str("&amp;"),
123            '<' => escaped.push_str("&lt;"),
124            '>' => escaped.push_str("&gt;"),
125            '"' => escaped.push_str("&quot;"),
126            '\'' => escaped.push_str("&apos;"),
127            // XML 1.0 permits tab, LF, CR, U+0020..U+D7FF,
128            // U+E000..U+FFFD, and supplementary Unicode characters.
129            '\u{9}'
130            | '\u{a}'
131            | '\u{d}'
132            | '\u{20}'..='\u{d7ff}'
133            | '\u{e000}'..='\u{fffd}'
134            | '\u{10000}'..='\u{10ffff}' => escaped.push(character),
135            _ => escaped.push('\u{fffd}'),
136        }
137    }
138    escaped
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::rule::{Fix, Severity};
145
146    fn warning(line: usize, column: usize, rule: &str, message: &str) -> LintWarning {
147        LintWarning {
148            line,
149            column,
150            end_line: line,
151            end_column: column,
152            rule_name: Some(rule.to_string()),
153            message: message.to_string(),
154            severity: Severity::Warning,
155            fix: None,
156        }
157    }
158
159    /// Count `<failure ...>` elements in the report.
160    fn count_failures(xml: &str) -> usize {
161        xml.matches("<failure ").count()
162    }
163
164    #[test]
165    fn test_junit_formatter_default_and_new() {
166        let _ = JunitFormatter;
167        let _ = JunitFormatter::new();
168    }
169
170    // ---- single-file path (OutputFormatter::format_warnings) -----------------
171
172    #[test]
173    fn test_format_warnings_empty_file_passes() {
174        // A clean file is a passing testcase: present, no <failure>, failures="0".
175        let output = JunitFormatter::new().format_warnings(&[], "test.md");
176
177        assert!(output.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
178        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"0\" errors=\"0\""));
179        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"0\" errors=\"0\" time=\"0.000\">"));
180        assert!(output.contains("<testcase name=\"Lint test.md\" classname=\"rumdl\" time=\"0.000\">"));
181        assert_eq!(count_failures(&output), 0);
182    }
183
184    #[test]
185    fn test_format_single_warning() {
186        let warnings = vec![warning(
187            10,
188            5,
189            "MD001",
190            "Heading levels should only increment by one level at a time",
191        )];
192        let output = JunitFormatter::new().format_warnings(&warnings, "README.md");
193
194        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\" errors=\"0\""));
195        assert!(
196            output.contains("<testsuite name=\"README.md\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">")
197        );
198        assert!(output.contains(
199            "<failure type=\"MD001\" message=\"Heading levels should only increment by one level at a time\">"
200        ));
201        assert!(output.contains("at line 10, column 5</failure>"));
202    }
203
204    #[test]
205    fn test_multiple_warnings_in_one_file_are_one_failed_testcase() {
206        // One file with two warnings is ONE failed testcase (failures="1"), with two
207        // <failure> children - not failures="2" (which would exceed tests="1").
208        let warnings = vec![
209            warning(5, 1, "MD001", "First warning"),
210            warning(10, 3, "MD013", "Second warning"),
211        ];
212        let output = JunitFormatter::new().format_warnings(&warnings, "test.md");
213
214        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\" errors=\"0\""));
215        assert!(output.contains("<testsuite name=\"test.md\" tests=\"1\" failures=\"1\" errors=\"0\" time=\"0.000\">"));
216        assert_eq!(count_failures(&output), 2);
217        assert!(output.contains("<failure type=\"MD001\" message=\"First warning\">"));
218        assert!(output.contains("<failure type=\"MD013\" message=\"Second warning\">"));
219    }
220
221    #[test]
222    fn test_format_single_warning_with_fix_has_no_fixable_marker() {
223        let mut w = warning(10, 5, "MD001", "Heading");
224        w.fix = Some(Fix::new(100..110, "## Heading".to_string()));
225        let output = JunitFormatter::new().format_warnings(&[w], "README.md");
226
227        assert!(output.contains("<failure type=\"MD001\""));
228        assert!(!output.contains("fixable"));
229    }
230
231    #[test]
232    fn test_format_warning_unknown_rule() {
233        let mut w = warning(1, 1, "MD001", "Unknown rule warning");
234        w.rule_name = None;
235        let output = JunitFormatter::new().format_warnings(&[w], "file.md");
236
237        assert!(output.contains("<failure type=\"unknown\" message=\"Unknown rule warning\">"));
238    }
239
240    // ---- batch report (format_junit_report) ----------------------------------
241
242    #[test]
243    fn test_report_no_files_is_empty() {
244        let output = format_junit_report(&[], &[], 1234);
245        assert!(output.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
246        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"0\" failures=\"0\" errors=\"0\" time=\"1.234\">"));
247        assert!(output.ends_with("</testsuites>\n"));
248    }
249
250    #[test]
251    fn test_report_all_clean_files_listed_as_passing() {
252        // The core of issue #654: a clean run lists its passing files, not an empty report.
253        let all_files = vec!["a.md".to_string(), "b.md".to_string()];
254        let output = format_junit_report(&[], &all_files, 500);
255
256        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"2\" failures=\"0\" errors=\"0\" time=\"0.500\">"));
257        assert!(output.contains("<testsuite name=\"a.md\" tests=\"1\" failures=\"0\""));
258        assert!(output.contains("<testsuite name=\"b.md\" tests=\"1\" failures=\"0\""));
259        assert!(output.contains("<testcase name=\"Lint a.md\""));
260        assert!(output.contains("<testcase name=\"Lint b.md\""));
261        assert_eq!(count_failures(&output), 0);
262    }
263
264    #[test]
265    fn test_report_mixed_clean_and_dirty() {
266        let all_files = vec!["clean.md".to_string(), "dirty.md".to_string(), "clean2.md".to_string()];
267        let all_warnings = vec![(
268            "dirty.md".to_string(),
269            vec![
270                warning(1, 2, "MD018", "No space after #"),
271                warning(3, 1, "MD012", "Blank lines"),
272            ],
273        )];
274        let output = format_junit_report(&all_warnings, &all_files, 0);
275
276        // 3 files checked, 1 failed file => failures <= tests.
277        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"3\" failures=\"1\" errors=\"0\""));
278        assert!(output.contains("<testsuite name=\"clean.md\" tests=\"1\" failures=\"0\""));
279        assert!(output.contains("<testsuite name=\"clean2.md\" tests=\"1\" failures=\"0\""));
280        assert!(output.contains("<testsuite name=\"dirty.md\" tests=\"1\" failures=\"1\""));
281        // The dirty file carries both warnings; clean files carry none.
282        assert_eq!(count_failures(&output), 2);
283    }
284
285    #[test]
286    fn test_report_failures_never_exceed_tests() {
287        // A file with many warnings is still one failed testcase.
288        let all_files = vec!["x.md".to_string()];
289        let warnings: Vec<LintWarning> = (1..=5).map(|i| warning(i, 1, "MD013", "long line")).collect();
290        let output = format_junit_report(&[("x.md".to_string(), warnings)], &all_files, 0);
291
292        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\""));
293        assert!(output.contains("<testsuite name=\"x.md\" tests=\"1\" failures=\"1\""));
294        assert_eq!(count_failures(&output), 5);
295    }
296
297    #[test]
298    fn test_report_includes_warning_file_absent_from_all_files() {
299        // Defensive: a warning-bearing file not present in all_files is still reported.
300        let output = format_junit_report(&[("orphan.md".to_string(), vec![warning(1, 1, "MD001", "x")])], &[], 0);
301        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\""));
302        assert!(output.contains("<testsuite name=\"orphan.md\""));
303    }
304
305    #[test]
306    fn test_report_deduplicates_files() {
307        // A file appearing in both all_files and all_warnings is rendered once.
308        let all_files = vec!["dup.md".to_string()];
309        let all_warnings = vec![("dup.md".to_string(), vec![warning(1, 1, "MD001", "x")])];
310        let output = format_junit_report(&all_warnings, &all_files, 0);
311
312        assert_eq!(output.matches("<testsuite name=\"dup.md\"").count(), 1);
313        assert!(output.contains("<testsuites name=\"rumdl\" tests=\"1\" failures=\"1\""));
314    }
315
316    #[test]
317    fn test_duration_formatting() {
318        let files = vec!["test.md".to_string()];
319        let warnings = vec![("test.md".to_string(), vec![warning(1, 1, "MD001", "x")])];
320        assert!(format_junit_report(&warnings, &files, 1234).contains("time=\"1.234\""));
321        assert!(format_junit_report(&warnings, &files, 500).contains("time=\"0.500\""));
322        assert!(format_junit_report(&warnings, &files, 12345).contains("time=\"12.345\""));
323    }
324
325    // ---- escaping & structure ------------------------------------------------
326
327    #[test]
328    fn test_xml_escape() {
329        assert_eq!(xml_escape("normal text"), "normal text");
330        assert_eq!(xml_escape("text with & ampersand"), "text with &amp; ampersand");
331        assert_eq!(xml_escape("text with < and >"), "text with &lt; and &gt;");
332        assert_eq!(xml_escape("text with \" quotes"), "text with &quot; quotes");
333        assert_eq!(xml_escape("text with ' apostrophe"), "text with &apos; apostrophe");
334        assert_eq!(xml_escape("all: < > & \" '"), "all: &lt; &gt; &amp; &quot; &apos;");
335    }
336
337    #[test]
338    fn test_special_characters_in_message() {
339        let warnings = vec![warning(1, 1, "MD001", "Warning with < > & \" ' special chars")];
340        let output = JunitFormatter::new().format_warnings(&warnings, "test.md");
341
342        assert!(output.contains("message=\"Warning with &lt; &gt; &amp; &quot; &apos; special chars\""));
343        assert!(output.contains(">Warning with &lt; &gt; &amp; &quot; &apos; special chars at line"));
344    }
345
346    #[test]
347    fn test_special_characters_in_file_path() {
348        let warnings = vec![warning(1, 1, "MD001", "Test")];
349        let output = JunitFormatter::new().format_warnings(&warnings, "path/with<special>&chars.md");
350
351        assert!(output.contains("<testsuite name=\"path/with&lt;special&gt;&amp;chars.md\""));
352        assert!(output.contains("<testcase name=\"Lint path/with&lt;special&gt;&amp;chars.md\""));
353    }
354
355    #[test]
356    fn test_xml_structure_nesting() {
357        let warnings = vec![warning(1, 1, "MD001", "Test")];
358        let output = JunitFormatter::new().format_warnings(&warnings, "test.md");
359
360        let lines: Vec<&str> = output.lines().collect();
361        assert_eq!(lines[0], "<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
362        assert!(lines[1].starts_with("<testsuites"));
363        assert!(lines[2].starts_with("  <testsuite"));
364        assert!(lines[3].starts_with("    <testcase"));
365        assert!(lines[4].starts_with("      <failure"));
366        assert_eq!(lines[5], "    </testcase>");
367        assert_eq!(lines[6], "  </testsuite>");
368        assert_eq!(lines[7], "</testsuites>");
369    }
370}