use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Instant;
use fallow_config::{OutputFormat, ResolvedConfig};
use fallow_output::{
SuppressionInventoryFile, SuppressionInventoryLevel, SuppressionInventoryOutput,
SuppressionInventoryOutputInput, build_suppression_inventory_output,
serialize_suppression_inventory_json_output,
};
use fallow_types::results::ActiveSuppression;
use crate::error::emit_error;
pub struct SuppressionsOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub production: bool,
pub allow_remote_extends: bool,
pub workspace: Option<&'a [String]>,
pub changed_workspaces: Option<&'a str>,
pub changed_since: Option<&'a str>,
pub file: &'a [PathBuf],
}
pub fn run_suppressions(opts: &SuppressionsOptions<'_>) -> ExitCode {
let start = Instant::now();
if let Err(code) = validate_suppressions_output(opts.output) {
return code;
}
let config = match load_suppressions_config(opts) {
Ok(c) => c,
Err(code) => return code,
};
let session = fallow_engine::session::AnalysisSession::from_resolved_config(config);
let results = match session.analyze_dead_code_with_artifacts(false, false) {
Ok(artifacts) => artifacts.results,
Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
};
let mut active = results.active_suppressions;
if let Err(code) = apply_suppression_scopes(&mut active, opts) {
return code;
}
crate::telemetry::note_result_count(active.len());
let output = build_suppression_inventory_output(SuppressionInventoryOutputInput {
active: &active,
stale: &results.stale_suppressions,
root: &session.config().root,
});
let elapsed = start.elapsed();
match opts.output {
OutputFormat::Human => print_suppressions_human(&output, elapsed, opts.quiet),
OutputFormat::Json => print_suppressions_json(output),
_ => unreachable!("validated above"),
}
ExitCode::SUCCESS
}
fn validate_suppressions_output(output: OutputFormat) -> Result<(), ExitCode> {
if matches!(output, OutputFormat::Human | OutputFormat::Json) {
Ok(())
} else {
Err(emit_error(
"fallow suppressions supports --format human or json only.",
2,
output,
))
}
}
fn load_suppressions_config(opts: &SuppressionsOptions<'_>) -> Result<ResolvedConfig, ExitCode> {
crate::runtime_support::load_config(
opts.root,
opts.config_path,
crate::runtime_support::LoadConfigArgs {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production: opts.production,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
},
)
}
fn apply_suppression_scopes(
active: &mut Vec<ActiveSuppression>,
opts: &SuppressionsOptions<'_>,
) -> Result<(), ExitCode> {
if let Some(git_ref) = opts.changed_since
&& let Some(changed) = crate::check::get_changed_files(opts.root, git_ref)
{
active.retain(|s| changed.contains(&s.path));
}
let ws_scope = crate::check::resolve_workspace_scope(
opts.root,
opts.workspace,
opts.changed_workspaces,
opts.output,
)?;
if let Some(ref ws_roots) = ws_scope {
active.retain(|s| ws_roots.iter().any(|r| s.path.starts_with(r)));
}
apply_file_scope(active, opts);
Ok(())
}
fn apply_file_scope(active: &mut Vec<ActiveSuppression>, opts: &SuppressionsOptions<'_>) {
if opts.file.is_empty() {
return;
}
let resolved_files: Vec<PathBuf> = opts
.file
.iter()
.map(|path| {
if crate::path_util::is_absolute_path_any_platform(path) {
path.clone()
} else {
opts.root.join(path)
}
})
.collect();
if !opts.quiet {
for (original, resolved) in opts.file.iter().zip(&resolved_files) {
if !resolved.exists() {
eprintln!(
"Warning: --file '{}' (resolved to '{}') was not found in the project",
original.display(),
resolved.display()
);
}
}
}
let file_set: rustc_hash::FxHashSet<PathBuf> = resolved_files.into_iter().collect();
active.retain(|s| file_set.contains(&s.path));
}
fn kind_label(kind: Option<&str>) -> &str {
kind.unwrap_or("blanket")
}
fn print_file_path(display: &str) {
use colored::Colorize;
if let Some(parent) = Path::new(display).parent() {
let parent_str = parent.to_string_lossy();
let file_name = Path::new(display)
.file_name()
.map_or(String::new(), |n| n.to_string_lossy().to_string());
if parent_str.is_empty() {
println!(" {}", file_name.bold());
} else {
println!(
" {}{}{}",
parent_str.dimmed(),
"/".dimmed(),
file_name.bold()
);
}
} else {
println!(" {}", display.bold());
}
}
fn print_suppressions_human(
output: &SuppressionInventoryOutput,
elapsed: std::time::Duration,
quiet: bool,
) {
use colored::Colorize;
if output.summary.total == 0 {
if !quiet {
eprintln!(
"{} No suppression markers found ({:.2}s)",
"\u{2713}".green().bold(),
elapsed.as_secs_f64()
);
}
return;
}
let label = format!("Suppression inventory ({})", output.summary.total);
println!("{} {}", "\u{25cf}".cyan(), label.cyan().bold());
for file in &output.files {
print_suppression_file(file);
}
println!();
println!("{} {}", "\u{25cf}".cyan(), "Totals by kind".cyan().bold());
for row in &output.summary.by_kind {
println!(" {}: {}", kind_label(row.kind.as_deref()), row.count);
}
if !quiet {
let elapsed_str = format!("{:.2}s", elapsed.as_secs_f64());
eprintln!(
"\n{} {} suppression{} in {} file{}, {} without reason{}, {} stale ({})",
"\u{2713}".green().bold(),
output.summary.total,
crate::report::plural(output.summary.total),
output.summary.files,
crate::report::plural(output.summary.files),
output.summary.without_reason,
crate::report::plural(output.summary.without_reason),
output.summary.stale,
elapsed_str.dimmed(),
);
}
}
fn print_suppression_file(file: &SuppressionInventoryFile) {
use colored::Colorize;
let display = file.path.to_string_lossy().replace('\\', "/");
print_file_path(&display);
for entry in &file.suppressions {
let level_tag = if entry.level == SuppressionInventoryLevel::File {
" (file-wide)"
} else {
""
};
let reason = entry
.reason
.as_deref()
.map_or_else(|| "reason: missing".to_string(), |r| format!("reason: {r}"));
println!(
" {} {}{} {}",
format!(":{}", entry.line).dimmed(),
kind_label(entry.kind.as_deref()).bold(),
level_tag.dimmed(),
reason.dimmed(),
);
}
}
#[expect(
clippy::expect_used,
reason = "suppression inventory JSON output is built from serializable literals"
)]
fn print_suppressions_json(output: SuppressionInventoryOutput) {
let value = serialize_suppression_inventory_json_output(
output,
crate::output_runtime::current_root_envelope_mode(),
crate::output_runtime::telemetry_analysis_run_id().as_deref(),
)
.expect("JSON serialization should not fail");
println!(
"{}",
serde_json::to_string_pretty(&value).expect("JSON serialization should not fail")
);
}
#[cfg(test)]
mod tests {
use super::*;
const NO_CONFIG: Option<PathBuf> = None;
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/suppression-reasons")
}
fn suppressions_opts(root: &Path, output: OutputFormat) -> SuppressionsOptions<'_> {
SuppressionsOptions {
root,
config_path: &NO_CONFIG,
output,
no_cache: true,
threads: 1,
quiet: true,
production: false,
allow_remote_extends: false,
workspace: None,
changed_workspaces: None,
changed_since: None,
file: &[],
}
}
#[test]
fn run_suppressions_renders_human_and_json() {
colored::control::set_override(false);
let root = fixture_root();
for output in [OutputFormat::Human, OutputFormat::Json] {
assert_eq!(
run_suppressions(&suppressions_opts(&root, output)),
ExitCode::SUCCESS,
"format {output:?} should render and exit 0"
);
}
}
#[test]
fn run_suppressions_rejects_unsupported_format() {
let root = fixture_root();
for output in [
OutputFormat::Sarif,
OutputFormat::Markdown,
OutputFormat::Badge,
] {
assert_eq!(
run_suppressions(&suppressions_opts(&root, output)),
ExitCode::from(2),
"format {output:?} should be rejected"
);
}
}
#[test]
fn file_scope_retains_matching_paths_only() {
let root = Path::new("/proj");
let mut active = vec![supp("/proj/src/a.ts"), supp("/proj/src/b.ts")];
let files = vec![PathBuf::from("src/a.ts")];
let opts = SuppressionsOptions {
file: &files,
..suppressions_opts(root, OutputFormat::Json)
};
apply_file_scope(&mut active, &opts);
assert_eq!(active.len(), 1);
assert!(active[0].path.ends_with("src/a.ts"));
}
#[test]
fn kind_label_renders_blanket_for_none() {
assert_eq!(kind_label(None), "blanket");
assert_eq!(kind_label(Some("unused-export")), "unused-export");
}
fn supp(path: &str) -> ActiveSuppression {
ActiveSuppression {
path: PathBuf::from(path),
kind: Some("unused-export".to_owned()),
is_file_level: false,
reason: None,
comment_line: 1,
}
}
}