rumdl_lib/output/
mod.rs

1//! Output formatting module for rumdl
2//!
3//! This module provides different output formats for linting results,
4//! similar to how Ruff handles multiple output formats.
5
6use crate::rule::LintWarning;
7use std::io::{self, Write};
8use std::str::FromStr;
9
10pub mod formatters;
11
12// Re-export formatters
13pub use formatters::*;
14
15/// Trait for output formatters
16pub trait OutputFormatter {
17    /// Format a collection of warnings for output
18    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String;
19
20    /// Format a summary of results across multiple files
21    fn format_summary(&self, _files_processed: usize, _total_warnings: usize, _duration_ms: u64) -> Option<String> {
22        // Default: no summary
23        None
24    }
25
26    /// Whether this formatter should use colors
27    fn use_colors(&self) -> bool {
28        false
29    }
30}
31
32/// Available output formats
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum OutputFormat {
35    /// Default human-readable format with colors and context
36    Text,
37    /// Concise format: file:line:col: [RULE] message
38    Concise,
39    /// Grouped format: violations grouped by file
40    Grouped,
41    /// JSON format (existing)
42    Json,
43    /// JSON Lines format (one JSON object per line)
44    JsonLines,
45    /// GitHub Actions annotation format
46    GitHub,
47    /// GitLab Code Quality format
48    GitLab,
49    /// Pylint-compatible format: file:line:column: CODE message
50    Pylint,
51    /// Azure Pipeline logging format
52    Azure,
53    /// SARIF 2.1.0 format
54    Sarif,
55    /// JUnit XML format
56    Junit,
57}
58
59impl FromStr for OutputFormat {
60    type Err = String;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s.to_lowercase().as_str() {
64            "text" | "full" => Ok(OutputFormat::Text),
65            "concise" => Ok(OutputFormat::Concise),
66            "grouped" => Ok(OutputFormat::Grouped),
67            "json" => Ok(OutputFormat::Json),
68            "json-lines" | "jsonlines" => Ok(OutputFormat::JsonLines),
69            "github" => Ok(OutputFormat::GitHub),
70            "gitlab" => Ok(OutputFormat::GitLab),
71            "pylint" => Ok(OutputFormat::Pylint),
72            "azure" => Ok(OutputFormat::Azure),
73            "sarif" => Ok(OutputFormat::Sarif),
74            "junit" => Ok(OutputFormat::Junit),
75            _ => Err(format!("Unknown output format: {s}")),
76        }
77    }
78}
79
80impl OutputFormat {
81    /// Create a formatter instance for this format
82    pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
83        match self {
84            OutputFormat::Text => Box::new(TextFormatter::new()),
85            OutputFormat::Concise => Box::new(ConciseFormatter::new()),
86            OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
87            OutputFormat::Json => Box::new(JsonFormatter::new()),
88            OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
89            OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
90            OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
91            OutputFormat::Pylint => Box::new(PylintFormatter::new()),
92            OutputFormat::Azure => Box::new(AzureFormatter::new()),
93            OutputFormat::Sarif => Box::new(SarifFormatter::new()),
94            OutputFormat::Junit => Box::new(JunitFormatter::new()),
95        }
96    }
97}
98
99/// Output writer that handles stdout/stderr routing
100pub struct OutputWriter {
101    use_stderr: bool,
102    _quiet: bool,
103    silent: bool,
104}
105
106impl OutputWriter {
107    pub fn new(use_stderr: bool, quiet: bool, silent: bool) -> Self {
108        Self {
109            use_stderr,
110            _quiet: quiet,
111            silent,
112        }
113    }
114
115    /// Write output to appropriate stream
116    pub fn write(&self, content: &str) -> io::Result<()> {
117        if self.silent {
118            return Ok(());
119        }
120
121        if self.use_stderr {
122            eprint!("{content}");
123            io::stderr().flush()?;
124        } else {
125            print!("{content}");
126            io::stdout().flush()?;
127        }
128        Ok(())
129    }
130
131    /// Write a line to appropriate stream
132    pub fn writeln(&self, content: &str) -> io::Result<()> {
133        if self.silent {
134            return Ok(());
135        }
136
137        if self.use_stderr {
138            eprintln!("{content}");
139        } else {
140            println!("{content}");
141        }
142        Ok(())
143    }
144
145    /// Write error/debug output (always to stderr unless silent)
146    pub fn write_error(&self, content: &str) -> io::Result<()> {
147        if self.silent {
148            return Ok(());
149        }
150
151        eprintln!("{content}");
152        Ok(())
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::rule::{Fix, Severity};
160
161    fn create_test_warning(line: usize, message: &str) -> LintWarning {
162        LintWarning {
163            line,
164            column: 5,
165            end_line: line,
166            end_column: 10,
167            rule_name: Some("MD001".to_string()),
168            message: message.to_string(),
169            severity: Severity::Warning,
170            fix: None,
171        }
172    }
173
174    fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
175        LintWarning {
176            line,
177            column: 5,
178            end_line: line,
179            end_column: 10,
180            rule_name: Some("MD001".to_string()),
181            message: message.to_string(),
182            severity: Severity::Warning,
183            fix: Some(Fix {
184                range: 0..5,
185                replacement: fix_text.to_string(),
186            }),
187        }
188    }
189
190    #[test]
191    fn test_output_format_from_str() {
192        // Valid formats
193        assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
194        assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Text);
195        assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
196        assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
197        assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
198        assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
199        assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
200        assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
201        assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
202        assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
203        assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
204        assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
205        assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
206
207        // Case insensitive
208        assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
209        assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
210        assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
211
212        // Invalid format
213        assert!(OutputFormat::from_str("invalid").is_err());
214        assert!(OutputFormat::from_str("").is_err());
215        assert!(OutputFormat::from_str("xml").is_err());
216    }
217
218    #[test]
219    fn test_output_format_create_formatter() {
220        // Test that each format creates the correct formatter
221        let formats = [
222            OutputFormat::Text,
223            OutputFormat::Concise,
224            OutputFormat::Grouped,
225            OutputFormat::Json,
226            OutputFormat::JsonLines,
227            OutputFormat::GitHub,
228            OutputFormat::GitLab,
229            OutputFormat::Pylint,
230            OutputFormat::Azure,
231            OutputFormat::Sarif,
232            OutputFormat::Junit,
233        ];
234
235        for format in &formats {
236            let formatter = format.create_formatter();
237            // Test that formatter can format warnings
238            let warnings = vec![create_test_warning(1, "Test warning")];
239            let output = formatter.format_warnings(&warnings, "test.md");
240            assert!(!output.is_empty(), "Formatter {format:?} should produce output");
241        }
242    }
243
244    #[test]
245    fn test_output_writer_new() {
246        let writer1 = OutputWriter::new(false, false, false);
247        assert!(!writer1.use_stderr);
248        assert!(!writer1._quiet);
249        assert!(!writer1.silent);
250
251        let writer2 = OutputWriter::new(true, true, false);
252        assert!(writer2.use_stderr);
253        assert!(writer2._quiet);
254        assert!(!writer2.silent);
255
256        let writer3 = OutputWriter::new(false, false, true);
257        assert!(!writer3.use_stderr);
258        assert!(!writer3._quiet);
259        assert!(writer3.silent);
260    }
261
262    #[test]
263    fn test_output_writer_silent_mode() {
264        let writer = OutputWriter::new(false, false, true);
265
266        // All write methods should succeed but not produce output when silent
267        assert!(writer.write("test").is_ok());
268        assert!(writer.writeln("test").is_ok());
269        assert!(writer.write_error("test").is_ok());
270    }
271
272    #[test]
273    fn test_output_writer_write_methods() {
274        // Test non-silent mode
275        let writer = OutputWriter::new(false, false, false);
276
277        // These should succeed (we can't easily test the actual output)
278        assert!(writer.write("test").is_ok());
279        assert!(writer.writeln("test line").is_ok());
280        assert!(writer.write_error("error message").is_ok());
281    }
282
283    #[test]
284    fn test_output_writer_stderr_mode() {
285        let writer = OutputWriter::new(true, false, false);
286
287        // Should write to stderr instead of stdout
288        assert!(writer.write("stderr test").is_ok());
289        assert!(writer.writeln("stderr line").is_ok());
290
291        // write_error always goes to stderr
292        assert!(writer.write_error("error").is_ok());
293    }
294
295    #[test]
296    fn test_formatter_trait_default_summary() {
297        // Create a simple test formatter
298        struct TestFormatter;
299        impl OutputFormatter for TestFormatter {
300            fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
301                "test".to_string()
302            }
303        }
304
305        let formatter = TestFormatter;
306        assert_eq!(formatter.format_summary(10, 5, 1000), None);
307        assert!(!formatter.use_colors());
308    }
309
310    #[test]
311    fn test_formatter_with_multiple_warnings() {
312        let warnings = vec![
313            create_test_warning(1, "First warning"),
314            create_test_warning(5, "Second warning"),
315            create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
316        ];
317
318        // Test with different formatters
319        let text_formatter = TextFormatter::new();
320        let output = text_formatter.format_warnings(&warnings, "test.md");
321        assert!(output.contains("First warning"));
322        assert!(output.contains("Second warning"));
323        assert!(output.contains("Third warning with fix"));
324    }
325
326    #[test]
327    fn test_edge_cases() {
328        // Empty warnings
329        let empty_warnings: Vec<LintWarning> = vec![];
330        let formatter = TextFormatter::new();
331        let output = formatter.format_warnings(&empty_warnings, "test.md");
332        // Most formatters should handle empty warnings gracefully
333        assert!(output.is_empty() || output.trim().is_empty());
334
335        // Very long file path
336        let long_path = "a/".repeat(100) + "file.md";
337        let warnings = vec![create_test_warning(1, "Test")];
338        let output = formatter.format_warnings(&warnings, &long_path);
339        assert!(!output.is_empty());
340
341        // Unicode in messages
342        let unicode_warning = LintWarning {
343            line: 1,
344            column: 1,
345            end_line: 1,
346            end_column: 10,
347            rule_name: Some("MD001".to_string()),
348            message: "Unicode test: 你好 🌟 émphasis".to_string(),
349            severity: Severity::Warning,
350            fix: None,
351        };
352        let output = formatter.format_warnings(&[unicode_warning], "test.md");
353        assert!(output.contains("Unicode test"));
354    }
355
356    #[test]
357    fn test_severity_variations() {
358        let severities = [Severity::Error, Severity::Warning, Severity::Info];
359
360        for severity in &severities {
361            let warning = LintWarning {
362                line: 1,
363                column: 1,
364                end_line: 1,
365                end_column: 5,
366                rule_name: Some("MD001".to_string()),
367                message: format!(
368                    "Test {} message",
369                    match severity {
370                        Severity::Error => "error",
371                        Severity::Warning => "warning",
372                        Severity::Info => "info",
373                    }
374                ),
375                severity: *severity,
376                fix: None,
377            };
378
379            let formatter = TextFormatter::new();
380            let output = formatter.format_warnings(&[warning], "test.md");
381            assert!(!output.is_empty());
382        }
383    }
384
385    #[test]
386    fn test_output_format_equality() {
387        assert_eq!(OutputFormat::Text, OutputFormat::Text);
388        assert_ne!(OutputFormat::Text, OutputFormat::Json);
389        assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
390    }
391
392    #[test]
393    fn test_all_formats_handle_no_rule_name() {
394        let warning = LintWarning {
395            line: 1,
396            column: 1,
397            end_line: 1,
398            end_column: 5,
399            rule_name: None, // No rule name
400            message: "Generic warning".to_string(),
401            severity: Severity::Warning,
402            fix: None,
403        };
404
405        let formats = [
406            OutputFormat::Text,
407            OutputFormat::Concise,
408            OutputFormat::Grouped,
409            OutputFormat::Json,
410            OutputFormat::JsonLines,
411            OutputFormat::GitHub,
412            OutputFormat::GitLab,
413            OutputFormat::Pylint,
414            OutputFormat::Azure,
415            OutputFormat::Sarif,
416            OutputFormat::Junit,
417        ];
418
419        for format in &formats {
420            let formatter = format.create_formatter();
421            let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
422            assert!(
423                !output.is_empty(),
424                "Format {format:?} should handle warnings without rule names"
425            );
426        }
427    }
428}