rumdl_lib/output/formatters/
text.rs

1//! Default text output formatter with colors and context
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use colored::*;
6
7/// Default human-readable formatter with colors
8pub struct TextFormatter {
9    use_colors: bool,
10}
11
12impl Default for TextFormatter {
13    fn default() -> Self {
14        Self { use_colors: true }
15    }
16}
17
18impl TextFormatter {
19    pub fn new() -> Self {
20        Self::default()
21    }
22}
23
24impl TextFormatter {
25    pub fn without_colors() -> Self {
26        Self { use_colors: false }
27    }
28}
29
30impl OutputFormatter for TextFormatter {
31    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
32        let mut output = String::new();
33
34        for warning in warnings {
35            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
36
37            // Add fix indicator if this warning has a fix
38            let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
39
40            // Format: file:line:column: [rule] message [*]
41            let line = format!(
42                "{}:{}:{}: {} {}{}",
43                if self.use_colors {
44                    file_path.blue().underline().to_string()
45                } else {
46                    file_path.to_string()
47                },
48                if self.use_colors {
49                    warning.line.to_string().cyan().to_string()
50                } else {
51                    warning.line.to_string()
52                },
53                if self.use_colors {
54                    warning.column.to_string().cyan().to_string()
55                } else {
56                    warning.column.to_string()
57                },
58                if self.use_colors {
59                    format!("[{rule_name:5}]").yellow().to_string()
60                } else {
61                    format!("[{rule_name:5}]")
62                },
63                warning.message,
64                if self.use_colors {
65                    fix_indicator.green().to_string()
66                } else {
67                    fix_indicator.to_string()
68                }
69            );
70
71            output.push_str(&line);
72            output.push('\n');
73        }
74
75        // Remove trailing newline
76        if output.ends_with('\n') {
77            output.pop();
78        }
79
80        output
81    }
82
83    fn use_colors(&self) -> bool {
84        self.use_colors
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::rule::{Fix, Severity};
92
93    #[test]
94    fn test_text_formatter_default() {
95        let formatter = TextFormatter::default();
96        assert!(formatter.use_colors());
97    }
98
99    #[test]
100    fn test_text_formatter_new() {
101        let formatter = TextFormatter::new();
102        assert!(formatter.use_colors());
103    }
104
105    #[test]
106    fn test_text_formatter_without_colors() {
107        let formatter = TextFormatter::without_colors();
108        assert!(!formatter.use_colors());
109    }
110
111    #[test]
112    fn test_format_warnings_empty() {
113        let formatter = TextFormatter::without_colors();
114        let warnings = vec![];
115        let output = formatter.format_warnings(&warnings, "test.md");
116        assert_eq!(output, "");
117    }
118
119    #[test]
120    fn test_format_single_warning_no_colors() {
121        let formatter = TextFormatter::without_colors();
122        let warnings = vec![LintWarning {
123            line: 10,
124            column: 5,
125            end_line: 10,
126            end_column: 15,
127            rule_name: Some("MD001".to_string()),
128            message: "Heading levels should only increment by one level at a time".to_string(),
129            severity: Severity::Warning,
130            fix: None,
131        }];
132
133        let output = formatter.format_warnings(&warnings, "README.md");
134        assert_eq!(
135            output,
136            "README.md:10:5: [MD001] Heading levels should only increment by one level at a time"
137        );
138    }
139
140    #[test]
141    fn test_format_warning_with_fix_no_colors() {
142        let formatter = TextFormatter::without_colors();
143        let warnings = vec![LintWarning {
144            line: 15,
145            column: 1,
146            end_line: 15,
147            end_column: 10,
148            rule_name: Some("MD022".to_string()),
149            message: "Headings should be surrounded by blank lines".to_string(),
150            severity: Severity::Warning,
151            fix: Some(Fix {
152                range: 100..110,
153                replacement: "\n# Heading\n".to_string(),
154            }),
155        }];
156
157        let output = formatter.format_warnings(&warnings, "doc.md");
158        assert_eq!(
159            output,
160            "doc.md:15:1: [MD022] Headings should be surrounded by blank lines [*]"
161        );
162    }
163
164    #[test]
165    fn test_format_multiple_warnings_no_colors() {
166        let formatter = TextFormatter::without_colors();
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".to_string()),
174                message: "First warning".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".to_string()),
184                message: "Second warning".to_string(),
185                severity: Severity::Error,
186                fix: Some(Fix {
187                    range: 50..60,
188                    replacement: "fixed".to_string(),
189                }),
190            },
191        ];
192
193        let output = formatter.format_warnings(&warnings, "test.md");
194        let expected = "test.md:5:1: [MD001] First warning\ntest.md:10:3: [MD013] Second warning [*]";
195        assert_eq!(output, expected);
196    }
197
198    #[test]
199    fn test_format_warning_unknown_rule() {
200        let formatter = TextFormatter::without_colors();
201        let warnings = vec![LintWarning {
202            line: 1,
203            column: 1,
204            end_line: 1,
205            end_column: 5,
206            rule_name: None,
207            message: "Unknown rule warning".to_string(),
208            severity: Severity::Warning,
209            fix: None,
210        }];
211
212        let output = formatter.format_warnings(&warnings, "file.md");
213        assert_eq!(output, "file.md:1:1: [unknown] Unknown rule warning");
214    }
215
216    #[test]
217    fn test_format_warnings_with_colors() {
218        // The colored crate might disable colors in test environments
219        // So we'll test the structure rather than actual ANSI codes
220        let formatter = TextFormatter::new(); // default has colors
221        let warnings = vec![LintWarning {
222            line: 1,
223            column: 1,
224            end_line: 1,
225            end_column: 5,
226            rule_name: Some("MD001".to_string()),
227            message: "Test warning".to_string(),
228            severity: Severity::Warning,
229            fix: Some(Fix {
230                range: 0..5,
231                replacement: "fixed".to_string(),
232            }),
233        }];
234
235        let output = formatter.format_warnings(&warnings, "test.md");
236
237        // Verify the formatter is set to use colors
238        assert!(formatter.use_colors());
239
240        // Check that core content is present (colors might be disabled in tests)
241        assert!(output.contains("test.md")); // file path is still there
242        assert!(output.contains("MD001")); // rule name is still there
243        assert!(output.contains("Test warning")); // message is still there
244        assert!(output.contains("[*]")); // fix indicator is still there
245
246        // Note: We don't check the exact format with colors because ANSI codes
247        // make exact string matching unreliable. The individual component checks above
248        // are sufficient to verify the output is correct.
249    }
250
251    #[test]
252    fn test_rule_name_padding() {
253        let formatter = TextFormatter::without_colors();
254
255        // Test short rule name gets padded
256        let warnings = vec![LintWarning {
257            line: 1,
258            column: 1,
259            end_line: 1,
260            end_column: 5,
261            rule_name: Some("MD1".to_string()),
262            message: "Test".to_string(),
263            severity: Severity::Warning,
264            fix: None,
265        }];
266
267        let output = formatter.format_warnings(&warnings, "test.md");
268        assert!(output.contains("[MD1  ]")); // Should be padded to 5 chars
269    }
270
271    #[test]
272    fn test_edge_cases() {
273        let formatter = TextFormatter::without_colors();
274
275        // Test large line/column numbers
276        let warnings = vec![LintWarning {
277            line: 99999,
278            column: 12345,
279            end_line: 100000,
280            end_column: 12350,
281            rule_name: Some("MD999".to_string()),
282            message: "Edge case warning".to_string(),
283            severity: Severity::Error,
284            fix: None,
285        }];
286
287        let output = formatter.format_warnings(&warnings, "large.md");
288        assert_eq!(output, "large.md:99999:12345: [MD999] Edge case warning");
289    }
290
291    #[test]
292    fn test_special_characters_in_message() {
293        let formatter = TextFormatter::without_colors();
294        let warnings = vec![LintWarning {
295            line: 1,
296            column: 1,
297            end_line: 1,
298            end_column: 5,
299            rule_name: Some("MD001".to_string()),
300            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
301            severity: Severity::Warning,
302            fix: None,
303        }];
304
305        let output = formatter.format_warnings(&warnings, "test.md");
306        assert!(output.contains("Warning with \"quotes\" and 'apostrophes' and \n newline"));
307    }
308
309    #[test]
310    fn test_special_characters_in_file_path() {
311        let formatter = TextFormatter::without_colors();
312        let warnings = vec![LintWarning {
313            line: 1,
314            column: 1,
315            end_line: 1,
316            end_column: 5,
317            rule_name: Some("MD001".to_string()),
318            message: "Test".to_string(),
319            severity: Severity::Warning,
320            fix: None,
321        }];
322
323        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
324        assert!(output.starts_with("path/with spaces/and-dashes.md:1:1:"));
325    }
326
327    #[test]
328    fn test_use_colors_trait_method() {
329        let formatter_with_colors = TextFormatter::new();
330        assert!(formatter_with_colors.use_colors());
331
332        let formatter_without_colors = TextFormatter::without_colors();
333        assert!(!formatter_without_colors.use_colors());
334    }
335}