1use 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 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 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
68pub 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 let total_issues: usize = all_warnings.iter().map(|(_, w)| w.len()).sum();
76 let files_with_issues = all_warnings.len();
77
78 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 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 xml.push_str(&format!(
99 r#" <testcase name="Lint {escaped_file}" classname="rumdl" time="0.000">"#
100 ));
101 xml.push('\n');
102
103 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
123fn xml_escape(s: &str) -> String {
125 s.replace('&', "&")
126 .replace('<', "<")
127 .replace('>', ">")
128 .replace('"', """)
129 .replace('\'', "'")
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 }
142
143 #[test]
144 fn test_junit_formatter_new() {
145 let _formatter = JunitFormatter::new();
146 }
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 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 & ampersand");
270 assert_eq!(xml_escape("text with < and >"), "text with < and >");
271 assert_eq!(xml_escape("text with \" quotes"), "text with " quotes");
272 assert_eq!(xml_escape("text with ' apostrophe"), "text with ' apostrophe");
273 assert_eq!(xml_escape("all: < > & \" '"), "all: < > & " '");
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 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 < > & " ' special chars\""));
376 assert!(output.contains(">Warning with < > & " ' 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<special>&chars.md\""));
396 assert!(output.contains("<testcase name=\"Lint path/with<special>&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 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 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}