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