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