rumdl_lib/output/formatters/
grouped.rs

1//! Grouped output formatter that groups violations by file
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use std::collections::HashMap;
6
7/// Grouped formatter: groups violations by file
8pub 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        // Group warnings by their rule name
31        let mut grouped: HashMap<&str, Vec<&LintWarning>> = HashMap::new();
32        for warning in warnings {
33            let rule_name = warning.rule_name.unwrap_or("unknown");
34            grouped.entry(rule_name).or_default().push(warning);
35        }
36
37        // Output file header
38        output.push_str(&format!("{file_path}:\n"));
39
40        // Sort rules for consistent output
41        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        // Remove trailing newline
58        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        // No fields to test, just ensure it constructs
75    }
76
77    #[test]
78    fn test_grouped_formatter_new() {
79        let _formatter = GroupedFormatter::new();
80        // No fields to test, just ensure it constructs
81    }
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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"),
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        // Verify rules are sorted alphabetically
267        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        // Test large line/column numbers
277        let warnings = vec![LintWarning {
278            line: 99999,
279            column: 12345,
280            end_line: 100000,
281            end_column: 12350,
282            rule_name: Some("MD999"),
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"),
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"),
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"),
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"),
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"),
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        // Test that severity doesn't affect output
380        let warnings = vec![
381            LintWarning {
382                line: 1,
383                column: 1,
384                end_line: 1,
385                end_column: 5,
386                rule_name: Some("MD001"),
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"),
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}