rumdl_lib/output/formatters/
concise.rs

1//! Concise output formatter for easy parsing by editors
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5
6/// Concise formatter: file:line:col: [RULE] message
7pub struct ConciseFormatter;
8
9impl Default for ConciseFormatter {
10    fn default() -> Self {
11        Self
12    }
13}
14
15impl ConciseFormatter {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl OutputFormatter for ConciseFormatter {
22    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
23        let mut output = String::new();
24
25        for warning in warnings {
26            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
27
28            // Simple format without colors: file:line:col: [RULE] message
29            let line = format!(
30                "{}:{}:{}: [{}] {}",
31                file_path, warning.line, warning.column, rule_name, warning.message
32            );
33
34            output.push_str(&line);
35            output.push('\n');
36        }
37
38        // Remove trailing newline
39        if output.ends_with('\n') {
40            output.pop();
41        }
42
43        output
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use crate::rule::{Fix, Severity};
51
52    #[test]
53    fn test_concise_formatter_default() {
54        let _formatter = ConciseFormatter;
55        // No fields to test, just ensure it constructs
56    }
57
58    #[test]
59    fn test_concise_formatter_new() {
60        let _formatter = ConciseFormatter::new();
61        // No fields to test, just ensure it constructs
62    }
63
64    #[test]
65    fn test_format_warnings_empty() {
66        let formatter = ConciseFormatter::new();
67        let warnings = vec![];
68        let output = formatter.format_warnings(&warnings, "test.md");
69        assert_eq!(output, "");
70    }
71
72    #[test]
73    fn test_format_single_warning() {
74        let formatter = ConciseFormatter::new();
75        let warnings = vec![LintWarning {
76            line: 10,
77            column: 5,
78            end_line: 10,
79            end_column: 15,
80            rule_name: Some("MD001".to_string()),
81            message: "Heading levels should only increment by one level at a time".to_string(),
82            severity: Severity::Warning,
83            fix: None,
84        }];
85
86        let output = formatter.format_warnings(&warnings, "README.md");
87        assert_eq!(
88            output,
89            "README.md:10:5: [MD001] Heading levels should only increment by one level at a time"
90        );
91    }
92
93    #[test]
94    fn test_format_warning_with_fix() {
95        let formatter = ConciseFormatter::new();
96        let warnings = vec![LintWarning {
97            line: 15,
98            column: 1,
99            end_line: 15,
100            end_column: 10,
101            rule_name: Some("MD022".to_string()),
102            message: "Headings should be surrounded by blank lines".to_string(),
103            severity: Severity::Warning,
104            fix: Some(Fix {
105                range: 100..110,
106                replacement: "\n# Heading\n".to_string(),
107            }),
108        }];
109
110        let output = formatter.format_warnings(&warnings, "doc.md");
111        // Concise format doesn't show fix indicator
112        assert_eq!(
113            output,
114            "doc.md:15:1: [MD022] Headings should be surrounded by blank lines"
115        );
116    }
117
118    #[test]
119    fn test_format_multiple_warnings() {
120        let formatter = ConciseFormatter::new();
121        let warnings = vec![
122            LintWarning {
123                line: 5,
124                column: 1,
125                end_line: 5,
126                end_column: 10,
127                rule_name: Some("MD001".to_string()),
128                message: "First warning".to_string(),
129                severity: Severity::Warning,
130                fix: None,
131            },
132            LintWarning {
133                line: 10,
134                column: 3,
135                end_line: 10,
136                end_column: 20,
137                rule_name: Some("MD013".to_string()),
138                message: "Second warning".to_string(),
139                severity: Severity::Error,
140                fix: Some(Fix {
141                    range: 50..60,
142                    replacement: "fixed".to_string(),
143                }),
144            },
145        ];
146
147        let output = formatter.format_warnings(&warnings, "test.md");
148        let expected = "test.md:5:1: [MD001] First warning\ntest.md:10:3: [MD013] Second warning";
149        assert_eq!(output, expected);
150    }
151
152    #[test]
153    fn test_format_warning_unknown_rule() {
154        let formatter = ConciseFormatter::new();
155        let warnings = vec![LintWarning {
156            line: 1,
157            column: 1,
158            end_line: 1,
159            end_column: 5,
160            rule_name: None,
161            message: "Unknown rule warning".to_string(),
162            severity: Severity::Warning,
163            fix: None,
164        }];
165
166        let output = formatter.format_warnings(&warnings, "file.md");
167        assert_eq!(output, "file.md:1:1: [unknown] Unknown rule warning");
168    }
169
170    #[test]
171    fn test_edge_cases() {
172        let formatter = ConciseFormatter::new();
173
174        // Test large line/column numbers
175        let warnings = vec![LintWarning {
176            line: 99999,
177            column: 12345,
178            end_line: 100000,
179            end_column: 12350,
180            rule_name: Some("MD999".to_string()),
181            message: "Edge case warning".to_string(),
182            severity: Severity::Error,
183            fix: None,
184        }];
185
186        let output = formatter.format_warnings(&warnings, "large.md");
187        assert_eq!(output, "large.md:99999:12345: [MD999] Edge case warning");
188    }
189
190    #[test]
191    fn test_special_characters_in_message() {
192        let formatter = ConciseFormatter::new();
193        let warnings = vec![LintWarning {
194            line: 1,
195            column: 1,
196            end_line: 1,
197            end_column: 5,
198            rule_name: Some("MD001".to_string()),
199            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
200            severity: Severity::Warning,
201            fix: None,
202        }];
203
204        let output = formatter.format_warnings(&warnings, "test.md");
205        assert_eq!(
206            output,
207            "test.md:1:1: [MD001] Warning with \"quotes\" and 'apostrophes' and \n newline"
208        );
209    }
210
211    #[test]
212    fn test_special_characters_in_file_path() {
213        let formatter = ConciseFormatter::new();
214        let warnings = vec![LintWarning {
215            line: 1,
216            column: 1,
217            end_line: 1,
218            end_column: 5,
219            rule_name: Some("MD001".to_string()),
220            message: "Test".to_string(),
221            severity: Severity::Warning,
222            fix: None,
223        }];
224
225        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
226        assert_eq!(output, "path/with spaces/and-dashes.md:1:1: [MD001] Test");
227    }
228
229    #[test]
230    fn test_concise_format_consistency() {
231        let formatter = ConciseFormatter::new();
232
233        // Test that the format is consistent with the expected pattern
234        let warnings = vec![
235            LintWarning {
236                line: 1,
237                column: 1,
238                end_line: 1,
239                end_column: 5,
240                rule_name: Some("MD001".to_string()),
241                message: "Test 1".to_string(),
242                severity: Severity::Warning,
243                fix: None,
244            },
245            LintWarning {
246                line: 2,
247                column: 2,
248                end_line: 2,
249                end_column: 6,
250                rule_name: Some("MD002".to_string()),
251                message: "Test 2".to_string(),
252                severity: Severity::Error,
253                fix: Some(Fix {
254                    range: 10..20,
255                    replacement: "fix".to_string(),
256                }),
257            },
258        ];
259
260        let output = formatter.format_warnings(&warnings, "test.md");
261        let lines: Vec<&str> = output.lines().collect();
262
263        assert_eq!(lines.len(), 2);
264
265        // Each line should follow the pattern: file:line:col: [RULE] message
266        for line in lines {
267            assert!(line.contains(":"));
268            assert!(line.contains(" [MD"));
269            assert!(line.contains("] "));
270        }
271    }
272
273    #[test]
274    fn test_severity_ignored() {
275        let formatter = ConciseFormatter::new();
276
277        // Test that severity doesn't affect output (concise format doesn't show severity)
278        let warnings = vec![
279            LintWarning {
280                line: 1,
281                column: 1,
282                end_line: 1,
283                end_column: 5,
284                rule_name: Some("MD001".to_string()),
285                message: "Warning severity".to_string(),
286                severity: Severity::Warning,
287                fix: None,
288            },
289            LintWarning {
290                line: 2,
291                column: 1,
292                end_line: 2,
293                end_column: 5,
294                rule_name: Some("MD002".to_string()),
295                message: "Error severity".to_string(),
296                severity: Severity::Error,
297                fix: None,
298            },
299        ];
300
301        let output = formatter.format_warnings(&warnings, "test.md");
302        let lines: Vec<&str> = output.lines().collect();
303
304        // Both should have same format regardless of severity
305        assert!(lines[0].starts_with("test.md:1:1: [MD001]"));
306        assert!(lines[1].starts_with("test.md:2:1: [MD002]"));
307    }
308}