Skip to main content

stern4rust/
report_printer.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use crate::column_widths::ColumnWidths;
6use crate::offence::Offence;
7use crate::offence_threshold::OffenceThreshold;
8use std::collections::BTreeSet;
9
10// One table for every rule. Columns are sized to their contents so the report
11// stays readable when a rule name or a path grows, and the summary line is
12// greppable so a wrapper script can report a count without parsing the table.
13pub struct ReportPrinter {
14    files_scanned: usize,
15    threshold: OffenceThreshold,
16    applied: Vec<String>,
17    skipped: Vec<String>,
18    unconfigured: Vec<String>,
19    exclusions: Vec<(String, usize)>,
20    config_file: Option<String>,
21    baseline: Option<String>,
22    suppressed: usize,
23    stale: usize,
24    fixed: usize,
25}
26
27impl ReportPrinter {
28    pub fn new(files_scanned: usize) -> Self {
29        Self {
30            files_scanned,
31            threshold: OffenceThreshold::default(),
32            applied: Vec::new(),
33            skipped: Vec::new(),
34            unconfigured: Vec::new(),
35            exclusions: Vec::new(),
36            config_file: None,
37            baseline: None,
38            suppressed: 0,
39            stale: 0,
40            fixed: 0,
41        }
42    }
43
44    // How many files --fix repaired. Stated alongside what is left, because a
45    // fixer reporting only its successes would be the same silence this tool
46    // refuses everywhere else.
47    pub fn with_fixed(self, fixed: usize) -> Self {
48        Self { fixed, ..self }
49    }
50
51    // A run that reported nothing while a baseline hid four hundred findings
52    // would be the most comfortable lie this tool could tell, so the count is
53    // in the summary of every run that used one -- including when it is the
54    // whole story and the report itself is empty.
55    pub fn with_baseline(self, baseline: Option<String>, suppressed: usize, stale: usize) -> Self {
56        Self {
57            baseline,
58            suppressed,
59            stale,
60            ..self
61        }
62    }
63
64    // A run configured by a file the reader never typed on the command line
65    // must say so, or the switches in force are invisible.
66    pub fn with_config_file(self, config_file: Option<String>) -> Self {
67        Self {
68            config_file,
69            ..self
70        }
71    }
72
73    pub fn with_exclusions(self, exclusions: Vec<(String, usize)>) -> Self {
74        Self { exclusions, ..self }
75    }
76
77    pub fn with_rules(
78        self,
79        applied: Vec<String>,
80        skipped: Vec<String>,
81        unconfigured: Vec<String>,
82    ) -> Self {
83        Self {
84            applied,
85            skipped,
86            unconfigured,
87            ..self
88        }
89    }
90
91    pub fn with_threshold(self, threshold: OffenceThreshold) -> Self {
92        Self { threshold, ..self }
93    }
94
95    pub fn print(&self, offences: &[Offence]) {
96        println!("{}", self.render(offences));
97    }
98
99    // Returns the report rather than writing it, so the shape is assertable in a
100    // test instead of being checked for not panicking.
101    pub fn render(&self, offences: &[Offence]) -> String {
102        let mut report = String::from("stern4rust report\n\n");
103        if offences.is_empty() {
104            report.push_str(self.clean_verdict());
105            report.push_str("\n\n");
106            report.push_str(&self.roster());
107            report.push_str(&self.exclusion_roster());
108            report.push_str(&self.config_line());
109            report.push_str(&self.baseline_line());
110            report.push_str(&self.fixed_line());
111            report.push_str(&self.summary(offences));
112            return report;
113        }
114
115        // Sized to what is shown, so one withheld offence with a very long path
116        // cannot widen a column nothing in the report occupies.
117        let shown = self.threshold.kept(offences);
118        let widths = ColumnWidths::of(shown);
119        report.push_str(&Self::heading(&widths));
120        for offence in shown {
121            report.push_str(&Self::row(offence, &widths));
122            report.push_str(&Self::correction_row(offence, &widths));
123        }
124        report.push('\n');
125        report.push_str(&self.omission(offences));
126        report.push_str(&self.roster());
127        report.push_str(&self.exclusion_roster());
128        report.push_str(&self.config_line());
129        report.push_str(&self.baseline_line());
130        report.push_str(&self.fixed_line());
131        report.push_str(&self.summary(offences));
132        report
133    }
134
135    // "All rules are satisfied" is only true when all of them ran. Saying it
136    // after --skip turned two off, or after the header rule was dropped for
137    // want of a header file, would be the tool telling the comfortable lie it
138    // exists to catch.
139    fn clean_verdict(&self) -> &'static str {
140        if self.everything_ran() {
141            "All rules are satisfied."
142        } else {
143            "All applied rules are satisfied."
144        }
145    }
146
147    fn everything_ran(&self) -> bool {
148        self.skipped.is_empty() && self.unconfigured.is_empty()
149    }
150
151    // Named, not counted. A count answers "how many", which is only useful to a
152    // reader who already knows how many there are.
153    fn roster(&self) -> String {
154        if self.applied.is_empty() {
155            return String::new();
156        }
157        let mut roster = format!("  applied: {}\n", self.applied.join(", "));
158        if !self.everything_ran() {
159            roster.push_str(&format!("  not applied: {}\n", self.absences().join(", ")));
160        }
161        roster.push('\n');
162        roster
163    }
164
165    // What --fix repaired, stated beside what it could not. A fixer reporting
166    // only its successes would leave the reader believing the file is done.
167    fn fixed_line(&self) -> String {
168        if self.fixed == 0 {
169            return String::new();
170        }
171        format!("  fixed: {} file(s) rewritten\n\n", self.fixed)
172    }
173
174    // Named with its count, because a run that reported nothing while a
175    // baseline hid four hundred findings would be the most comfortable lie this
176    // tool could tell. A stale entry is called out for the same reason a dead
177    // --exclude pattern is: it describes an offence somebody has since fixed,
178    // and until the file is rewritten it makes the baseline look like it is
179    // still holding something back.
180    fn baseline_line(&self) -> String {
181        let Some(path) = &self.baseline else {
182            return String::new();
183        };
184        let mut line = format!("  baseline: {path} ({} suppressed)\n", self.suppressed);
185        if self.stale > 0 {
186            line.push_str(&format!(
187                "  {} baseline entries matched nothing -- rerun with --write-baseline to \
188                 refresh it\n",
189                self.stale
190            ));
191        }
192        line.push('\n');
193        line
194    }
195
196    // A run configured by a file the reader never typed must say which file.
197    // Every switch in force would otherwise be invisible, and a report that
198    // applied one rule because of a line in a .toml would look exactly like one
199    // that applied one rule because somebody asked for it.
200    fn config_line(&self) -> String {
201        match &self.config_file {
202            Some(path) => format!("  config: {path}\n\n"),
203            None => String::new(),
204        }
205    }
206
207    // Every pattern with the number of files it removed, including zero. A
208    // pattern that matched nothing is the one the reader most needs to see:
209    // it names a tree that has moved or been deleted, and until somebody is
210    // told, it goes on looking like it is doing work.
211    fn exclusion_roster(&self) -> String {
212        if self.exclusions.is_empty() {
213            return String::new();
214        }
215        let listed: Vec<String> = self
216            .exclusions
217            .iter()
218            .map(|(pattern, count)| format!("{pattern} ({count} files)"))
219            .collect();
220        let mut roster = format!("  excluded: {}\n", listed.join(", "));
221        let dead = self.unmatched();
222        if !dead.is_empty() {
223            roster.push_str(&format!(
224                "  matched nothing: {} -- delete the pattern or correct it\n",
225                dead.join(", ")
226            ));
227        }
228        roster.push('\n');
229        roster
230    }
231
232    fn unmatched(&self) -> Vec<&str> {
233        self.exclusions
234            .iter()
235            .filter(|(_, count)| *count == 0)
236            .map(|(pattern, _)| pattern.as_str())
237            .collect()
238    }
239
240    // Skipped and unconfigured are both "did not run" and are not the same
241    // thing. One is a choice the reader made; the other is a flag they did not
242    // pass, and saying which is the difference between a note and an
243    // instruction.
244    fn absences(&self) -> Vec<String> {
245        self.skipped
246            .iter()
247            .map(|name| format!("{name} (skipped)"))
248            .chain(
249                self.unconfigured
250                    .iter()
251                    .map(|name| format!("{name} (needs --header-file)")),
252            )
253            .collect()
254    }
255
256    // Named alongside the flag that raises it. A cap nobody was told about reads
257    // as "that was all of them", which is the one thing this report must never
258    // say when it is not true.
259    fn omission(&self, offences: &[Offence]) -> String {
260        let omitted = self.threshold.omitted(offences);
261        if omitted == 0 {
262            return String::new();
263        }
264        format!(
265            "... and {omitted} more offences not shown. Raise --offence-threshold \
266             (currently {}, use 0 for all) to see them.\n\n",
267            self.threshold.limit()
268        )
269    }
270
271    fn heading(widths: &ColumnWidths) -> String {
272        format!(
273            "{:<file$}  {:>line$}  {:<rule$}  offence\n{}  {}  {}  {}\n",
274            "file",
275            "line",
276            "rule",
277            "-".repeat(widths.file),
278            "-".repeat(widths.line),
279            "-".repeat(widths.rule),
280            "-".repeat(widths.description),
281            file = widths.file,
282            line = widths.line,
283            rule = widths.rule
284        )
285    }
286
287    fn row(offence: &Offence, widths: &ColumnWidths) -> String {
288        format!(
289            "{:<file$}  {:>line$}  {:<rule$}  {}\n",
290            offence.file,
291            offence.line,
292            offence.rule,
293            offence.description,
294            file = widths.file,
295            line = widths.line,
296            rule = widths.rule
297        )
298    }
299
300    // On its own line beneath the offence rather than in a fifth column. The
301    // description column is already the widest thing in the report, and a
302    // correction is a sentence rather than a field -- side by side, neither
303    // would be readable.
304    fn correction_row(offence: &Offence, widths: &ColumnWidths) -> String {
305        let indent = widths.file + widths.line + widths.rule + 6;
306        format!("{}fix: {}\n", " ".repeat(indent), offence.correction)
307    }
308
309    fn excluded_total(&self) -> usize {
310        self.exclusions.iter().map(|(_, count)| count).sum()
311    }
312
313    fn summary(&self, offences: &[Offence]) -> String {
314        let broken: BTreeSet<&str> = offences.iter().map(|offence| offence.rule).collect();
315        format!(
316            "summary: files_scanned={} files_excluded={} offences={} rules_broken={} \
317             rules_applied={} rules_skipped={} rules_unconfigured={}",
318            self.files_scanned,
319            self.excluded_total(),
320            offences.len(),
321            broken.len(),
322            self.applied.len(),
323            self.skipped.len(),
324            self.unconfigured.len()
325        )
326    }
327}