1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use std::collections::HashMap;
6
7pub struct GroupedFormatter;
9
10impl Default for GroupedFormatter {
11 fn default() -> Self {
12 Self
13 }
14}
15
16impl GroupedFormatter {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl OutputFormatter for GroupedFormatter {
23 fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
24 if warnings.is_empty() {
25 return String::new();
26 }
27
28 let mut output = String::new();
29
30 let mut grouped: HashMap<&str, Vec<&LintWarning>> = HashMap::new();
32 for warning in warnings {
33 let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
34 grouped.entry(rule_name).or_default().push(warning);
35 }
36
37 output.push_str(&format!("{file_path}:\n"));
39
40 let mut rules: Vec<_> = grouped.keys().collect();
42 rules.sort();
43
44 for rule_name in rules {
45 let rule_warnings = &grouped[rule_name];
46 output.push_str(&format!(" {rule_name}:\n"));
47
48 for warning in rule_warnings {
49 output.push_str(&format!(" {}:{} {}", warning.line, warning.column, warning.message));
50 if warning.fix.is_some() {
51 output.push_str(" (fixable)");
52 }
53 output.push('\n');
54 }
55 }
56
57 if output.ends_with('\n') {
59 output.pop();
60 }
61
62 output
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use crate::rule::{Fix, Severity};
70
71 #[test]
72 fn test_grouped_formatter_default() {
73 let _formatter = GroupedFormatter;
74 }
76
77 #[test]
78 fn test_grouped_formatter_new() {
79 let _formatter = GroupedFormatter::new();
80 }
82
83 #[test]
84 fn test_format_warnings_empty() {
85 let formatter = GroupedFormatter::new();
86 let warnings = vec![];
87 let output = formatter.format_warnings(&warnings, "test.md");
88 assert_eq!(output, "");
89 }
90
91 #[test]
92 fn test_format_single_warning() {
93 let formatter = GroupedFormatter::new();
94 let warnings = vec![LintWarning {
95 line: 10,
96 column: 5,
97 end_line: 10,
98 end_column: 15,
99 rule_name: Some("MD001".to_string()),
100 message: "Heading levels should only increment by one level at a time".to_string(),
101 severity: Severity::Warning,
102 fix: None,
103 }];
104
105 let output = formatter.format_warnings(&warnings, "README.md");
106 let expected = "README.md:\n MD001:\n 10:5 Heading levels should only increment by one level at a time";
107 assert_eq!(output, expected);
108 }
109
110 #[test]
111 fn test_format_single_warning_with_fix() {
112 let formatter = GroupedFormatter::new();
113 let warnings = vec![LintWarning {
114 line: 10,
115 column: 5,
116 end_line: 10,
117 end_column: 15,
118 rule_name: Some("MD001".to_string()),
119 message: "Heading levels should only increment by one level at a time".to_string(),
120 severity: Severity::Warning,
121 fix: Some(Fix {
122 range: 100..110,
123 replacement: "## Heading".to_string(),
124 }),
125 }];
126
127 let output = formatter.format_warnings(&warnings, "README.md");
128 let expected =
129 "README.md:\n MD001:\n 10:5 Heading levels should only increment by one level at a time (fixable)";
130 assert_eq!(output, expected);
131 }
132
133 #[test]
134 fn test_format_multiple_warnings_same_rule() {
135 let formatter = GroupedFormatter::new();
136 let warnings = vec![
137 LintWarning {
138 line: 5,
139 column: 1,
140 end_line: 5,
141 end_column: 10,
142 rule_name: Some("MD001".to_string()),
143 message: "First violation".to_string(),
144 severity: Severity::Warning,
145 fix: None,
146 },
147 LintWarning {
148 line: 10,
149 column: 3,
150 end_line: 10,
151 end_column: 20,
152 rule_name: Some("MD001".to_string()),
153 message: "Second violation".to_string(),
154 severity: Severity::Warning,
155 fix: None,
156 },
157 ];
158
159 let output = formatter.format_warnings(&warnings, "test.md");
160 let expected = "test.md:\n MD001:\n 5:1 First violation\n 10:3 Second violation";
161 assert_eq!(output, expected);
162 }
163
164 #[test]
165 fn test_format_multiple_warnings_different_rules() {
166 let formatter = GroupedFormatter::new();
167 let warnings = vec![
168 LintWarning {
169 line: 5,
170 column: 1,
171 end_line: 5,
172 end_column: 10,
173 rule_name: Some("MD001".to_string()),
174 message: "Heading increment".to_string(),
175 severity: Severity::Warning,
176 fix: None,
177 },
178 LintWarning {
179 line: 10,
180 column: 3,
181 end_line: 10,
182 end_column: 20,
183 rule_name: Some("MD013".to_string()),
184 message: "Line too long".to_string(),
185 severity: Severity::Error,
186 fix: Some(Fix {
187 range: 50..60,
188 replacement: "fixed".to_string(),
189 }),
190 },
191 LintWarning {
192 line: 15,
193 column: 1,
194 end_line: 15,
195 end_column: 5,
196 rule_name: Some("MD001".to_string()),
197 message: "Another heading issue".to_string(),
198 severity: Severity::Warning,
199 fix: None,
200 },
201 ];
202
203 let output = formatter.format_warnings(&warnings, "test.md");
204 let expected = "test.md:\n MD001:\n 5:1 Heading increment\n 15:1 Another heading issue\n MD013:\n 10:3 Line too long (fixable)";
205 assert_eq!(output, expected);
206 }
207
208 #[test]
209 fn test_format_warning_unknown_rule() {
210 let formatter = GroupedFormatter::new();
211 let warnings = vec![LintWarning {
212 line: 1,
213 column: 1,
214 end_line: 1,
215 end_column: 5,
216 rule_name: None,
217 message: "Unknown rule warning".to_string(),
218 severity: Severity::Warning,
219 fix: None,
220 }];
221
222 let output = formatter.format_warnings(&warnings, "file.md");
223 let expected = "file.md:\n unknown:\n 1:1 Unknown rule warning";
224 assert_eq!(output, expected);
225 }
226
227 #[test]
228 fn test_rule_sorting() {
229 let formatter = GroupedFormatter::new();
230 let warnings = vec![
231 LintWarning {
232 line: 1,
233 column: 1,
234 end_line: 1,
235 end_column: 5,
236 rule_name: Some("MD010".to_string()),
237 message: "Hard tabs".to_string(),
238 severity: Severity::Warning,
239 fix: None,
240 },
241 LintWarning {
242 line: 2,
243 column: 1,
244 end_line: 2,
245 end_column: 5,
246 rule_name: Some("MD001".to_string()),
247 message: "Heading".to_string(),
248 severity: Severity::Warning,
249 fix: None,
250 },
251 LintWarning {
252 line: 3,
253 column: 1,
254 end_line: 3,
255 end_column: 5,
256 rule_name: Some("MD005".to_string()),
257 message: "List indent".to_string(),
258 severity: Severity::Warning,
259 fix: None,
260 },
261 ];
262
263 let output = formatter.format_warnings(&warnings, "test.md");
264 let lines: Vec<&str> = output.lines().collect();
265
266 assert_eq!(lines[1], " MD001:");
268 assert_eq!(lines[3], " MD005:");
269 assert_eq!(lines[5], " MD010:");
270 }
271
272 #[test]
273 fn test_edge_cases() {
274 let formatter = GroupedFormatter::new();
275
276 let warnings = vec![LintWarning {
278 line: 99999,
279 column: 12345,
280 end_line: 100000,
281 end_column: 12350,
282 rule_name: Some("MD999".to_string()),
283 message: "Edge case warning".to_string(),
284 severity: Severity::Error,
285 fix: None,
286 }];
287
288 let output = formatter.format_warnings(&warnings, "large.md");
289 let expected = "large.md:\n MD999:\n 99999:12345 Edge case warning";
290 assert_eq!(output, expected);
291 }
292
293 #[test]
294 fn test_special_characters_in_message() {
295 let formatter = GroupedFormatter::new();
296 let warnings = vec![LintWarning {
297 line: 1,
298 column: 1,
299 end_line: 1,
300 end_column: 5,
301 rule_name: Some("MD001".to_string()),
302 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
303 severity: Severity::Warning,
304 fix: None,
305 }];
306
307 let output = formatter.format_warnings(&warnings, "test.md");
308 let expected = "test.md:\n MD001:\n 1:1 Warning with \"quotes\" and 'apostrophes' and \n newline";
309 assert_eq!(output, expected);
310 }
311
312 #[test]
313 fn test_special_characters_in_file_path() {
314 let formatter = GroupedFormatter::new();
315 let warnings = vec![LintWarning {
316 line: 1,
317 column: 1,
318 end_line: 1,
319 end_column: 5,
320 rule_name: Some("MD001".to_string()),
321 message: "Test".to_string(),
322 severity: Severity::Warning,
323 fix: None,
324 }];
325
326 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
327 let expected = "path/with spaces/and-dashes.md:\n MD001:\n 1:1 Test";
328 assert_eq!(output, expected);
329 }
330
331 #[test]
332 fn test_mixed_fixable_unfixable() {
333 let formatter = GroupedFormatter::new();
334 let warnings = vec![
335 LintWarning {
336 line: 1,
337 column: 1,
338 end_line: 1,
339 end_column: 5,
340 rule_name: Some("MD001".to_string()),
341 message: "Not fixable".to_string(),
342 severity: Severity::Warning,
343 fix: None,
344 },
345 LintWarning {
346 line: 2,
347 column: 1,
348 end_line: 2,
349 end_column: 5,
350 rule_name: Some("MD001".to_string()),
351 message: "Fixable".to_string(),
352 severity: Severity::Warning,
353 fix: Some(Fix {
354 range: 10..20,
355 replacement: "fix".to_string(),
356 }),
357 },
358 LintWarning {
359 line: 3,
360 column: 1,
361 end_line: 3,
362 end_column: 5,
363 rule_name: Some("MD001".to_string()),
364 message: "Also not fixable".to_string(),
365 severity: Severity::Warning,
366 fix: None,
367 },
368 ];
369
370 let output = formatter.format_warnings(&warnings, "test.md");
371 let expected = "test.md:\n MD001:\n 1:1 Not fixable\n 2:1 Fixable (fixable)\n 3:1 Also not fixable";
372 assert_eq!(output, expected);
373 }
374
375 #[test]
376 fn test_severity_not_shown() {
377 let formatter = GroupedFormatter::new();
378
379 let warnings = vec![
381 LintWarning {
382 line: 1,
383 column: 1,
384 end_line: 1,
385 end_column: 5,
386 rule_name: Some("MD001".to_string()),
387 message: "Warning severity".to_string(),
388 severity: Severity::Warning,
389 fix: None,
390 },
391 LintWarning {
392 line: 2,
393 column: 1,
394 end_line: 2,
395 end_column: 5,
396 rule_name: Some("MD001".to_string()),
397 message: "Error severity".to_string(),
398 severity: Severity::Error,
399 fix: None,
400 },
401 ];
402
403 let output = formatter.format_warnings(&warnings, "test.md");
404 let expected = "test.md:\n MD001:\n 1:1 Warning severity\n 2:1 Error severity";
405 assert_eq!(output, expected);
406 }
407}