1use crate::column_widths::ColumnWidths;
6use crate::offence::Offence;
7use crate::offence_threshold::OffenceThreshold;
8use std::collections::BTreeSet;
9
10pub 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 pub fn with_fixed(self, fixed: usize) -> Self {
48 Self { fixed, ..self }
49 }
50
51 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 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 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 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 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 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 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 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 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 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 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 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 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}