wowlab_tidy/runner/
report.rs1use std::{collections::BTreeMap, process::ExitCode};
5
6use tabled::Tabled;
7use wowlab_common::output;
8use wowlab_fs::{
9 file,
10 path::{Path, PathBuf},
11};
12
13use crate::{
14 Config, Fix, Rule, Violation,
15 infra::{ignore, walk},
16};
17
18pub fn report_suppressions(
20 crate_filter: &[String],
21 config: &Config,
22 root: &Path,
23 crates_dir: &Path,
24 quiet: bool,
25) -> ExitCode {
26 let paths = match walk::rs_paths(crates_dir, crate_filter) {
27 Ok(paths) => paths,
28 Err(error) => {
29 if !quiet {
30 output::error(&format!("suppression report aborted: {error}"));
31 }
32
33 return ExitCode::FAILURE;
34 }
35 };
36
37 let registered_names: std::collections::HashSet<&str> = inventory::iter::<Rule>
38 .into_iter()
39 .map(|rule| rule.info.name)
40 .collect();
41
42 let all_entries = match load_suppressions(&paths, root, ®istered_names) {
43 Ok(entries) => entries,
44 Err(failures) => return report_read_failures(failures, quiet),
45 };
46
47 if quiet {
48 eprintln!("{}", all_entries.len());
50
51 return ExitCode::SUCCESS;
52 }
53
54 if all_entries.is_empty() {
55 output::success("no suppression directives found");
56 output::blank();
57
58 return ExitCode::SUCCESS;
59 }
60
61 let _ = config;
62 let mut by_rule: BTreeMap<String, Vec<&ignore::SuppressionEntry>> = BTreeMap::new();
63
64 for entry in &all_entries {
65 if entry.wildcard {
66 by_rule.entry("*".to_string()).or_default().push(entry);
67 } else {
68 for rule in &entry.rules {
69 by_rule.entry(rule.clone()).or_default().push(entry);
71 }
72 }
73 }
74
75 let mut rows: Vec<SuppressionRow> = Vec::with_capacity(by_rule.len());
76
77 for (rule, entries) in by_rule {
78 output::header(&format!("{rule} ({} suppression(s))", entries.len()));
79
80 for e in &entries {
81 output::detail(&format!(
82 " {}:{} [{}] {}",
83 e.rel,
84 e.line,
85 e.scope.prefix(),
86 e.reason,
87 ));
88 }
89
90 output::blank();
91 rows.push(SuppressionRow {
92 rule,
93 count: entries.len(),
94 });
95 }
96
97 rows.sort_by_key(|a| std::cmp::Reverse(a.count));
98 output::separator();
99 output::blank();
100 output::table(rows);
101 output::blank();
102 output::detail(&format!(
103 "{} directive(s) across {} file(s)",
104 all_entries.len(),
105 {
106 let mut files: std::collections::HashSet<&str> = std::collections::HashSet::new();
107 for e in &all_entries {
108 files.insert(&e.rel);
109 }
110 files.len()
111 }
112 ));
113 output::blank();
114
115 ExitCode::SUCCESS
116}
117
118fn load_suppressions(
119 paths: &[PathBuf],
120 root: &Path,
121 registered_names: &std::collections::HashSet<&str>,
122) -> Result<Vec<ignore::SuppressionEntry>, Vec<String>> {
123 let mut all_entries = Vec::new();
124 let mut failures = Vec::with_capacity(paths.len());
125
126 for path in paths {
127 let contents = match file::read_text(path) {
128 Ok(contents) => contents,
129 Err(error) => {
130 let rel = path.strip_prefix(root).unwrap_or(path).display();
131
132 failures.push(format!("{rel}: failed to read source: {error}"));
133 continue;
134 }
135 };
136 let rel = path
137 .strip_prefix(root)
138 .unwrap_or(path)
139 .display()
140 .to_string();
141 let lines: Vec<&str> = contents.lines().collect();
142 let directive_lines = crate::infra::scanner::directive_source_lines(&contents, &lines);
143 let mut errors = Vec::new();
144 let suppressed =
145 ignore::suppressed_lines(&rel, &directive_lines, &mut errors, Some(registered_names));
146
147 all_entries.extend(suppressed.entries);
148 }
149
150 if failures.is_empty() {
151 Ok(all_entries)
152 } else {
153 failures.sort_unstable();
154 failures.dedup();
155
156 Err(failures)
157 }
158}
159
160fn report_read_failures(failures: Vec<String>, quiet: bool) -> ExitCode {
161 if !quiet {
162 for failure in failures {
163 output::error(&failure);
164 }
165
166 output::error("suppression report aborted because analysis was incomplete");
167 }
168
169 ExitCode::FAILURE
170}
171
172#[derive(Tabled)]
173#[tabled(crate = "tabled")]
174struct SuppressionRow {
175 #[tabled(rename = "Rule")]
176 rule: String,
177 #[tabled(rename = "Count")]
178 count: usize,
179}
180
181pub(super) fn print_grouped(violations: &[Violation]) {
182 let mut by_crate: BTreeMap<&str, Vec<&Violation>> = BTreeMap::new();
183 let mut by_rule: BTreeMap<&str, usize> = BTreeMap::new();
184
185 for v in violations {
186 by_crate
187 .entry(extract_crate_name(&v.rel))
188 .or_default()
189 .push(v);
190 *by_rule.entry(v.rule_name()).or_default() += 1;
191 }
192
193 for (crate_name, violations) in &by_crate {
194 output::header(&format!("{crate_name} ({} issues)", violations.len()));
195
196 for v in violations {
197 output::error(&format!("{}:{}: {}", v.rel, v.line, v.message));
198 }
199
200 output::blank();
201 }
202
203 output::separator();
204 output::blank();
205
206 let mut fixable_rules: std::collections::HashSet<&str> = std::collections::HashSet::new();
207
208 for rule in inventory::iter::<Rule> {
209 if rule.fix.is_some() {
210 fixable_rules.insert(rule.info.name);
211 }
212 }
213
214 let mut rule_counts: Vec<RuleSummaryRow> = by_rule
215 .into_iter()
216 .map(|(rule, count)| RuleSummaryRow {
217 rule: rule.to_string(),
218 fixable: if fixable_rules.contains(rule) {
219 "yes".to_string()
220 } else {
221 String::new()
222 },
223 count,
224 })
225 .collect();
226
227 rule_counts.sort_by_key(|a| std::cmp::Reverse(a.count));
228
229 output::table(rule_counts);
230 output::blank();
231
232 let fixable_count: usize = violations
233 .iter()
234 .filter(|v| fixable_rules.contains(v.rule_name()))
235 .count();
236
237 output::error(&format!(
238 "{} violation(s) in {} crate(s)",
239 violations.len(),
240 by_crate.len()
241 ));
242
243 if fixable_count > 0 {
244 output::detail(&format!(
245 "{fixable_count} of these can be auto-fixed with --fix"
246 ));
247 }
248}
249
250#[derive(Tabled)]
251#[tabled(crate = "tabled")]
252struct RuleSummaryRow {
253 #[tabled(rename = "Rule")]
254 rule: String,
255 #[tabled(rename = "Fixable")]
256 fixable: String,
257 #[tabled(rename = "Count")]
258 count: usize,
259}
260
261pub(super) fn print_dry_run(fixes: &[(String, Fix)]) {
263 let mut by_file: BTreeMap<&str, Vec<&Fix>> = BTreeMap::new();
264
265 for (rel, fix) in fixes {
266 by_file.entry(rel).or_default().push(fix);
267 }
268
269 for (rel, file_fixes) in &by_file {
270 output::header(&format!("{rel} ({} fix(es))", file_fixes.len()));
271
272 for f in file_fixes {
273 if f.replacement.is_empty() {
274 output::detail(&format!(" delete lines {}-{}", f.start_line, f.end_line));
275 } else {
276 let new_lines = f.replacement.lines().count();
277
278 output::detail(&format!(
279 " replace lines {}-{} ({} line(s))",
280 f.start_line, f.end_line, new_lines
281 ));
282 }
283 }
284
285 output::blank();
286 }
287}
288
289fn extract_crate_name(rel: &str) -> &str {
290 let mut segs = rel.splitn(3, '/');
292
293 match (segs.next(), segs.next()) {
294 (Some("crates"), Some(name)) => name,
295 _ => rel,
296 }
297}