use std::{collections::BTreeMap, io::Write as _, process::ExitCode};
use crate::output;
use crate::{
file,
path::{Path, PathBuf},
};
use tabled::Tabled;
use crate::{
Config, Fix, RuleRegistry, Violation,
infra::{ignore, walk},
};
pub fn report_suppressions(
registry: &RuleRegistry,
package_filter: &[String],
config: &Config,
root: &Path,
workspace_root: &Path,
quiet: bool,
) -> ExitCode {
let paths = match walk::rs_paths(workspace_root, package_filter) {
Ok(paths) => paths,
Err(error) => {
if !quiet {
output::error(&format!("suppression report aborted: {error}"));
}
return ExitCode::FAILURE;
}
};
let registered_names: std::collections::HashSet<&str> = registry
.rules()
.iter()
.copied()
.map(|rule| rule.info.name)
.collect();
let all_entries = match load_suppressions(&paths, root, ®istered_names) {
Ok(entries) => entries,
Err(failures) => return report_read_failures(failures, quiet),
};
if quiet {
eprintln!("{}", all_entries.len());
return ExitCode::SUCCESS;
}
if all_entries.is_empty() {
output::success("no suppression directives found");
output::blank();
return ExitCode::SUCCESS;
}
let _ = config;
let mut by_rule: BTreeMap<String, Vec<&ignore::SuppressionEntry>> = BTreeMap::new();
for entry in &all_entries {
if entry.wildcard {
by_rule.entry("*".to_string()).or_default().push(entry);
} else {
for rule in &entry.rules {
by_rule.entry(rule.clone()).or_default().push(entry);
}
}
}
let mut rows: Vec<SuppressionRow> = Vec::with_capacity(by_rule.len());
for (rule, entries) in by_rule {
output::header(&format!("{rule} ({} suppression(s))", entries.len()));
for e in &entries {
output::detail(&format!(
" {}:{} [{}] {}",
e.rel,
e.line,
e.scope.prefix(),
e.reason,
));
}
output::blank();
rows.push(SuppressionRow {
rule,
count: entries.len(),
});
}
rows.sort_by_key(|a| std::cmp::Reverse(a.count));
output::separator();
output::blank();
output::table(rows);
output::blank();
output::detail(&format!(
"{} directive(s) across {} file(s)",
all_entries.len(),
{
let mut files: std::collections::HashSet<&str> = std::collections::HashSet::new();
for e in &all_entries {
files.insert(&e.rel);
}
files.len()
}
));
output::blank();
ExitCode::SUCCESS
}
fn load_suppressions(
paths: &[PathBuf],
root: &Path,
registered_names: &std::collections::HashSet<&str>,
) -> Result<Vec<ignore::SuppressionEntry>, Vec<String>> {
let mut all_entries = Vec::new();
let mut failures = Vec::with_capacity(paths.len());
for path in paths {
let relative = path.strip_prefix(root).unwrap_or(path);
let Some(rel) = relative.to_slash() else {
failures.push(format!(
"{}: non-UTF-8 source paths cannot be reported safely",
path.display()
));
continue;
};
let contents = match file::read_text(path) {
Ok(contents) => contents,
Err(error) => {
failures.push(format!("{rel}: failed to read source: {error}"));
continue;
}
};
let lines: Vec<&str> = contents.lines().collect();
let directive_lines = crate::infra::scanner::directive_source_lines(&contents, &lines);
let mut errors = Vec::new();
let suppressed =
ignore::suppressed_lines(&rel, &directive_lines, &mut errors, Some(registered_names));
all_entries.extend(suppressed.entries);
}
if failures.is_empty() {
Ok(all_entries)
} else {
failures.sort_unstable();
failures.dedup();
Err(failures)
}
}
fn report_read_failures(failures: Vec<String>, quiet: bool) -> ExitCode {
if !quiet {
for failure in failures {
output::error(&failure);
}
output::error("suppression report aborted because analysis was incomplete");
}
ExitCode::FAILURE
}
#[derive(Tabled)]
#[tabled(crate = "tabled")]
struct SuppressionRow {
#[tabled(rename = "Rule")]
rule: String,
#[tabled(rename = "Count")]
count: usize,
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::*;
#[cfg(unix)]
#[test]
fn suppression_report_rejects_non_utf8_path_identity() {
use std::os::unix::ffi::OsStringExt as _;
let directory = crate::temporary::Directory::new().expect("temporary directory");
let name = std::ffi::OsString::from_vec(b"source-\xff.rs".to_vec());
let path = directory.path().join(std::path::PathBuf::from(name));
let errors = load_suppressions(&[path], directory.path(), &HashSet::new())
.expect_err("lossy report identity must fail");
assert!(errors[0].contains("non-UTF-8 source paths"));
}
}
pub(super) fn print_grouped(
violations: &[Violation],
_registry: &RuleRegistry,
packages: &[super::PackageRoot],
root: &Path,
) {
let mut by_package: BTreeMap<&str, Vec<&Violation>> = BTreeMap::new();
let mut by_rule: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
for v in violations {
by_package
.entry(package_name(&v.rel, packages, root))
.or_default()
.push(v);
let (total, fixable) = by_rule.entry(v.rule_name()).or_default();
*total += 1;
*fixable += usize::from(v.is_fixable());
}
for (package_name, violations) in &by_package {
output::header(&format!("{package_name} ({} issues)", violations.len()));
for v in violations {
let location = v.column.map_or_else(
|| format!("{}:{}", v.rel, v.line),
|column| format!("{}:{}:{column}", v.rel, v.line),
);
output::error(&format!("{location}: {}", v.message));
}
output::blank();
}
output::separator();
output::blank();
let mut rule_counts: Vec<RuleSummaryRow> = by_rule
.into_iter()
.map(|(rule, (count, fixable))| RuleSummaryRow {
rule: rule.to_string(),
fixable: if fixable > 0 {
fixable.to_string()
} else {
String::new()
},
count,
})
.collect();
rule_counts.sort_by_key(|a| std::cmp::Reverse(a.count));
output::table(rule_counts);
output::blank();
let fixable_count: usize = violations
.iter()
.filter(|violation| violation.is_fixable())
.count();
output::error(&format!(
"{} violation(s) in {} package(s)",
violations.len(),
by_package.len()
));
if fixable_count > 0 {
output::detail(&format!(
"{fixable_count} of these can be auto-fixed with --fix"
));
}
}
#[derive(serde::Serialize)]
struct FindingsDocument<'a> {
schema_version: u32,
findings: Vec<StructuredFinding<'a>>,
}
#[derive(serde::Serialize)]
struct StructuredFinding<'a> {
id: String,
path: &'a str,
line: usize,
column: Option<usize>,
rule: &'a str,
severity: &'a str,
message: &'a str,
description: &'a str,
guidance: &'a str,
fixable: bool,
}
pub(super) fn print_json(violations: &[Violation], registry: &RuleRegistry) {
let findings = violations
.iter()
.map(|violation| {
let rule = registry
.rules()
.iter()
.copied()
.find(|rule| rule.info.name == violation.rule_name());
StructuredFinding {
id: finding_id(violation),
path: &violation.rel,
line: violation.line,
column: violation.column,
rule: violation.rule_name(),
severity: rule.map_or("unknown", |rule| rule.info.severity.as_str()),
message: &violation.message,
description: rule.map_or("", |rule| rule.info.description),
guidance: rule.map_or("", |rule| rule.info.justification),
fixable: violation.is_fixable(),
}
})
.collect();
let document = FindingsDocument {
schema_version: 2,
findings,
};
let stdout = std::io::stdout();
let mut output = stdout.lock();
if serde_json::to_writer_pretty(&mut output, &document).is_ok() {
let _ = output.write_all(b"\n");
}
}
fn finding_id(violation: &Violation) -> String {
let mut hasher = blake3::Hasher::new();
for part in [
violation.rule_name().as_bytes(),
violation.rel.as_bytes(),
violation.message.as_bytes(),
] {
hasher.update(&(part.len() as u64).to_le_bytes());
hasher.update(part);
}
hasher.finalize().to_hex().to_string()
}
#[cfg(test)]
mod structured_tests {
use super::*;
#[test]
fn finding_ids_survive_line_and_column_movement() {
let original = crate::violation("src/lib.rs", 4, "finding")
.with_column(8)
.with_rule("rust_example");
let moved = crate::violation("src/lib.rs", 400, "finding")
.with_column(16)
.with_rule("rust_example");
assert_eq!(finding_id(&original), finding_id(&moved));
}
#[test]
fn finding_ids_distinguish_rules_paths_and_messages() {
let finding = crate::violation("src/lib.rs", 4, "finding").with_rule("rust_example");
for distinct in [
crate::violation("src/lib.rs", 4, "other").with_rule("rust_example"),
crate::violation("src/other.rs", 4, "finding").with_rule("rust_example"),
crate::violation("src/lib.rs", 4, "finding").with_rule("rust_other"),
] {
assert_ne!(finding_id(&finding), finding_id(&distinct));
}
}
}
#[derive(Tabled)]
#[tabled(crate = "tabled")]
struct RuleSummaryRow {
#[tabled(rename = "Rule")]
rule: String,
#[tabled(rename = "Auto-fix")]
fixable: String,
#[tabled(rename = "Count")]
count: usize,
}
pub(super) fn print_dry_run(fixes: &[(String, Fix)]) {
let mut by_file: BTreeMap<&str, Vec<&Fix>> = BTreeMap::new();
for (rel, fix) in fixes {
by_file.entry(rel).or_default().push(fix);
}
for (rel, file_fixes) in &by_file {
output::header(&format!("{rel} ({} changed span(s))", file_fixes.len()));
for f in file_fixes {
if f.start_line > f.end_line {
output::detail(&format!(
" insert {} line(s) at line {}",
f.replacement.lines().count(),
f.start_line
));
} else if f.replacement.is_empty() {
output::detail(&format!(" delete lines {}-{}", f.start_line, f.end_line));
} else {
let new_lines = f.replacement.lines().count();
output::detail(&format!(
" replace lines {}-{} ({} line(s))",
f.start_line, f.end_line, new_lines
));
}
}
output::blank();
}
}
fn package_name<'a>(rel: &'a str, packages: &'a [super::PackageRoot], root: &Path) -> &'a str {
super::package_name_for_path(packages, &root.join(rel)).unwrap_or(rel)
}