Skip to main content

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