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    /// Whether this format is a batch format: a single document spanning all
101    /// results, which therefore needs every file's warnings collected before
102    /// anything is emitted. Streaming formats emit per file as results arrive.
103    pub fn is_batch(&self) -> bool {
104        matches!(
105            self,
106            OutputFormat::Json | OutputFormat::GitLab | OutputFormat::Sarif | OutputFormat::Junit
107        )
108    }
109
110    /// Whether this batch format also reports passing files and therefore
111    /// needs every checked file's path, not just the warning-bearing ones.
112    pub fn needs_all_files(&self) -> bool {
113        matches!(self, OutputFormat::Junit)
114    }
115
116    /// Format the complete result set for a batch format. Returns `None` for
117    /// streaming formats, so callers can fall through to per-file output
118    /// without matching on the variants themselves.
119    ///
120    /// `all_files` and `duration_ms` are consumed only by formats that report
121    /// passing files and run time (JUnit); issue-list formats ignore them.
122    pub fn format_batch(
123        &self,
124        file_warnings: &[(String, Vec<LintWarning>)],
125        all_files: &[String],
126        duration_ms: u64,
127    ) -> Option<String> {
128        match self {
129            OutputFormat::Json => Some(formatters::json::format_all_warnings_as_json(file_warnings)),
130            OutputFormat::GitLab => Some(formatters::gitlab::format_gitlab_report(file_warnings)),
131            OutputFormat::Sarif => Some(formatters::sarif::format_sarif_report(file_warnings)),
132            OutputFormat::Junit => Some(formatters::junit::format_junit_report(
133                file_warnings,
134                all_files,
135                duration_ms,
136            )),
137            _ => None,
138        }
139    }
140
141    /// Create a formatter instance for this format
142    pub fn create_formatter(&self) -> Box<dyn OutputFormatter> {
143        match self {
144            OutputFormat::Text => Box::new(TextFormatter::new()),
145            OutputFormat::Full => Box::new(FullFormatter::new()),
146            OutputFormat::Concise => Box::new(ConciseFormatter::new()),
147            OutputFormat::Grouped => Box::new(GroupedFormatter::new()),
148            OutputFormat::Json => Box::new(JsonFormatter::new()),
149            OutputFormat::JsonLines => Box::new(JsonLinesFormatter::new()),
150            OutputFormat::GitHub => Box::new(GitHubFormatter::new()),
151            OutputFormat::GitLab => Box::new(GitLabFormatter::new()),
152            OutputFormat::Pylint => Box::new(PylintFormatter::new()),
153            OutputFormat::Azure => Box::new(AzureFormatter::new()),
154            OutputFormat::Sarif => Box::new(SarifFormatter::new()),
155            OutputFormat::Junit => Box::new(JunitFormatter::new()),
156        }
157    }
158}
159
160/// Output writer that handles stdout/stderr routing
161pub struct OutputWriter {
162    use_stderr: bool,
163    silent: bool,
164}
165
166impl OutputWriter {
167    pub fn new(use_stderr: bool, silent: bool) -> Self {
168        Self { use_stderr, silent }
169    }
170
171    /// Write output to appropriate stream
172    pub fn write(&self, content: &str) -> io::Result<()> {
173        if self.silent {
174            return Ok(());
175        }
176
177        if self.use_stderr {
178            eprint!("{content}");
179            io::stderr().flush()?;
180        } else {
181            print!("{content}");
182            io::stdout().flush()?;
183        }
184        Ok(())
185    }
186
187    /// Write a line to appropriate stream
188    pub fn writeln(&self, content: &str) -> io::Result<()> {
189        if self.silent {
190            return Ok(());
191        }
192
193        if self.use_stderr {
194            eprintln!("{content}");
195        } else {
196            println!("{content}");
197        }
198        Ok(())
199    }
200
201    /// Write error/debug output (always to stderr unless silent)
202    pub fn write_error(&self, content: &str) -> io::Result<()> {
203        if self.silent {
204            return Ok(());
205        }
206
207        eprintln!("{content}");
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::rule::{Fix, Severity};
216
217    fn create_test_warning(line: usize, message: &str) -> LintWarning {
218        LintWarning {
219            line,
220            column: 5,
221            end_line: line,
222            end_column: 10,
223            rule_name: Some("MD001".to_string()),
224            message: message.to_string(),
225            severity: Severity::Warning,
226            fix: None,
227        }
228    }
229
230    fn create_test_warning_with_fix(line: usize, message: &str, fix_text: &str) -> LintWarning {
231        LintWarning {
232            line,
233            column: 5,
234            end_line: line,
235            end_column: 10,
236            rule_name: Some("MD001".to_string()),
237            message: message.to_string(),
238            severity: Severity::Warning,
239            fix: Some(Fix::new(0..5, fix_text.to_string())),
240        }
241    }
242
243    #[test]
244    fn test_output_format_from_str() {
245        // Valid formats
246        assert_eq!(OutputFormat::from_str("text").unwrap(), OutputFormat::Text);
247        assert_eq!(OutputFormat::from_str("full").unwrap(), OutputFormat::Full);
248        assert_eq!(OutputFormat::from_str("concise").unwrap(), OutputFormat::Concise);
249        assert_eq!(OutputFormat::from_str("grouped").unwrap(), OutputFormat::Grouped);
250        assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
251        assert_eq!(OutputFormat::from_str("json-lines").unwrap(), OutputFormat::JsonLines);
252        assert_eq!(OutputFormat::from_str("jsonlines").unwrap(), OutputFormat::JsonLines);
253        assert_eq!(OutputFormat::from_str("github").unwrap(), OutputFormat::GitHub);
254        assert_eq!(OutputFormat::from_str("gitlab").unwrap(), OutputFormat::GitLab);
255        assert_eq!(OutputFormat::from_str("pylint").unwrap(), OutputFormat::Pylint);
256        assert_eq!(OutputFormat::from_str("azure").unwrap(), OutputFormat::Azure);
257        assert_eq!(OutputFormat::from_str("sarif").unwrap(), OutputFormat::Sarif);
258        assert_eq!(OutputFormat::from_str("junit").unwrap(), OutputFormat::Junit);
259
260        // Case insensitive
261        assert_eq!(OutputFormat::from_str("TEXT").unwrap(), OutputFormat::Text);
262        assert_eq!(OutputFormat::from_str("GitHub").unwrap(), OutputFormat::GitHub);
263        assert_eq!(OutputFormat::from_str("JSON-LINES").unwrap(), OutputFormat::JsonLines);
264
265        // Invalid format
266        assert!(OutputFormat::from_str("invalid").is_err());
267        assert!(OutputFormat::from_str("").is_err());
268        assert!(OutputFormat::from_str("xml").is_err());
269    }
270
271    #[test]
272    fn test_output_format_create_formatter() {
273        // Test that each format creates the correct formatter
274        let formats = [
275            OutputFormat::Text,
276            OutputFormat::Full,
277            OutputFormat::Concise,
278            OutputFormat::Grouped,
279            OutputFormat::Json,
280            OutputFormat::JsonLines,
281            OutputFormat::GitHub,
282            OutputFormat::GitLab,
283            OutputFormat::Pylint,
284            OutputFormat::Azure,
285            OutputFormat::Sarif,
286            OutputFormat::Junit,
287        ];
288
289        for format in &formats {
290            let formatter = format.create_formatter();
291            // Test that formatter can format warnings
292            let warnings = vec![create_test_warning(1, "Test warning")];
293            let output = formatter.format_warnings(&warnings, "test.md");
294            assert!(!output.is_empty(), "Formatter {format:?} should produce output");
295        }
296    }
297
298    #[test]
299    fn test_output_writer_new() {
300        let writer1 = OutputWriter::new(false, false);
301        assert!(!writer1.use_stderr);
302        assert!(!writer1.silent);
303
304        let writer2 = OutputWriter::new(true, false);
305        assert!(writer2.use_stderr);
306        assert!(!writer2.silent);
307
308        let writer3 = OutputWriter::new(false, true);
309        assert!(!writer3.use_stderr);
310        assert!(writer3.silent);
311    }
312
313    #[test]
314    fn test_output_writer_silent_mode() {
315        let writer = OutputWriter::new(false, true);
316
317        // All write methods should succeed but not produce output when silent
318        assert!(writer.write("test").is_ok());
319        assert!(writer.writeln("test").is_ok());
320        assert!(writer.write_error("test").is_ok());
321    }
322
323    #[test]
324    fn test_output_writer_write_methods() {
325        // Test non-silent mode
326        let writer = OutputWriter::new(false, false);
327
328        // These should succeed (we can't easily test the actual output)
329        assert!(writer.write("test").is_ok());
330        assert!(writer.writeln("test line").is_ok());
331        assert!(writer.write_error("error message").is_ok());
332    }
333
334    #[test]
335    fn test_output_writer_stderr_mode() {
336        let writer = OutputWriter::new(true, false);
337
338        // Should write to stderr instead of stdout
339        assert!(writer.write("stderr test").is_ok());
340        assert!(writer.writeln("stderr line").is_ok());
341
342        // write_error always goes to stderr
343        assert!(writer.write_error("error").is_ok());
344    }
345
346    #[test]
347    fn test_formatter_trait_default_summary() {
348        // Create a simple test formatter
349        struct TestFormatter;
350        impl OutputFormatter for TestFormatter {
351            fn format_warnings(&self, _warnings: &[LintWarning], _file_path: &str) -> String {
352                "test".to_string()
353            }
354        }
355
356        let formatter = TestFormatter;
357        assert_eq!(formatter.format_summary(10, 5, 1000), None);
358        assert!(!formatter.use_colors());
359    }
360
361    #[test]
362    fn test_formatter_with_multiple_warnings() {
363        let warnings = vec![
364            create_test_warning(1, "First warning"),
365            create_test_warning(5, "Second warning"),
366            create_test_warning_with_fix(10, "Third warning with fix", "fixed content"),
367        ];
368
369        // Test with different formatters
370        let text_formatter = TextFormatter::new();
371        let output = text_formatter.format_warnings(&warnings, "test.md");
372        assert!(output.contains("First warning"));
373        assert!(output.contains("Second warning"));
374        assert!(output.contains("Third warning with fix"));
375    }
376
377    #[test]
378    fn test_edge_cases() {
379        // Empty warnings
380        let empty_warnings: Vec<LintWarning> = vec![];
381        let formatter = TextFormatter::new();
382        let output = formatter.format_warnings(&empty_warnings, "test.md");
383        // Most formatters should handle empty warnings gracefully
384        assert!(output.is_empty() || output.trim().is_empty());
385
386        // Very long file path
387        let long_path = "a/".repeat(100) + "file.md";
388        let warnings = vec![create_test_warning(1, "Test")];
389        let output = formatter.format_warnings(&warnings, &long_path);
390        assert!(!output.is_empty());
391
392        // Unicode in messages
393        let unicode_warning = LintWarning {
394            line: 1,
395            column: 1,
396            end_line: 1,
397            end_column: 10,
398            rule_name: Some("MD001".to_string()),
399            message: "Unicode test: 你好 🌟 émphasis".to_string(),
400            severity: Severity::Warning,
401            fix: None,
402        };
403        let output = formatter.format_warnings(&[unicode_warning], "test.md");
404        assert!(output.contains("Unicode test"));
405    }
406
407    #[test]
408    fn test_severity_variations() {
409        let severities = [Severity::Error, Severity::Warning, Severity::Info];
410
411        for severity in &severities {
412            let warning = LintWarning {
413                line: 1,
414                column: 1,
415                end_line: 1,
416                end_column: 5,
417                rule_name: Some("MD001".to_string()),
418                message: format!(
419                    "Test {} message",
420                    match severity {
421                        Severity::Error => "error",
422                        Severity::Warning => "warning",
423                        Severity::Info => "info",
424                    }
425                ),
426                severity: *severity,
427                fix: None,
428            };
429
430            let formatter = TextFormatter::new();
431            let output = formatter.format_warnings(&[warning], "test.md");
432            assert!(!output.is_empty());
433        }
434    }
435
436    #[test]
437    fn test_output_format_equality() {
438        assert_eq!(OutputFormat::Text, OutputFormat::Text);
439        assert_ne!(OutputFormat::Text, OutputFormat::Json);
440        assert_ne!(OutputFormat::Concise, OutputFormat::Grouped);
441    }
442
443    #[test]
444    fn test_all_formats_handle_no_rule_name() {
445        let warning = LintWarning {
446            line: 1,
447            column: 1,
448            end_line: 1,
449            end_column: 5,
450            rule_name: None, // No rule name
451            message: "Generic warning".to_string(),
452            severity: Severity::Warning,
453            fix: None,
454        };
455
456        let formats = [
457            OutputFormat::Text,
458            OutputFormat::Full,
459            OutputFormat::Concise,
460            OutputFormat::Grouped,
461            OutputFormat::Json,
462            OutputFormat::JsonLines,
463            OutputFormat::GitHub,
464            OutputFormat::GitLab,
465            OutputFormat::Pylint,
466            OutputFormat::Azure,
467            OutputFormat::Sarif,
468            OutputFormat::Junit,
469        ];
470
471        for format in &formats {
472            let formatter = format.create_formatter();
473            let output = formatter.format_warnings(std::slice::from_ref(&warning), "test.md");
474            assert!(
475                !output.is_empty(),
476                "Format {format:?} should handle warnings without rule names"
477            );
478        }
479    }
480
481    #[test]
482    fn test_batch_seam() {
483        let batch = [
484            OutputFormat::Json,
485            OutputFormat::GitLab,
486            OutputFormat::Sarif,
487            OutputFormat::Junit,
488        ];
489        let streaming = [
490            OutputFormat::Text,
491            OutputFormat::Full,
492            OutputFormat::Concise,
493            OutputFormat::Grouped,
494            OutputFormat::JsonLines,
495            OutputFormat::GitHub,
496            OutputFormat::Pylint,
497            OutputFormat::Azure,
498        ];
499
500        let file_warnings = vec![("dirty.md".to_string(), vec![create_test_warning(1, "w")])];
501        let all_files = vec!["dirty.md".to_string(), "clean.md".to_string()];
502
503        for format in &batch {
504            assert!(format.is_batch(), "{format:?} is a batch format");
505            let output = format
506                .format_batch(&file_warnings, &all_files, 5)
507                .unwrap_or_else(|| panic!("{format:?} must format a batch"));
508            assert!(!output.is_empty());
509        }
510        for format in &streaming {
511            assert!(!format.is_batch(), "{format:?} is a streaming format");
512            assert!(
513                format.format_batch(&file_warnings, &all_files, 5).is_none(),
514                "{format:?} must not claim batch output"
515            );
516        }
517
518        // Only JUnit reports passing files and needs the full file list.
519        for format in batch.iter().chain(&streaming) {
520            assert_eq!(format.needs_all_files(), *format == OutputFormat::Junit);
521        }
522        let junit = OutputFormat::Junit.format_batch(&file_warnings, &all_files, 5).unwrap();
523        assert!(junit.contains("clean.md"), "JUnit batch output reports passing files");
524    }
525
526    #[test]
527    fn test_is_machine_readable() {
528        // Human-readable formats
529        assert!(!OutputFormat::Text.is_machine_readable());
530        assert!(!OutputFormat::Full.is_machine_readable());
531        assert!(!OutputFormat::Concise.is_machine_readable());
532        assert!(!OutputFormat::Grouped.is_machine_readable());
533
534        // Machine-readable formats
535        assert!(OutputFormat::Json.is_machine_readable());
536        assert!(OutputFormat::JsonLines.is_machine_readable());
537        assert!(OutputFormat::GitHub.is_machine_readable());
538        assert!(OutputFormat::GitLab.is_machine_readable());
539        assert!(OutputFormat::Pylint.is_machine_readable());
540        assert!(OutputFormat::Azure.is_machine_readable());
541        assert!(OutputFormat::Sarif.is_machine_readable());
542        assert!(OutputFormat::Junit.is_machine_readable());
543    }
544}