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 {
202 range: 100..110,
203 replacement: "## Heading".to_string(),
204 }),
205 }];
206
207 let output = formatter.format_warnings(&warnings, "README.md");
208
209 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 & ampersand");
273 assert_eq!(xml_escape("text with < and >"), "text with < and >");
274 assert_eq!(xml_escape("text with \" quotes"), "text with " quotes");
275 assert_eq!(xml_escape("text with ' apostrophe"), "text with ' apostrophe");
276 assert_eq!(xml_escape("all: < > & \" '"), "all: < > & " '");
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 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 < > & " ' special chars\""));
379 assert!(output.contains(">Warning with < > & " ' 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<special>&chars.md\""));
399 assert!(output.contains("<testcase name=\"Lint path/with<special>&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 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 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}