Skip to main content

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::new(100..110, "\n# Heading\n".to_string())),
152        }];
153
154        let output = formatter.format_warnings(&warnings, "doc.md");
155        assert_eq!(
156            output,
157            "doc.md:15:1: [MD022] Headings should be surrounded by blank lines [*]"
158        );
159    }
160
161    #[test]
162    fn test_format_multiple_warnings_no_colors() {
163        let formatter = TextFormatter::without_colors();
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: "First warning".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: "Second warning".to_string(),
182                severity: Severity::Error,
183                fix: Some(Fix::new(50..60, "fixed".to_string())),
184            },
185        ];
186
187        let output = formatter.format_warnings(&warnings, "test.md");
188        let expected = "test.md:5:1: [MD001] First warning\ntest.md:10:3: [MD013] Second warning [*]";
189        assert_eq!(output, expected);
190    }
191
192    #[test]
193    fn test_format_warning_unknown_rule() {
194        let formatter = TextFormatter::without_colors();
195        let warnings = vec![LintWarning {
196            line: 1,
197            column: 1,
198            end_line: 1,
199            end_column: 5,
200            rule_name: None,
201            message: "Unknown rule warning".to_string(),
202            severity: Severity::Warning,
203            fix: None,
204        }];
205
206        let output = formatter.format_warnings(&warnings, "file.md");
207        assert_eq!(output, "file.md:1:1: [unknown] Unknown rule warning");
208    }
209
210    #[test]
211    fn test_format_warnings_with_colors() {
212        // The colored crate might disable colors in test environments
213        // So we'll test the structure rather than actual ANSI codes
214        let formatter = TextFormatter::new(); // default has colors
215        let warnings = vec![LintWarning {
216            line: 1,
217            column: 1,
218            end_line: 1,
219            end_column: 5,
220            rule_name: Some("MD001".to_string()),
221            message: "Test warning".to_string(),
222            severity: Severity::Warning,
223            fix: Some(Fix::new(0..5, "fixed".to_string())),
224        }];
225
226        let output = formatter.format_warnings(&warnings, "test.md");
227
228        // Verify the formatter is set to use colors
229        assert!(formatter.use_colors());
230
231        // Check that core content is present (colors might be disabled in tests)
232        assert!(output.contains("test.md")); // file path is still there
233        assert!(output.contains("MD001")); // rule name is still there
234        assert!(output.contains("Test warning")); // message is still there
235        assert!(output.contains("[*]")); // fix indicator is still there
236
237        // Note: We don't check the exact format with colors because ANSI codes
238        // make exact string matching unreliable. The individual component checks above
239        // are sufficient to verify the output is correct.
240    }
241
242    #[test]
243    fn test_rule_name_padding() {
244        let formatter = TextFormatter::without_colors();
245
246        // Test short rule name gets padded
247        let warnings = vec![LintWarning {
248            line: 1,
249            column: 1,
250            end_line: 1,
251            end_column: 5,
252            rule_name: Some("MD1".to_string()),
253            message: "Test".to_string(),
254            severity: Severity::Warning,
255            fix: None,
256        }];
257
258        let output = formatter.format_warnings(&warnings, "test.md");
259        assert!(output.contains("[MD1  ]")); // Should be padded to 5 chars
260    }
261
262    #[test]
263    fn test_edge_cases() {
264        let formatter = TextFormatter::without_colors();
265
266        // Test large line/column numbers
267        let warnings = vec![LintWarning {
268            line: 99999,
269            column: 12345,
270            end_line: 100000,
271            end_column: 12350,
272            rule_name: Some("MD999".to_string()),
273            message: "Edge case warning".to_string(),
274            severity: Severity::Error,
275            fix: None,
276        }];
277
278        let output = formatter.format_warnings(&warnings, "large.md");
279        assert_eq!(output, "large.md:99999:12345: [MD999] Edge case warning");
280    }
281
282    #[test]
283    fn test_special_characters_in_message() {
284        let formatter = TextFormatter::without_colors();
285        let warnings = vec![LintWarning {
286            line: 1,
287            column: 1,
288            end_line: 1,
289            end_column: 5,
290            rule_name: Some("MD001".to_string()),
291            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
292            severity: Severity::Warning,
293            fix: None,
294        }];
295
296        let output = formatter.format_warnings(&warnings, "test.md");
297        assert!(output.contains("Warning with \"quotes\" and 'apostrophes' and \n newline"));
298    }
299
300    #[test]
301    fn test_special_characters_in_file_path() {
302        let formatter = TextFormatter::without_colors();
303        let warnings = vec![LintWarning {
304            line: 1,
305            column: 1,
306            end_line: 1,
307            end_column: 5,
308            rule_name: Some("MD001".to_string()),
309            message: "Test".to_string(),
310            severity: Severity::Warning,
311            fix: None,
312        }];
313
314        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
315        assert!(output.starts_with("path/with spaces/and-dashes.md:1:1:"));
316    }
317
318    #[test]
319    fn test_use_colors_trait_method() {
320        let formatter_with_colors = TextFormatter::new();
321        assert!(formatter_with_colors.use_colors());
322
323        let formatter_without_colors = TextFormatter::without_colors();
324        assert!(!formatter_without_colors.use_colors());
325    }
326}