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 std::collections::BTreeSet;
6
7use crate::offence::Offence;
8use crate::offence_threshold::OffenceThreshold;
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}
20
21impl ReportPrinter {
22    pub fn new(files_scanned: usize) -> Self {
23        Self {
24            files_scanned,
25            threshold: OffenceThreshold::default(),
26            applied: Vec::new(),
27            skipped: Vec::new(),
28            unconfigured: Vec::new(),
29        }
30    }
31
32    pub fn with_rules(
33        self,
34        applied: Vec<String>,
35        skipped: Vec<String>,
36        unconfigured: Vec<String>,
37    ) -> Self {
38        Self {
39            applied,
40            skipped,
41            unconfigured,
42            ..self
43        }
44    }
45
46    pub fn with_threshold(self, threshold: OffenceThreshold) -> Self {
47        Self { threshold, ..self }
48    }
49
50    pub fn print(&self, offences: &[Offence]) {
51        println!("{}", self.render(offences));
52    }
53
54    // Returns the report rather than writing it, so the shape is assertable in a
55    // test instead of being checked for not panicking.
56    pub fn render(&self, offences: &[Offence]) -> String {
57        let mut report = String::from("stern4rust report\n\n");
58        if offences.is_empty() {
59            report.push_str(self.clean_verdict());
60            report.push_str("\n\n");
61            report.push_str(&self.roster());
62            report.push_str(&self.summary(offences));
63            return report;
64        }
65
66        // Sized to what is shown, so one withheld offence with a very long path
67        // cannot widen a column nothing in the report occupies.
68        let shown = self.threshold.kept(offences);
69        let widths = ColumnWidths::of(shown);
70        report.push_str(&Self::heading(&widths));
71        for offence in shown {
72            report.push_str(&Self::row(offence, &widths));
73            report.push_str(&Self::correction_row(offence, &widths));
74        }
75        report.push('\n');
76        report.push_str(&self.omission(offences));
77        report.push_str(&self.roster());
78        report.push_str(&self.summary(offences));
79        report
80    }
81
82    // "All rules are satisfied" is only true when all of them ran. Saying it
83    // after --skip turned two off, or after the header rule was dropped for
84    // want of a header file, would be the tool telling the comfortable lie it
85    // exists to catch.
86    fn clean_verdict(&self) -> &'static str {
87        if self.everything_ran() {
88            "All rules are satisfied."
89        } else {
90            "All applied rules are satisfied."
91        }
92    }
93
94    fn everything_ran(&self) -> bool {
95        self.skipped.is_empty() && self.unconfigured.is_empty()
96    }
97
98    // Named, not counted. A count answers "how many", which is only useful to a
99    // reader who already knows how many there are.
100    fn roster(&self) -> String {
101        if self.applied.is_empty() {
102            return String::new();
103        }
104        let mut roster = format!("  applied: {}\n", self.applied.join(", "));
105        if !self.everything_ran() {
106            roster.push_str(&format!("  not applied: {}\n", self.absences().join(", ")));
107        }
108        roster.push('\n');
109        roster
110    }
111
112    // Skipped and unconfigured are both "did not run" and are not the same
113    // thing. One is a choice the reader made; the other is a flag they did not
114    // pass, and saying which is the difference between a note and an
115    // instruction.
116    fn absences(&self) -> Vec<String> {
117        self.skipped
118            .iter()
119            .map(|name| format!("{name} (skipped)"))
120            .chain(
121                self.unconfigured
122                    .iter()
123                    .map(|name| format!("{name} (needs --header-file)")),
124            )
125            .collect()
126    }
127
128    // Named alongside the flag that raises it. A cap nobody was told about reads
129    // as "that was all of them", which is the one thing this report must never
130    // say when it is not true.
131    fn omission(&self, offences: &[Offence]) -> String {
132        let omitted = self.threshold.omitted(offences);
133        if omitted == 0 {
134            return String::new();
135        }
136        format!(
137            "... and {omitted} more offences not shown. Raise --offence-threshold \
138             (currently {}, use 0 for all) to see them.\n\n",
139            self.threshold.limit()
140        )
141    }
142
143    fn heading(widths: &ColumnWidths) -> String {
144        format!(
145            "{:<file$}  {:>line$}  {:<rule$}  offence\n{}  {}  {}  {}\n",
146            "file",
147            "line",
148            "rule",
149            "-".repeat(widths.file),
150            "-".repeat(widths.line),
151            "-".repeat(widths.rule),
152            "-".repeat(widths.description),
153            file = widths.file,
154            line = widths.line,
155            rule = widths.rule
156        )
157    }
158
159    fn row(offence: &Offence, widths: &ColumnWidths) -> String {
160        format!(
161            "{:<file$}  {:>line$}  {:<rule$}  {}\n",
162            offence.file,
163            offence.line,
164            offence.rule,
165            offence.description,
166            file = widths.file,
167            line = widths.line,
168            rule = widths.rule
169        )
170    }
171
172    // On its own line beneath the offence rather than in a fifth column. The
173    // description column is already the widest thing in the report, and a
174    // correction is a sentence rather than a field -- side by side, neither
175    // would be readable.
176    fn correction_row(offence: &Offence, widths: &ColumnWidths) -> String {
177        let indent = widths.file + widths.line + widths.rule + 6;
178        format!("{}fix: {}\n", " ".repeat(indent), offence.correction)
179    }
180
181    fn summary(&self, offences: &[Offence]) -> String {
182        let broken: BTreeSet<&str> = offences.iter().map(|offence| offence.rule).collect();
183        format!(
184            "summary: files_scanned={} offences={} rules_broken={} rules_applied={} \
185             rules_skipped={} rules_unconfigured={}",
186            self.files_scanned,
187            offences.len(),
188            broken.len(),
189            self.applied.len(),
190            self.skipped.len(),
191            self.unconfigured.len()
192        )
193    }
194}
195
196struct ColumnWidths {
197    file: usize,
198    line: usize,
199    rule: usize,
200    description: usize,
201}
202
203impl ColumnWidths {
204    fn of(offences: &[Offence]) -> Self {
205        Self {
206            file: Self::widest(offences.iter().map(|offence| offence.file.len()), "file"),
207            line: Self::widest(
208                offences
209                    .iter()
210                    .map(|offence| offence.line.to_string().len()),
211                "line",
212            ),
213            rule: Self::widest(offences.iter().map(|offence| offence.rule.len()), "rule"),
214            description: Self::widest(
215                offences.iter().map(|offence| offence.description.len()),
216                "offence",
217            ),
218        }
219    }
220
221    fn widest<I: Iterator<Item = usize>>(lengths: I, heading: &str) -> usize {
222        lengths.max().unwrap_or(0).max(heading.len())
223    }
224}