Skip to main content

mago_reporting/
reporter.rs

1//! Issue reporter and output formatting.
2//!
3//! This module provides the core reporter functionality that formats and outputs
4//! issues in various formats. It supports multiple output targets (stdout/stderr),
5//! different formatting styles (rich, medium, short, JSON, etc.), and optional
6//! pagination for terminal output.
7//!
8//! The reporter can filter issues based on baseline files and severity levels,
9//! and can sort issues for better readability.
10
11use std::io::Write;
12
13use mago_database::ReadDatabase;
14
15use crate::IssueCollection;
16use crate::Level;
17use crate::baseline::Baseline;
18use crate::color::ColorChoice;
19use crate::error::ReportingError;
20use crate::formatter::FormatterConfig;
21use crate::formatter::ReportingFormat;
22use crate::formatter::dispatch_format;
23use crate::output::ReportingTarget;
24
25/// Configuration options for the reporter.
26///
27/// This struct controls how issues are formatted and displayed, including
28/// the output target, format style, color usage, and filtering options.
29#[derive(Debug)]
30pub struct ReporterConfig {
31    /// The target where the report will be sent.
32    pub target: ReportingTarget,
33
34    /// The format to use for the report output.
35    pub format: ReportingFormat,
36
37    /// Color choice for the report output.
38    pub color_choice: ColorChoice,
39
40    /// Filter the output to only show issues that can be automatically fixed.
41    ///
42    /// When enabled, only issues that have available automatic fixes will be displayed.
43    /// This is useful when you want to focus on issues that can be resolved immediately.
44    pub filter_fixable: bool,
45
46    /// Sort reported issues by severity level, rule code, and file location.
47    ///
48    /// By default, issues are reported in the order they appear in files.
49    /// This option provides a more organized view for reviewing large numbers of issues.
50    pub sort: bool,
51
52    /// the minimum issue severity to be shown in the report.
53    ///
54    /// Issues below this level will be completely ignored and not displayed.
55    pub minimum_report_level: Option<Level>,
56
57    /// Optional editor URL template for OSC 8 terminal hyperlinks on file paths.
58    ///
59    /// Supported placeholders: `%file%` (absolute path), `%line%`, `%column%`.
60    /// Example: `"phpstorm://open?file=%file%&line=%line%"`
61    pub editor_url: Option<String>,
62}
63
64/// Status information returned after reporting issues.
65///
66/// This struct provides detailed statistics about the reporting operation,
67/// including baseline filtering results and severity level information.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct ReportStatus {
70    /// Indicates whether the baseline contains dead issues.
71    pub baseline_dead_issues: bool,
72
73    /// The number of issues that were filtered out by the baseline.
74    pub baseline_filtered_issues: usize,
75
76    /// The highest severity level among the reported issues.
77    pub highest_reported_level: Option<Level>,
78
79    /// The lowest severity level among the reported issues.
80    pub lowest_reported_level: Option<Level>,
81
82    /// The total number of issues reported.
83    pub total_reported_issues: usize,
84}
85
86/// The main reporter that handles formatting and outputting issues.
87///
88/// The reporter takes a collection of issues and outputs them according to
89/// the configured format and options. It can apply baseline filtering,
90/// severity filtering, and sorting before output.
91#[derive(Debug)]
92pub struct Reporter {
93    database: ReadDatabase,
94    config: ReporterConfig,
95}
96
97impl Reporter {
98    /// Create a new reporter with the given database and configuration.
99    #[must_use]
100    pub fn new(database: ReadDatabase, config: ReporterConfig) -> Self {
101        Self { database, config }
102    }
103
104    /// Report issues to the configured target.
105    ///
106    /// This method applies baseline filtering, severity filtering, and sorting
107    /// based on the reporter configuration, then formats and outputs the issues.
108    ///
109    /// # Errors
110    ///
111    /// Returns a [`ReportingError`] if formatting or writing the issues fails.
112    pub fn report(&self, issues: IssueCollection, baseline: Option<Baseline>) -> Result<ReportStatus, ReportingError> {
113        let mut writer = self.config.target.resolve();
114
115        let mut issues = issues;
116
117        // Apply baseline filtering
118        let mut baseline_has_dead_issues = false;
119        let mut baseline_filtered_issues = 0;
120        if let Some(baseline) = baseline {
121            let original_count = issues.len();
122            let comparison = baseline.compare_with_issues(&issues, &self.database);
123            let filtered_issues = baseline.filter_issues(issues, &self.database);
124
125            baseline_filtered_issues = original_count - filtered_issues.len();
126            baseline_has_dead_issues = comparison.removed_issues_count > 0;
127            issues = filtered_issues;
128        }
129
130        // Track reported issue stats before formatting
131        let total_reported_issues = issues.len();
132        let highest_reported_level = issues.get_highest_level();
133        let lowest_reported_level = issues.get_lowest_level();
134
135        // Early return if no issues to report
136        if total_reported_issues == 0 {
137            return Ok(ReportStatus {
138                baseline_dead_issues: baseline_has_dead_issues,
139                baseline_filtered_issues,
140                highest_reported_level: None,
141                lowest_reported_level: None,
142                total_reported_issues: 0,
143            });
144        }
145
146        // Build formatter config
147        let formatter_config = FormatterConfig {
148            color_choice: self.config.color_choice,
149            sort: self.config.sort,
150            minimum_level: self.config.minimum_report_level,
151            filter_fixable: self.config.filter_fixable,
152            editor_url: self.config.editor_url.clone(),
153        };
154
155        // Dispatch to the appropriate formatter
156        dispatch_format(self.config.format, &mut *writer, &issues, &self.database, &formatter_config)?;
157        // When writing to pipes, some formatters do not flush the last line of json
158        writer.flush()?;
159
160        Ok(ReportStatus {
161            baseline_dead_issues: baseline_has_dead_issues,
162            baseline_filtered_issues,
163            highest_reported_level,
164            lowest_reported_level,
165            total_reported_issues,
166        })
167    }
168
169    /// Report issues to a custom writer.
170    ///
171    /// This method allows writing to any `Write` implementation, making it useful
172    /// for testing, capturing output to strings, writing to files, or streaming
173    /// over network sockets.
174    ///
175    /// # Errors
176    ///
177    /// Returns a [`ReportingError`] if formatting or writing the issues fails.
178    ///
179    /// # Examples
180    ///
181    /// ```ignore
182    /// // Write to a buffer for testing
183    /// let mut buffer = Vec::new();
184    /// reporter.report_to(issues, None, &mut buffer)?;
185    /// let output = String::from_utf8(buffer)?;
186    ///
187    /// // Write to a file
188    /// let mut file = File::create("report.txt")?;
189    /// reporter.report_to(issues, None, &mut file)?;
190    /// ```
191    pub fn report_to<W: Write>(
192        &self,
193        issues: IssueCollection,
194        baseline: Option<Baseline>,
195        writer: &mut W,
196    ) -> Result<ReportStatus, ReportingError> {
197        let mut issues = issues;
198
199        // Apply baseline filtering
200        let mut baseline_has_dead_issues = false;
201        let mut baseline_filtered_issues = 0;
202        if let Some(baseline) = baseline {
203            let original_count = issues.len();
204            let comparison = baseline.compare_with_issues(&issues, &self.database);
205            let filtered_issues = baseline.filter_issues(issues, &self.database);
206
207            baseline_filtered_issues = original_count - filtered_issues.len();
208            baseline_has_dead_issues = comparison.removed_issues_count > 0;
209            issues = filtered_issues;
210        }
211
212        // Track reported issue stats before formatting
213        let total_reported_issues = issues.len();
214        let highest_reported_level = issues.get_highest_level();
215        let lowest_reported_level = issues.get_lowest_level();
216
217        // Early return if no issues to report
218        if total_reported_issues == 0 {
219            return Ok(ReportStatus {
220                baseline_dead_issues: baseline_has_dead_issues,
221                baseline_filtered_issues,
222                highest_reported_level: None,
223                lowest_reported_level: None,
224                total_reported_issues: 0,
225            });
226        }
227
228        // Build formatter config
229        let formatter_config = FormatterConfig {
230            color_choice: self.config.color_choice,
231            sort: self.config.sort,
232            minimum_level: self.config.minimum_report_level,
233            filter_fixable: self.config.filter_fixable,
234            editor_url: self.config.editor_url.clone(),
235        };
236
237        // Dispatch to the appropriate formatter
238        dispatch_format(self.config.format, writer, &issues, &self.database, &formatter_config)?;
239
240        Ok(ReportStatus {
241            baseline_dead_issues: baseline_has_dead_issues,
242            baseline_filtered_issues,
243            highest_reported_level,
244            lowest_reported_level,
245            total_reported_issues,
246        })
247    }
248}