Skip to main content

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::new(100..110, "\n# Heading\n".to_string())),
105        }];
106
107        let output = formatter.format_warnings(&warnings, "doc.md");
108        // Concise format doesn't show fix indicator
109        assert_eq!(
110            output,
111            "doc.md:15:1: [MD022] Headings should be surrounded by blank lines"
112        );
113    }
114
115    #[test]
116    fn test_format_multiple_warnings() {
117        let formatter = ConciseFormatter::new();
118        let warnings = vec![
119            LintWarning {
120                line: 5,
121                column: 1,
122                end_line: 5,
123                end_column: 10,
124                rule_name: Some("MD001".to_string()),
125                message: "First warning".to_string(),
126                severity: Severity::Warning,
127                fix: None,
128            },
129            LintWarning {
130                line: 10,
131                column: 3,
132                end_line: 10,
133                end_column: 20,
134                rule_name: Some("MD013".to_string()),
135                message: "Second warning".to_string(),
136                severity: Severity::Error,
137                fix: Some(Fix::new(50..60, "fixed".to_string())),
138            },
139        ];
140
141        let output = formatter.format_warnings(&warnings, "test.md");
142        let expected = "test.md:5:1: [MD001] First warning\ntest.md:10:3: [MD013] Second warning";
143        assert_eq!(output, expected);
144    }
145
146    #[test]
147    fn test_format_warning_unknown_rule() {
148        let formatter = ConciseFormatter::new();
149        let warnings = vec![LintWarning {
150            line: 1,
151            column: 1,
152            end_line: 1,
153            end_column: 5,
154            rule_name: None,
155            message: "Unknown rule warning".to_string(),
156            severity: Severity::Warning,
157            fix: None,
158        }];
159
160        let output = formatter.format_warnings(&warnings, "file.md");
161        assert_eq!(output, "file.md:1:1: [unknown] Unknown rule warning");
162    }
163
164    #[test]
165    fn test_edge_cases() {
166        let formatter = ConciseFormatter::new();
167
168        // Test large line/column numbers
169        let warnings = vec![LintWarning {
170            line: 99999,
171            column: 12345,
172            end_line: 100000,
173            end_column: 12350,
174            rule_name: Some("MD999".to_string()),
175            message: "Edge case warning".to_string(),
176            severity: Severity::Error,
177            fix: None,
178        }];
179
180        let output = formatter.format_warnings(&warnings, "large.md");
181        assert_eq!(output, "large.md:99999:12345: [MD999] Edge case warning");
182    }
183
184    #[test]
185    fn test_special_characters_in_message() {
186        let formatter = ConciseFormatter::new();
187        let warnings = vec![LintWarning {
188            line: 1,
189            column: 1,
190            end_line: 1,
191            end_column: 5,
192            rule_name: Some("MD001".to_string()),
193            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
194            severity: Severity::Warning,
195            fix: None,
196        }];
197
198        let output = formatter.format_warnings(&warnings, "test.md");
199        assert_eq!(
200            output,
201            "test.md:1:1: [MD001] Warning with \"quotes\" and 'apostrophes' and \n newline"
202        );
203    }
204
205    #[test]
206    fn test_special_characters_in_file_path() {
207        let formatter = ConciseFormatter::new();
208        let warnings = vec![LintWarning {
209            line: 1,
210            column: 1,
211            end_line: 1,
212            end_column: 5,
213            rule_name: Some("MD001".to_string()),
214            message: "Test".to_string(),
215            severity: Severity::Warning,
216            fix: None,
217        }];
218
219        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
220        assert_eq!(output, "path/with spaces/and-dashes.md:1:1: [MD001] Test");
221    }
222
223    #[test]
224    fn test_concise_format_consistency() {
225        let formatter = ConciseFormatter::new();
226
227        // Test that the format is consistent with the expected pattern
228        let warnings = vec![
229            LintWarning {
230                line: 1,
231                column: 1,
232                end_line: 1,
233                end_column: 5,
234                rule_name: Some("MD001".to_string()),
235                message: "Test 1".to_string(),
236                severity: Severity::Warning,
237                fix: None,
238            },
239            LintWarning {
240                line: 2,
241                column: 2,
242                end_line: 2,
243                end_column: 6,
244                rule_name: Some("MD002".to_string()),
245                message: "Test 2".to_string(),
246                severity: Severity::Error,
247                fix: Some(Fix::new(10..20, "fix".to_string())),
248            },
249        ];
250
251        let output = formatter.format_warnings(&warnings, "test.md");
252        let lines: Vec<&str> = output.lines().collect();
253
254        assert_eq!(lines.len(), 2);
255
256        // Each line should follow the pattern: file:line:col: [RULE] message
257        for line in lines {
258            assert!(line.contains(':'));
259            assert!(line.contains(" [MD"));
260            assert!(line.contains("] "));
261        }
262    }
263
264    #[test]
265    fn test_severity_ignored() {
266        let formatter = ConciseFormatter::new();
267
268        // Test that severity doesn't affect output (concise format doesn't show severity)
269        let warnings = vec![
270            LintWarning {
271                line: 1,
272                column: 1,
273                end_line: 1,
274                end_column: 5,
275                rule_name: Some("MD001".to_string()),
276                message: "Warning severity".to_string(),
277                severity: Severity::Warning,
278                fix: None,
279            },
280            LintWarning {
281                line: 2,
282                column: 1,
283                end_line: 2,
284                end_column: 5,
285                rule_name: Some("MD002".to_string()),
286                message: "Error severity".to_string(),
287                severity: Severity::Error,
288                fix: None,
289            },
290        ];
291
292        let output = formatter.format_warnings(&warnings, "test.md");
293        let lines: Vec<&str> = output.lines().collect();
294
295        // Both should have same format regardless of severity
296        assert!(lines[0].starts_with("test.md:1:1: [MD001]"));
297        assert!(lines[1].starts_with("test.md:2:1: [MD002]"));
298    }
299}