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::new(100..110, "## Heading".to_string())),
122 }];
123
124 let output = formatter.format_warnings(&warnings, "README.md");
125 let expected =
126 "README.md:\n MD001:\n 10:5 Heading levels should only increment by one level at a time (fixable)";
127 assert_eq!(output, expected);
128 }
129
130 #[test]
131 fn test_format_multiple_warnings_same_rule() {
132 let formatter = GroupedFormatter::new();
133 let warnings = vec![
134 LintWarning {
135 line: 5,
136 column: 1,
137 end_line: 5,
138 end_column: 10,
139 rule_name: Some("MD001".to_string()),
140 message: "First violation".to_string(),
141 severity: Severity::Warning,
142 fix: None,
143 },
144 LintWarning {
145 line: 10,
146 column: 3,
147 end_line: 10,
148 end_column: 20,
149 rule_name: Some("MD001".to_string()),
150 message: "Second violation".to_string(),
151 severity: Severity::Warning,
152 fix: None,
153 },
154 ];
155
156 let output = formatter.format_warnings(&warnings, "test.md");
157 let expected = "test.md:\n MD001:\n 5:1 First violation\n 10:3 Second violation";
158 assert_eq!(output, expected);
159 }
160
161 #[test]
162 fn test_format_multiple_warnings_different_rules() {
163 let formatter = GroupedFormatter::new();
164 let warnings = vec![
165 LintWarning {
166 line: 5,
167 column: 1,
168 end_line: 5,
169 end_column: 10,
170 rule_name: Some("MD001".to_string()),
171 message: "Heading increment".to_string(),
172 severity: Severity::Warning,
173 fix: None,
174 },
175 LintWarning {
176 line: 10,
177 column: 3,
178 end_line: 10,
179 end_column: 20,
180 rule_name: Some("MD013".to_string()),
181 message: "Line too long".to_string(),
182 severity: Severity::Error,
183 fix: Some(Fix::new(50..60, "fixed".to_string())),
184 },
185 LintWarning {
186 line: 15,
187 column: 1,
188 end_line: 15,
189 end_column: 5,
190 rule_name: Some("MD001".to_string()),
191 message: "Another heading issue".to_string(),
192 severity: Severity::Warning,
193 fix: None,
194 },
195 ];
196
197 let output = formatter.format_warnings(&warnings, "test.md");
198 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)";
199 assert_eq!(output, expected);
200 }
201
202 #[test]
203 fn test_format_warning_unknown_rule() {
204 let formatter = GroupedFormatter::new();
205 let warnings = vec![LintWarning {
206 line: 1,
207 column: 1,
208 end_line: 1,
209 end_column: 5,
210 rule_name: None,
211 message: "Unknown rule warning".to_string(),
212 severity: Severity::Warning,
213 fix: None,
214 }];
215
216 let output = formatter.format_warnings(&warnings, "file.md");
217 let expected = "file.md:\n unknown:\n 1:1 Unknown rule warning";
218 assert_eq!(output, expected);
219 }
220
221 #[test]
222 fn test_rule_sorting() {
223 let formatter = GroupedFormatter::new();
224 let warnings = vec![
225 LintWarning {
226 line: 1,
227 column: 1,
228 end_line: 1,
229 end_column: 5,
230 rule_name: Some("MD010".to_string()),
231 message: "Hard tabs".to_string(),
232 severity: Severity::Warning,
233 fix: None,
234 },
235 LintWarning {
236 line: 2,
237 column: 1,
238 end_line: 2,
239 end_column: 5,
240 rule_name: Some("MD001".to_string()),
241 message: "Heading".to_string(),
242 severity: Severity::Warning,
243 fix: None,
244 },
245 LintWarning {
246 line: 3,
247 column: 1,
248 end_line: 3,
249 end_column: 5,
250 rule_name: Some("MD005".to_string()),
251 message: "List indent".to_string(),
252 severity: Severity::Warning,
253 fix: None,
254 },
255 ];
256
257 let output = formatter.format_warnings(&warnings, "test.md");
258 let lines: Vec<&str> = output.lines().collect();
259
260 assert_eq!(lines[1], " MD001:");
262 assert_eq!(lines[3], " MD005:");
263 assert_eq!(lines[5], " MD010:");
264 }
265
266 #[test]
267 fn test_edge_cases() {
268 let formatter = GroupedFormatter::new();
269
270 let warnings = vec![LintWarning {
272 line: 99999,
273 column: 12345,
274 end_line: 100000,
275 end_column: 12350,
276 rule_name: Some("MD999".to_string()),
277 message: "Edge case warning".to_string(),
278 severity: Severity::Error,
279 fix: None,
280 }];
281
282 let output = formatter.format_warnings(&warnings, "large.md");
283 let expected = "large.md:\n MD999:\n 99999:12345 Edge case warning";
284 assert_eq!(output, expected);
285 }
286
287 #[test]
288 fn test_special_characters_in_message() {
289 let formatter = GroupedFormatter::new();
290 let warnings = vec![LintWarning {
291 line: 1,
292 column: 1,
293 end_line: 1,
294 end_column: 5,
295 rule_name: Some("MD001".to_string()),
296 message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
297 severity: Severity::Warning,
298 fix: None,
299 }];
300
301 let output = formatter.format_warnings(&warnings, "test.md");
302 let expected = "test.md:\n MD001:\n 1:1 Warning with \"quotes\" and 'apostrophes' and \n newline";
303 assert_eq!(output, expected);
304 }
305
306 #[test]
307 fn test_special_characters_in_file_path() {
308 let formatter = GroupedFormatter::new();
309 let warnings = vec![LintWarning {
310 line: 1,
311 column: 1,
312 end_line: 1,
313 end_column: 5,
314 rule_name: Some("MD001".to_string()),
315 message: "Test".to_string(),
316 severity: Severity::Warning,
317 fix: None,
318 }];
319
320 let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
321 let expected = "path/with spaces/and-dashes.md:\n MD001:\n 1:1 Test";
322 assert_eq!(output, expected);
323 }
324
325 #[test]
326 fn test_mixed_fixable_unfixable() {
327 let formatter = GroupedFormatter::new();
328 let warnings = vec![
329 LintWarning {
330 line: 1,
331 column: 1,
332 end_line: 1,
333 end_column: 5,
334 rule_name: Some("MD001".to_string()),
335 message: "Not fixable".to_string(),
336 severity: Severity::Warning,
337 fix: None,
338 },
339 LintWarning {
340 line: 2,
341 column: 1,
342 end_line: 2,
343 end_column: 5,
344 rule_name: Some("MD001".to_string()),
345 message: "Fixable".to_string(),
346 severity: Severity::Warning,
347 fix: Some(Fix::new(10..20, "fix".to_string())),
348 },
349 LintWarning {
350 line: 3,
351 column: 1,
352 end_line: 3,
353 end_column: 5,
354 rule_name: Some("MD001".to_string()),
355 message: "Also not fixable".to_string(),
356 severity: Severity::Warning,
357 fix: None,
358 },
359 ];
360
361 let output = formatter.format_warnings(&warnings, "test.md");
362 let expected = "test.md:\n MD001:\n 1:1 Not fixable\n 2:1 Fixable (fixable)\n 3:1 Also not fixable";
363 assert_eq!(output, expected);
364 }
365
366 #[test]
367 fn test_severity_not_shown() {
368 let formatter = GroupedFormatter::new();
369
370 let warnings = vec![
372 LintWarning {
373 line: 1,
374 column: 1,
375 end_line: 1,
376 end_column: 5,
377 rule_name: Some("MD001".to_string()),
378 message: "Warning severity".to_string(),
379 severity: Severity::Warning,
380 fix: None,
381 },
382 LintWarning {
383 line: 2,
384 column: 1,
385 end_line: 2,
386 end_column: 5,
387 rule_name: Some("MD001".to_string()),
388 message: "Error severity".to_string(),
389 severity: Severity::Error,
390 fix: None,
391 },
392 ];
393
394 let output = formatter.format_warnings(&warnings, "test.md");
395 let expected = "test.md:\n MD001:\n 1:1 Warning severity\n 2:1 Error severity";
396 assert_eq!(output, expected);
397 }
398}