1use std::{
4 collections::{BTreeSet, HashSet},
5 process::ExitCode,
6};
7
8#[cfg(test)]
9use googletest::prelude::*;
10use wowlab_common::output;
11
12use super::RunCtx;
13use crate::{
14 FileCtx, Fix, Rule, Violation,
15 infra::{fix, ignore::SuppressionEntry, parse, walk},
16 languages::rust::{self, SuppressionAuditError},
17};
18
19const DEFAULT_FILE_SIZE_THRESHOLD: usize = 1500;
20const PREVIOUS_LINE_OFFSET: usize = 2;
21
22#[derive(Debug)]
23struct Removal {
24 rel: String,
25 line: usize,
26 targets: Box<[String]>,
27}
28
29#[derive(Debug, Default)]
30struct CleanPlan {
31 fixes: Vec<(String, Fix)>,
32 removals: Vec<Removal>,
33 failures: Vec<Box<str>>,
34 generated_files: usize,
35}
36
37#[derive(Debug)]
38struct AuditedFile {
39 rel: String,
40 contents: String,
41 suppressions: crate::infra::ignore::Suppressions,
42 raw_violations: Vec<Violation>,
43}
44
45#[derive(Debug)]
46enum AuditedPath {
47 Generated,
48 Source(Box<AuditedSource>),
49}
50
51#[derive(Debug)]
52struct AuditedSource {
53 selected: Option<AuditedFile>,
54 workspace_file: crate::languages::workspace::WorkspaceRustFile,
55}
56
57#[derive(Debug)]
58enum EntryPlanError {
59 Render,
60}
61
62#[must_use]
64pub fn clean_suppressions(ctx: &RunCtx<'_>, dry_run: bool) -> ExitCode {
65 let mut plan = build_plan(ctx);
66
67 if !plan.failures.is_empty() {
68 return report_failures(plan.failures, ctx.quiet);
69 }
70
71 plan.removals
72 .sort_by(|a, b| a.rel.cmp(&b.rel).then(a.line.cmp(&b.line)));
73
74 if plan.removals.is_empty() {
75 if !ctx.quiet {
76 output::success("no unused suppression directives found");
77
78 if plan.generated_files > 0 {
79 output::detail(&format!(
80 "skipped {} generated Rust file(s)",
81 plan.generated_files
82 ));
83 }
84
85 output::blank();
86 }
87
88 return ExitCode::SUCCESS;
89 }
90
91 let target_count: usize = plan
92 .removals
93 .iter()
94 .map(|removal| removal.targets.len())
95 .sum();
96
97 if !ctx.quiet {
98 print_removals(&plan.removals);
99 }
100
101 if dry_run {
102 if !ctx.quiet {
103 super::report::print_dry_run(&plan.fixes);
104 output::detail(&format!(
105 "{target_count} unused suppression target(s) would be removed (dry run)"
106 ));
107 output::blank();
108 }
109
110 return ExitCode::SUCCESS;
111 }
112
113 let applied = fix::apply_fixes(&plan.fixes, ctx.root);
114
115 if applied != plan.fixes.len() {
116 if !ctx.quiet {
117 output::error(&format!(
118 "applied {applied} of {} planned directive edit(s)",
119 plan.fixes.len()
120 ));
121 output::blank();
122 }
123
124 return ExitCode::FAILURE;
125 }
126
127 if !ctx.quiet {
128 output::success(&format!(
129 "removed {target_count} unused suppression target(s) in {applied} directive(s)"
130 ));
131 output::blank();
132 }
133
134 ExitCode::SUCCESS
135}
136
137fn report_failures(mut failures: Vec<Box<str>>, quiet: bool) -> ExitCode {
138 failures.sort_unstable();
139
140 if !quiet {
141 for failure in failures {
142 output::error(&failure);
143 }
144
145 output::blank();
146 output::error("clean aborted without writing because suppression analysis was incomplete");
147 output::blank();
148 }
149
150 ExitCode::FAILURE
151}
152
153fn build_plan(ctx: &RunCtx<'_>) -> CleanPlan {
154 let rules: Vec<&Rule> = inventory::iter::<Rule>
155 .into_iter()
156 .filter(|rule| rule.check.extensions().contains(&"rs"))
157 .collect();
158 let registered_names: HashSet<&str> = inventory::iter::<Rule>
159 .into_iter()
160 .map(|rule| rule.info.name)
161 .collect();
162 let mut plan = CleanPlan::default();
163 let (selected_rels, paths) = match cleaner_paths(ctx) {
164 Ok(paths) => paths,
165 Err(error) => {
166 plan.failures.push(error.to_string().into_boxed_str());
167
168 return plan;
169 }
170 };
171 let mut audited_files = Vec::new();
172 let mut workspace_files = Vec::new();
173
174 for path in paths {
175 let rel = relative_path(ctx, &path);
176 let selected = selected_rels.contains(&rel);
177
178 match audit_path(ctx, &path, rel, selected, &rules, ®istered_names) {
179 Ok(AuditedPath::Generated) => {
180 plan.generated_files += usize::from(selected);
181 }
182 Ok(AuditedPath::Source(source)) => {
183 workspace_files.push(source.workspace_file);
184
185 if let Some(file) = source.selected {
186 audited_files.push(file);
187 }
188 }
189 Err(failures) => plan.failures.extend(failures),
190 }
191 }
192
193 if !plan.failures.is_empty() {
194 return plan;
195 }
196
197 for violation in rust::audit_workspace_suppressions(&workspace_files, &rules, ctx.config) {
198 if let Some(file) = audited_files
199 .iter_mut()
200 .find(|file| file.rel == violation.rel)
201 {
202 file.raw_violations.push(violation);
203 }
204 }
205
206 for file in &mut audited_files {
207 file.raw_violations
208 .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(&b.rule)));
209 }
210
211 for file in audited_files {
212 plan_file(ctx, file, &rules, &mut plan);
213 }
214
215 plan
216}
217
218fn cleaner_paths(
219 ctx: &RunCtx<'_>,
220) -> Result<(BTreeSet<String>, Vec<wowlab_fs::path::PathBuf>), walk::PathDiscoveryError> {
221 let selected_paths = if ctx.dirty {
222 walk::git_dirty_paths(ctx.root, ctx.crates_dir, ctx.crate_filter, &["rs"])?
223 } else {
224 walk::rs_paths(ctx.crates_dir, ctx.crate_filter)?
225 };
226 let selected_rels = selected_paths
227 .iter()
228 .map(|path| relative_path(ctx, path))
229 .collect();
230 let paths = walk::rs_paths(ctx.crates_dir, &[])?;
231
232 Ok((selected_rels, paths))
233}
234
235fn audit_path(
236 ctx: &RunCtx<'_>,
237 path: &wowlab_fs::path::Path,
238 rel: String,
239 selected: bool,
240 rules: &[&Rule],
241 registered_names: &HashSet<&str>,
242) -> Result<AuditedPath, Vec<Box<str>>> {
243 let contents = match wowlab_fs::file::read_text(path) {
244 Ok(contents) => contents,
245 Err(error) => {
246 return Err(vec![
247 format!("{rel}: failed to read source: {error}").into_boxed_str(),
248 ]);
249 }
250 };
251
252 if is_generated(&contents) {
253 return Ok(AuditedPath::Generated);
254 }
255
256 let lines: Vec<&str> = contents.lines().collect();
257 let file = FileCtx {
258 rel: &rel,
259 path,
260 lines: &lines,
261 contents: &contents,
262 config: ctx.config,
263 };
264 let audit = match rust::audit_suppressions(&file, rules, registered_names) {
265 Ok(audit) => audit,
266 Err(SuppressionAuditError::RustSyntax) => {
267 return Err(vec![
268 format!("{rel}: Rust syntax could not be parsed").into_boxed_str(),
269 ]);
270 }
271 Err(SuppressionAuditError::InvalidDirectives(errors)) => {
272 return Err(errors
273 .into_iter()
274 .map(|error| {
275 format!("{}:{}: {}", error.rel, error.line, error.message).into_boxed_str()
276 })
277 .collect());
278 }
279 };
280
281 let selected = selected.then_some(AuditedFile {
282 rel,
283 contents,
284 suppressions: audit.suppressions,
285 raw_violations: audit.raw_violations,
286 });
287
288 Ok(AuditedPath::Source(Box::new(AuditedSource {
289 selected,
290 workspace_file: audit.workspace_file,
291 })))
292}
293
294fn relative_path(ctx: &RunCtx<'_>, path: &wowlab_fs::path::Path) -> String {
295 path.strip_prefix(ctx.root)
296 .unwrap_or(path)
297 .display()
298 .to_string()
299}
300
301fn plan_file(ctx: &RunCtx<'_>, audited: AuditedFile, rules: &[&Rule], plan: &mut CleanPlan) {
302 let AuditedFile {
303 rel,
304 contents,
305 suppressions,
306 raw_violations,
307 } = audited;
308 let lines: Vec<&str> = contents.lines().collect();
309
310 for entry in suppressions.entries {
311 let source_line = lines
312 .get(entry.line.saturating_sub(1))
313 .copied()
314 .unwrap_or("");
315 let budget_active = entry.values.contains_key("rust_too_many_lines_in_file")
316 && lines.len() > file_size_threshold(ctx.config, rules);
317 let Ok(planned) = plan_entry(&entry, &raw_violations, source_line, budget_active) else {
318 plan.failures.push(
319 format!(
320 "{rel}:{}: failed to render suppression directive",
321 entry.line
322 )
323 .into_boxed_str(),
324 );
325 continue;
326 };
327 let Some((mut fix, stale)) = planned else {
328 continue;
329 };
330
331 if fix.replacement.is_empty() && redundant_separator_after(&entry, &lines) {
332 fix.end_line = fix.end_line.saturating_add(1);
333 }
334
335 plan.fixes.push((rel.clone(), fix));
337 plan.removals.push(Removal {
338 rel: rel.clone(),
340 line: entry.line,
341 targets: stale.into_boxed_slice(),
342 });
343 }
344}
345
346fn redundant_separator_after(entry: &SuppressionEntry, lines: &[&str]) -> bool {
347 let next_is_blank = lines
348 .get(entry.line)
349 .is_some_and(|line| line.trim().is_empty());
350 let previous_is_blank = entry
351 .line
352 .checked_sub(PREVIOUS_LINE_OFFSET)
353 .and_then(|index| lines.get(index))
354 .is_some_and(|line| line.trim().is_empty());
355
356 next_is_blank
357 && (entry.line == 1 || previous_is_blank || matches!(entry.scope, parse::Scope::File))
358}
359
360fn print_removals(removals: &[Removal]) {
361 for removal in removals {
362 output::detail(&format!(
363 "{}:{} remove {}",
364 removal.rel,
365 removal.line,
366 removal.targets.join(", ")
367 ));
368 }
369
370 output::blank();
371}
372
373fn plan_entry(
374 entry: &SuppressionEntry,
375 raw_violations: &[Violation],
376 source_line: &str,
377 budget_active: bool,
378) -> Result<Option<(Fix, Vec<String>)>, EntryPlanError> {
379 if entry.wildcard {
380 return Ok((!raw_violations
381 .iter()
382 .any(|violation| entry.suppresses(violation)))
383 .then(|| (Fix::delete(entry.line, entry.line), vec!["*".to_string()])));
384 }
385
386 let active: Vec<&str> = entry
387 .rules
388 .iter()
389 .filter(|target| {
390 (target.as_str() == "rust_too_many_lines_in_file" && budget_active)
391 || raw_violations.iter().any(|violation| {
392 violation.rule == Some(target.as_str()) && entry.covers_line(violation.line)
393 })
394 })
395 .map(String::as_str)
396 .collect();
397 let stale: Vec<String> = entry
398 .rules
399 .iter()
400 .filter(|target| !active.contains(&target.as_str()))
401 .cloned()
402 .collect();
403
404 if stale.is_empty() {
405 return Ok(None);
406 }
407
408 let fix = if active.is_empty() {
409 Fix::delete(entry.line, entry.line)
410 } else {
411 let rendered: Vec<String> = active
412 .iter()
413 .map(|target| {
414 entry.values.get(*target).map_or_else(
415 || (*target).to_owned(),
416 |value| format!("{target} = {value}"),
417 )
418 })
419 .collect();
420 let rendered_refs: Vec<&str> = rendered.iter().map(String::as_str).collect();
421 let replacement = parse::rewrite_directive_rules(source_line, &entry.scope, &rendered_refs)
422 .ok_or(EntryPlanError::Render)?;
423
424 Fix::replace_line(entry.line, replacement)
425 };
426
427 Ok(Some((fix, stale)))
428}
429
430fn file_size_threshold(config: &crate::Config, rules: &[&Rule]) -> usize {
431 let parameter = rules
432 .iter()
433 .find(|rule| rule.info.name == "rust_too_many_lines_in_file")
434 .and_then(|rule| rule.info.params.first());
435
436 parameter.map_or(DEFAULT_FILE_SIZE_THRESHOLD, |param| {
437 config.get_usize("rust_too_many_lines_in_file", param)
438 })
439}
440
441fn is_generated(contents: &str) -> bool {
442 const GENERATED_HEADER_LINES: usize = 8;
443
444 contents
445 .lines()
446 .take(GENERATED_HEADER_LINES)
447 .any(|line| line.contains("@generated"))
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::{infra::ignore, violation};
454
455 fn entries(source: &str) -> Result<Vec<SuppressionEntry>> {
456 let lines: Vec<&str> = source.lines().collect();
457 let mut errors = Vec::new();
458 let suppressions = ignore::suppressed_lines("fixture.rs", &lines, &mut errors, None);
459
460 verify_true!(errors.is_empty())?;
461
462 Ok(suppressions.entries)
463 }
464
465 fn tagged(line: usize, rule: &'static str) -> Violation {
466 violation("fixture.rs", line, "fixture violation").with_rule(rule)
467 }
468
469 #[gtest]
470 fn generated_header_is_detected_without_scanning_body() -> Result<()> {
471 verify_true!(is_generated(
472 "//! @generated by codegen\npub fn generated() {}"
473 ))?;
474 verify_false!(is_generated(
475 "\n\n\n\n\n\n\n\n// @generated appears too late\npub fn handwritten() {}\n"
476 ))?;
477
478 Ok(())
479 }
480
481 #[gtest]
482 fn mixed_targets_rewrite_only_the_target_list() -> Result<()> {
483 let source =
484 " // #t(fn: rust_dbg, rust_panic) keep this exact reason\nfn f() { dbg!(1); }";
485 let entry = entries(source)?.pop().or_fail()?;
486
487 let (fix, stale) = plan_entry(
488 &entry,
489 &[tagged(2, "rust_dbg")],
490 source.lines().next().or_fail()?,
491 false,
492 )
493 .or_fail()?
494 .or_fail()?;
495
496 verify_eq!(stale, ["rust_panic"])?;
497 verify_eq!(
498 fix.replacement,
499 " // #t(fn: rust_dbg) keep this exact reason"
500 )?;
501
502 Ok(())
503 }
504
505 #[gtest]
506 fn fully_stale_directive_deletes_only_its_line() -> Result<()> {
507 let source = "// #t(rust_panic) stale target\nlet value = 1;";
508 let entry = entries(source)?.pop().or_fail()?;
509
510 let (fix, stale) = plan_entry(&entry, &[], source.lines().next().or_fail()?, false)
511 .or_fail()?
512 .or_fail()?;
513
514 verify_eq!(stale, ["rust_panic"])?;
515 verify_true!(fix.replacement.is_empty())?;
516 verify_eq!((fix.start_line, fix.end_line), (1, 1))?;
517
518 Ok(())
519 }
520
521 #[gtest]
522 fn stale_file_directive_deletes_its_required_separator() -> Result<()> {
523 let source = "// #t(file: rust_panic) stale target\n\nfn retained() {}";
524 let entry = entries(source)?.pop().or_fail()?;
525 let lines: Vec<&str> = source.lines().collect();
526
527 verify_true!(redundant_separator_after(&entry, &lines))
528 }
529
530 #[gtest]
531 fn wildcard_is_atomic() -> Result<()> {
532 let source = "// #t(*) fixture wildcard\ndbg!(1);";
533 let entry = entries(source)?.pop().or_fail()?;
534
535 verify_true!(
536 plan_entry(
537 &entry,
538 &[tagged(2, "rust_dbg")],
539 source.lines().next().or_fail()?,
540 false,
541 )
542 .or_fail()?
543 .is_none()
544 )?;
545 let (_, stale) = plan_entry(&entry, &[], source.lines().next().or_fail()?, false)
546 .or_fail()?
547 .or_fail()?;
548
549 verify_eq!(stale, ["*"])?;
550
551 Ok(())
552 }
553
554 #[gtest]
555 fn every_scope_keeps_a_target_with_a_covered_violation() -> Result<()> {
556 let cases = [
557 ("// #t(rust_dbg) next\ndbg!(1);", 2),
558 ("// #t(file: rust_dbg) file\nfn f() {}", 20),
559 ("// #t(block: rust_dbg) block\ndbg!(1);\n", 2),
560 ("// #t(fn: rust_dbg) function\nfn f() {\n dbg!(1);\n}", 3),
561 ];
562
563 for (source, violation_line) in cases {
564 let entry = entries(source)?.pop().or_fail()?;
565
566 verify_true!(
567 plan_entry(
568 &entry,
569 &[tagged(violation_line, "rust_dbg")],
570 source.lines().next().or_fail()?,
571 false,
572 )
573 .or_fail()?
574 .is_none()
575 )?;
576 }
577
578 Ok(())
579 }
580
581 #[gtest]
582 fn overlapping_active_directives_are_both_preserved() -> Result<()> {
583 let source =
584 "// #t(file: rust_dbg) first\n// #t(file: rust_dbg) second\nfn f() { dbg!(1); }";
585 let raw = [tagged(3, "rust_dbg")];
586
587 for entry in entries(source)? {
588 verify_true!(
589 plan_entry(
590 &entry,
591 &raw,
592 source.lines().nth(entry.line - 1).or_fail()?,
593 false,
594 )
595 .or_fail()?
596 .is_none()
597 )?;
598 }
599
600 Ok(())
601 }
602
603 #[gtest]
604 fn file_size_budget_is_preserved_above_base_threshold() -> Result<()> {
605 let source =
606 "// #t(file: rust_too_many_lines_in_file = 1600) cohesive generated-style table";
607 let entry = entries(source)?.pop().or_fail()?;
608
609 verify_true!(plan_entry(&entry, &[], source, true).or_fail()?.is_none())?;
610
611 Ok(())
612 }
613
614 #[gtest]
615 fn file_size_budget_is_stale_below_base_threshold() -> Result<()> {
616 let source = "// #t(file: rust_too_many_lines_in_file = 1600) old budget";
617 let entry = entries(source)?.pop().or_fail()?;
618 let (fix, stale) = plan_entry(&entry, &[], source, false)
619 .or_fail()?
620 .or_fail()?;
621
622 verify_eq!(stale, ["rust_too_many_lines_in_file"])?;
623 verify_true!(fix.replacement.is_empty())?;
624
625 Ok(())
626 }
627
628 #[gtest]
629 fn mixed_directive_preserves_numeric_budget_when_other_target_is_stale() -> Result<()> {
630 let source = "// #t(file: rust_too_many_lines_in_file = 1600, rust_dbg) exact reason";
631 let entry = entries(source)?.pop().or_fail()?;
632 let (fix, stale) = plan_entry(&entry, &[], source, true).or_fail()?.or_fail()?;
633
634 verify_eq!(stale, ["rust_dbg"])?;
635 verify_eq!(
636 fix.replacement,
637 "// #t(file: rust_too_many_lines_in_file = 1600) exact reason"
638 )?;
639
640 Ok(())
641 }
642}