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}
17
18impl ReportPrinter {
19    pub fn new(files_scanned: usize) -> Self {
20        Self {
21            files_scanned,
22            threshold: OffenceThreshold::default(),
23        }
24    }
25
26    pub fn with_threshold(self, threshold: OffenceThreshold) -> Self {
27        Self { threshold, ..self }
28    }
29
30    pub fn print(&self, offences: &[Offence]) {
31        println!("{}", self.render(offences));
32    }
33
34    // Returns the report rather than writing it, so the shape is assertable in a
35    // test instead of being checked for not panicking.
36    pub fn render(&self, offences: &[Offence]) -> String {
37        let mut report = String::from("stern4rust report\n\n");
38        if offences.is_empty() {
39            report.push_str("All rules are satisfied.\n\n");
40            report.push_str(&self.summary(offences));
41            return report;
42        }
43
44        // Sized to what is shown, so one withheld offence with a very long path
45        // cannot widen a column nothing in the report occupies.
46        let shown = self.threshold.kept(offences);
47        let widths = ColumnWidths::of(shown);
48        report.push_str(&Self::heading(&widths));
49        for offence in shown {
50            report.push_str(&Self::row(offence, &widths));
51            report.push_str(&Self::correction_row(offence, &widths));
52        }
53        report.push('\n');
54        report.push_str(&self.omission(offences));
55        report.push_str(&self.summary(offences));
56        report
57    }
58
59    // Named alongside the flag that raises it. A cap nobody was told about reads
60    // as "that was all of them", which is the one thing this report must never
61    // say when it is not true.
62    fn omission(&self, offences: &[Offence]) -> String {
63        let omitted = self.threshold.omitted(offences);
64        if omitted == 0 {
65            return String::new();
66        }
67        format!(
68            "... and {omitted} more offences not shown. Raise --offence-threshold \
69             (currently {}, use 0 for all) to see them.\n\n",
70            self.threshold.limit()
71        )
72    }
73
74    fn heading(widths: &ColumnWidths) -> String {
75        format!(
76            "{:<file$}  {:>line$}  {:<rule$}  offence\n{}  {}  {}  {}\n",
77            "file",
78            "line",
79            "rule",
80            "-".repeat(widths.file),
81            "-".repeat(widths.line),
82            "-".repeat(widths.rule),
83            "-".repeat(widths.description),
84            file = widths.file,
85            line = widths.line,
86            rule = widths.rule
87        )
88    }
89
90    fn row(offence: &Offence, widths: &ColumnWidths) -> String {
91        format!(
92            "{:<file$}  {:>line$}  {:<rule$}  {}\n",
93            offence.file,
94            offence.line,
95            offence.rule,
96            offence.description,
97            file = widths.file,
98            line = widths.line,
99            rule = widths.rule
100        )
101    }
102
103    // On its own line beneath the offence rather than in a fifth column. The
104    // description column is already the widest thing in the report, and a
105    // correction is a sentence rather than a field -- side by side, neither
106    // would be readable.
107    fn correction_row(offence: &Offence, widths: &ColumnWidths) -> String {
108        let indent = widths.file + widths.line + widths.rule + 6;
109        format!("{}fix: {}\n", " ".repeat(indent), offence.correction)
110    }
111
112    fn summary(&self, offences: &[Offence]) -> String {
113        let broken: BTreeSet<&str> = offences.iter().map(|offence| offence.rule).collect();
114        format!(
115            "summary: files_scanned={} offences={} rules_broken={}",
116            self.files_scanned,
117            offences.len(),
118            broken.len()
119        )
120    }
121}
122
123struct ColumnWidths {
124    file: usize,
125    line: usize,
126    rule: usize,
127    description: usize,
128}
129
130impl ColumnWidths {
131    fn of(offences: &[Offence]) -> Self {
132        Self {
133            file: Self::widest(offences.iter().map(|offence| offence.file.len()), "file"),
134            line: Self::widest(
135                offences
136                    .iter()
137                    .map(|offence| offence.line.to_string().len()),
138                "line",
139            ),
140            rule: Self::widest(offences.iter().map(|offence| offence.rule.len()), "rule"),
141            description: Self::widest(
142                offences.iter().map(|offence| offence.description.len()),
143                "offence",
144            ),
145        }
146    }
147
148    fn widest<I: Iterator<Item = usize>>(lengths: I, heading: &str) -> usize {
149        lengths.max().unwrap_or(0).max(heading.len())
150    }
151}