Skip to main content

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