rumdl_lib/output/formatters/
junit.rs1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5
6pub 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_junit_report(
25 &[(file_path.to_string(), warnings.to_vec())],
26 &[file_path.to_string()],
27 0,
28 )
29 }
30}
31
32pub 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 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 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
117fn 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("&"),
123 '<' => escaped.push_str("<"),
124 '>' => escaped.push_str(">"),
125 '"' => escaped.push_str("""),
126 '\'' => escaped.push_str("'"),
127 '\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 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 #[test]
173 fn test_format_warnings_empty_file_passes() {
174 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 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 #[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 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 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 assert_eq!(count_failures(&output), 2);
283 }
284
285 #[test]
286 fn test_report_failures_never_exceed_tests() {
287 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 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 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 #[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 & ampersand");
331 assert_eq!(xml_escape("text with < and >"), "text with < and >");
332 assert_eq!(xml_escape("text with \" quotes"), "text with " quotes");
333 assert_eq!(xml_escape("text with ' apostrophe"), "text with ' apostrophe");
334 assert_eq!(xml_escape("all: < > & \" '"), "all: < > & " '");
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 < > & " ' special chars\""));
343 assert!(output.contains(">Warning with < > & " ' 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<special>&chars.md\""));
352 assert!(output.contains("<testcase name=\"Lint path/with<special>&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}