1use 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
11pub 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 pub fn with_fixed(self, fixed: usize) -> Self {
51 Self { fixed, ..self }
52 }
53
54 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}