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    /// Whether this format produces machine-readable output that should not
92    /// be mixed with human-readable summary lines.
93    pub fn is_machine_readable(&self) -> bool {
94        !matches!(
95            self,
96            OutputFormat::Text | OutputFormat::Full | OutputFormat::Concise | OutputFormat::Grouped
97        )
98    }
99
100    /// Create a formatter instance for this format
101    pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
102        match self {
103            OutputFormat::Text => Box::new(TextFormatter::new()),
104            OutputFormat::Full => Box::new(FullFormatter::new()),
105            OutputFormat::Concise => Box::new(ConciseFormatter::new()),
106            OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
107            OutputFormat::Json => Box::new(JsonFormatter::new()),
108            OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
109            OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
110            OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
111            OutputFormat::Pylint => Box::new(PylintFormatter::new()),
112            OutputFormat::Azure => Box::new(AzureFormatter::new()),
113            OutputFormat::Sarif => Box::new(SarifFormatter::new()),
114            OutputFormat::Junit => Box::new(JunitFormatter::new()),
115        }
116    }
117}
118
119/// Output writer that handles stdout/stderr routing
120pub struct OutputWriter {
121    use_stderr: bool,
122    _quiet: bool,
123    silent: bool,
124}
125
126impl OutputWriter {
127    pub fn new(use_stderr: bool, quiet: bool, silent: bool) -> Self {
128        Self {
129            use_stderr,
130            _quiet: quiet,
131            silent,
132        }
133    }
134
135    /// Write output to appropriate stream
136    pub fn write(&self, content: &str) -> io::Result<()> {
137        if self.silent {
138            return Ok(());
139        }
140
141        if self.use_stderr {
142            eprint!("{content}");
143            io::stderr().flush()?;
144        } else {
145            print!("{content}");
146            io::stdout().flush()?;
147        }
148        Ok(())
149    }
150
151    /// Write a line to appropriate stream
152    pub fn writeln(&self, content: &str) -> io::Result<()> {
153        if self.silent {
154            return Ok(());
155        }
156
157        if self.use_stderr {
158            eprintln!("{content}");
159        } else {
160            println!("{content}");
161        }
162        Ok(())
163    }
164
165    /// Write error/debug output (always to stderr unless silent)
166    pub fn write_error(&self, content: &str) -> io::Result<()> {
167        if self.silent {
168            return Ok(());
169        }
170
171        eprintln!("{content}");
172        Ok(())
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::rule::{Fix, Severity};
180
181    fn create_test_warning(line: usize, message: &str) -> LintWarning {
182        LintWarning {
183            line,
184            column: 5,
185            end_line: line,
186            end_column: 10,
187            rule_name: Some("MD001".to_string()),
188            message: message.to_string(),
189            severity: Severity::Warning,
190            fix: None,
191        }
192    }
193
194    fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
195        LintWarning {
196            line,
197            column: 5,
198            end_line: line,
199            end_column: 10,
200            rule_name: Some("MD001".to_string()),
201            message: message.to_string(),
202            severity: Severity::Warning,
203            fix: Some(Fix {
204                range: 0..5,
205                replacement: fix_text.to_string(),
206            }),
207        }
208    }
209
210    #[test]
211    fn test_output_format_from_str() {
212        // Valid formats
213        assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
214        assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Full);
215        assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
216        assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
217        assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
218        assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
219        assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
220        assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
221        assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
222        assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
223        assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
224        assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
225        assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
226
227        // Case insensitive
228        assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
229        assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
230        assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
231
232        // Invalid format
233        assert!(OutputFormat::from_str("invalid").is_err());
234        assert!(OutputFormat::from_str("").is_err());
235        assert!(OutputFormat::from_str("xml").is_err());
236    }
237
238    #[test]
239    fn test_output_format_create_formatter() {
240        // Test that each format creates the correct formatter
241        let formats = [
242            OutputFormat::Text,
243            OutputFormat::Full,
244            OutputFormat::Concise,
245            OutputFormat::Grouped,
246            OutputFormat::Json,
247            OutputFormat::JsonLines,
248            OutputFormat::GitHub,
249            OutputFormat::GitLab,
250            OutputFormat::Pylint,
251            OutputFormat::Azure,
252            OutputFormat::Sarif,
253            OutputFormat::Junit,
254        ];
255
256        for format in &formats {
257            let formatter = format.create_formatter();
258            // Test that formatter can format warnings
259            let warnings = vec![create_test_warning(1, "Test warning")];
260            let output = formatter.format_warnings(&warnings, "test.md");
261            assert!(!output.is_empty(), "Formatter {format:?} should produce output");
262        }
263    }
264
265    #[test]
266    fn test_output_writer_new() {
267        let writer1 = OutputWriter::new(false, false, false);
268        assert!(!writer1.use_stderr);
269        assert!(!writer1._quiet);
270        assert!(!writer1.silent);
271
272        let writer2 = OutputWriter::new(true, true, false);
273        assert!(writer2.use_stderr);
274        assert!(writer2._quiet);
275        assert!(!writer2.silent);
276
277        let writer3 = OutputWriter::new(false, false, true);
278        assert!(!writer3.use_stderr);
279        assert!(!writer3._quiet);
280        assert!(writer3.silent);
281    }
282
283    #[test]
284    fn test_output_writer_silent_mode() {
285        let writer = OutputWriter::new(false, false, true);
286
287        // All write methods should succeed but not produce output when silent
288        assert!(writer.write("test").is_ok());
289        assert!(writer.writeln("test").is_ok());
290        assert!(writer.write_error("test").is_ok());
291    }
292
293    #[test]
294    fn test_output_writer_write_methods() {
295        // Test non-silent mode
296        let writer = OutputWriter::new(false, false, false);
297
298        // These should succeed (we can't easily test the actual output)
299        assert!(writer.write("test").is_ok());
300        assert!(writer.writeln("test line").is_ok());
301        assert!(writer.write_error("error message").is_ok());
302    }
303
304    #[test]
305    fn test_output_writer_stderr_mode() {
306        let writer = OutputWriter::new(true, false, false);
307
308        // Should write to stderr instead of stdout
309        assert!(writer.write("stderr test").is_ok());
310        assert!(writer.writeln("stderr line").is_ok());
311
312        // write_error always goes to stderr
313        assert!(writer.write_error("error").is_ok());
314    }
315
316    #[test]
317    fn test_formatter_trait_default_summary() {
318        // Create a simple test formatter
319        struct TestFormatter;
320        impl OutputFormatter for TestFormatter {
321            fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
322                "test".to_string()
323            }
324        }
325
326        let formatter = TestFormatter;
327        assert_eq!(formatter.format_summary(10, 5, 1000), None);
328        assert!(!formatter.use_colors());
329    }
330
331    #[test]
332    fn test_formatter_with_multiple_warnings() {
333        let warnings = vec![
334            create_test_warning(1, "First warning"),
335            create_test_warning(5, "Second warning"),
336            create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
337        ];
338
339        // Test with different formatters
340        let text_formatter = TextFormatter::new();
341        let output = text_formatter.format_warnings(&warnings, "test.md");
342        assert!(output.contains("First warning"));
343        assert!(output.contains("Second warning"));
344        assert!(output.contains("Third warning with fix"));
345    }
346
347    #[test]
348    fn test_edge_cases() {
349        // Empty warnings
350        let empty_warnings: Vec<LintWarning> = vec![];
351        let formatter = TextFormatter::new();
352        let output = formatter.format_warnings(&empty_warnings, "test.md");
353        // Most formatters should handle empty warnings gracefully
354        assert!(output.is_empty() || output.trim().is_empty());
355
356        // Very long file path
357        let long_path = "a/".repeat(100) + "file.md";
358        let warnings = vec![create_test_warning(1, "Test")];
359        let output = formatter.format_warnings(&warnings, &long_path);
360        assert!(!output.is_empty());
361
362        // Unicode in messages
363        let unicode_warning = LintWarning {
364            line: 1,
365            column: 1,
366            end_line: 1,
367            end_column: 10,
368            rule_name: Some("MD001".to_string()),
369            message: "Unicode test: 你好 🌟 émphasis".to_string(),
370            severity: Severity::Warning,
371            fix: None,
372        };
373        let output = formatter.format_warnings(&[unicode_warning], "test.md");
374        assert!(output.contains("Unicode test"));
375    }
376
377    #[test]
378    fn test_severity_variations() {
379        let severities = [Severity::Error, Severity::Warning, Severity::Info];
380
381        for severity in &severities {
382            let warning = LintWarning {
383                line: 1,
384                column: 1,
385                end_line: 1,
386                end_column: 5,
387                rule_name: Some("MD001".to_string()),
388                message: format!(
389                    "Test {} message",
390                    match severity {
391                        Severity::Error => "error",
392                        Severity::Warning => "warning",
393                        Severity::Info => "info",
394                    }
395                ),
396                severity: *severity,
397                fix: None,
398            };
399
400            let formatter = TextFormatter::new();
401            let output = formatter.format_warnings(&[warning], "test.md");
402            assert!(!output.is_empty());
403        }
404    }
405
406    #[test]
407    fn test_output_format_equality() {
408        assert_eq!(OutputFormat::Text, OutputFormat::Text);
409        assert_ne!(OutputFormat::Text, OutputFormat::Json);
410        assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
411    }
412
413    #[test]
414    fn test_all_formats_handle_no_rule_name() {
415        let warning = LintWarning {
416            line: 1,
417            column: 1,
418            end_line: 1,
419            end_column: 5,
420            rule_name: None, // No rule name
421            message: "Generic warning".to_string(),
422            severity: Severity::Warning,
423            fix: None,
424        };
425
426        let formats = [
427            OutputFormat::Text,
428            OutputFormat::Full,
429            OutputFormat::Concise,
430            OutputFormat::Grouped,
431            OutputFormat::Json,
432            OutputFormat::JsonLines,
433            OutputFormat::GitHub,
434            OutputFormat::GitLab,
435            OutputFormat::Pylint,
436            OutputFormat::Azure,
437            OutputFormat::Sarif,
438            OutputFormat::Junit,
439        ];
440
441        for format in &formats {
442            let formatter = format.create_formatter();
443            let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
444            assert!(
445                !output.is_empty(),
446                "Format {format:?} should handle warnings without rule names"
447            );
448        }
449    }
450
451    #[test]
452    fn test_is_machine_readable() {
453        // Human-readable formats
454        assert!(!OutputFormat::Text.is_machine_readable());
455        assert!(!OutputFormat::Full.is_machine_readable());
456        assert!(!OutputFormat::Concise.is_machine_readable());
457        assert!(!OutputFormat::Grouped.is_machine_readable());
458
459        // Machine-readable formats
460        assert!(OutputFormat::Json.is_machine_readable());
461        assert!(OutputFormat::JsonLines.is_machine_readable());
462        assert!(OutputFormat::GitHub.is_machine_readable());
463        assert!(OutputFormat::GitLab.is_machine_readable());
464        assert!(OutputFormat::Pylint.is_machine_readable());
465        assert!(OutputFormat::Azure.is_machine_readable());
466        assert!(OutputFormat::Sarif.is_machine_readable());
467        assert!(OutputFormat::Junit.is_machine_readable());
468    }
469}