Skip to main content

wowlab_tidy/runner/
report.rs

1// #t(file: rust_alloc_in_loop) violation report renderer
2// #t(file: rust_default_hasher) trusted rule-name sets in the cold report path; fast-hasher dependency not warranted
3
4use 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
18/// Print every `#t(...)` directive found across the selected crates as a grouped table.
19pub 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, &registered_names) {
43        Ok(entries) => entries,
44        Err(failures) => return report_read_failures(failures, quiet),
45    };
46
47    if quiet {
48        // #t(rust_println) quiet mode only prints count
49        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                // #t(rust_clone_in_loop) need owned key for map
70                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
261// #t(fn: rust_alloc_in_loop) terminal output requires format! per fix
262pub(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    // #t(rust_magic_numbers) splitn(3) = "crates" / "<name>" / "<rest>"
291    let mut segs = rel.splitn(3, '/');
292
293    match (segs.next(), segs.next()) {
294        (Some("crates"), Some(name)) => name,
295        _ => rel,
296    }
297}