Skip to main content

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.as_deref().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".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        // Verify rules are sorted alphabetically
261        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        // Test large line/column numbers
271        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        // Test that severity doesn't affect output
371        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}