use anyhow::{Context, Result};
use clap::Parser;
use std::collections::BTreeSet;
use std::path::PathBuf;
mod adapters;
mod cfg;
mod config;
mod derive;
mod diff;
mod doc_drift;
mod dyn_dispatch;
mod ffi;
pub mod finding;
pub mod format;
mod git;
mod ignore;
pub mod log_miss;
pub mod mcp;
mod nextest;
mod rust_analyzer;
mod semver_checks;
mod symbols;
mod tests_scan;
mod trait_methods;
mod traits;
pub use finding::{Finding, FindingKind, Location, SeverityClass, Tier, TierSummary};
pub use format::{Format, render as render_report, render_with_budget};
pub use nextest::filter_expression as nextest_filter;
pub fn context_file_list(report: &AnalysisReport) -> Vec<std::path::PathBuf> {
let mut set: std::collections::BTreeSet<std::path::PathBuf> =
report.changed_files.iter().cloned().collect();
for f in &report.findings {
if let Some(p) = f.primary_path() {
set.insert(p.to_path_buf());
}
}
set.into_iter().collect()
}
#[derive(Parser, Debug, Clone)]
#[command(
name = "cargo-impact",
bin_name = "cargo-impact",
version,
about = "Blast-radius analysis for Rust workspaces",
long_about = None,
)]
pub struct ImpactArgs {
#[arg(long)]
pub test: bool,
#[arg(long, value_enum, default_value_t = Format::Text)]
pub format: Format,
#[arg(long, default_value = "HEAD")]
pub since: String,
#[arg(long)]
pub manifest_dir: Option<PathBuf>,
#[arg(long, default_value_t = 0.0)]
pub confidence_min: f64,
#[arg(long, value_enum)]
pub fail_on: Option<FailOn>,
#[arg(long)]
pub semver_checks: bool,
#[arg(long)]
pub rust_analyzer: bool,
#[arg(long, default_value_t = 0)]
pub budget: usize,
#[arg(long)]
pub context: bool,
#[arg(long = "features", value_delimiter = ',')]
pub features: Vec<String>,
#[arg(long, conflicts_with = "no_default_features")]
pub all_features: bool,
#[arg(long)]
pub no_default_features: bool,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum FailOn {
High,
Medium,
Low,
}
impl FailOn {
fn triggers(self, sev: SeverityClass) -> bool {
matches!(
(self, sev),
(Self::High, SeverityClass::High)
| (Self::Medium, SeverityClass::High | SeverityClass::Medium)
| (
Self::Low,
SeverityClass::High | SeverityClass::Medium | SeverityClass::Low,
)
)
}
}
#[derive(Debug, Clone)]
pub struct AnalysisReport {
pub changed_files: Vec<PathBuf>,
pub candidate_symbols: Vec<String>,
pub findings: Vec<Finding>,
}
pub fn analyze(args: &ImpactArgs) -> Result<AnalysisReport> {
let root = match &args.manifest_dir {
Some(p) => p.clone(),
None => std::env::current_dir().context("reading current directory")?,
};
let cfg_file = config::ConfigFile::load(&root);
let mut args = args.clone();
config::apply_config(&cfg_file.defaults, &mut args);
let args = &args;
let features = cfg::resolve_features(
&root,
&args.features,
args.no_default_features,
args.all_features,
)?;
cfg::with_features(features, || analyze_inner(args, &root))
}
fn analyze_inner(args: &ImpactArgs, root: &std::path::Path) -> Result<AnalysisReport> {
let changed_files = git::changed_rust_files(root, &args.since)?;
if changed_files.is_empty() {
return Ok(AnalysisReport {
changed_files,
candidate_symbols: Vec::new(),
findings: Vec::new(),
});
}
let mut all_symbols: Vec<symbols::TopLevelSymbol> = Vec::new();
for rel in &changed_files {
match diff::diff_file(root, rel, &args.since) {
Ok(Some(items)) => {
for it in items {
all_symbols.push(symbols::TopLevelSymbol {
name: it.name,
kind: it.kind,
});
}
}
Ok(None) => {
let abs = root.join(rel);
match symbols::top_level_symbols(&abs) {
Ok(syms) => all_symbols.extend(syms),
Err(e) => eprintln!("cargo-impact: skipping {}: {e:#}", rel.display()),
}
}
Err(e) => eprintln!("cargo-impact: diff failed for {}: {e:#}", rel.display()),
}
}
let symbol_names: BTreeSet<String> = all_symbols.iter().map(|s| s.name.clone()).collect();
let changed_trait_names = traits::changed_trait_names(&all_symbols);
let mut findings = Vec::new();
findings.extend(tests_scan::find_affected_tests(root, &symbol_names)?);
findings.extend(traits::find_trait_impls(root, &changed_trait_names)?);
findings.extend(derive::find_derive_impls(root, &changed_trait_names)?);
findings.extend(dyn_dispatch::find_dyn_dispatch_sites(
root,
&changed_trait_names,
)?);
findings.extend(doc_drift::find_doc_drift(root, &symbol_names)?);
findings.extend(adapters::find_runtime_surfaces(root, &symbol_names)?);
for rel in &changed_files {
match ffi::find_ffi_changes(root, rel, &args.since) {
Ok(hits) => findings.extend(hits),
Err(e) => eprintln!("cargo-impact: ffi scan failed for {}: {e:#}", rel.display()),
}
}
for rel in &changed_files {
match trait_methods::classify_changes_in_file(root, rel, &args.since) {
Ok(records) => findings.extend(records.into_iter().map(|r| r.into_finding())),
Err(e) => eprintln!(
"cargo-impact: trait-method classification failed for {}: {e:#}",
rel.display()
),
}
}
for rel in &changed_files {
let is_build_rs = rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "build.rs");
if is_build_rs {
let evidence = format!(
"build script `{}` changed — build scripts can invalidate \
downstream compilation in non-obvious ways (env vars, \
rerun-if-*, generated code, linker flags)",
rel.display()
);
let kind = FindingKind::BuildScriptChanged { file: rel.clone() };
findings.push(Finding::new("", Tier::Likely, 0.90, kind, evidence));
}
}
match semver_checks::run(root, &args.since, args.semver_checks) {
Ok(hits) => findings.extend(hits),
Err(e) => eprintln!("cargo-impact: semver-checks failed: {e:#}"),
}
match rust_analyzer::run(root, &changed_files, &symbol_names, args.rust_analyzer) {
Ok(hits) => findings.extend(hits),
Err(e) => eprintln!("cargo-impact: rust-analyzer failed: {e:#}"),
}
let ignore_set = ignore::IgnoreSet::load(root);
if !ignore_set.is_empty() {
findings.retain(|f| match f.primary_path() {
Some(p) => !ignore_set.is_ignored(p),
None => true,
});
}
findings.retain(|f| f.confidence >= args.confidence_min);
for f in &mut findings {
f.id = f.content_id();
}
findings.sort_by(|a, b| {
a.severity
.cmp(&b.severity)
.then_with(|| b.tier.rank().cmp(&a.tier.rank()))
.then_with(|| a.kind.tag().cmp(b.kind.tag()))
.then_with(|| a.evidence.cmp(&b.evidence))
.then_with(|| a.id.cmp(&b.id))
});
let mut candidate_symbols: Vec<String> = symbol_names.into_iter().collect();
candidate_symbols.sort();
Ok(AnalysisReport {
changed_files,
candidate_symbols,
findings,
})
}
pub fn run(args: &ImpactArgs) -> Result<i32> {
let report = analyze(args)?;
if report.changed_files.is_empty() {
if args.test {
println!();
} else if matches!(args.format, Format::Text) {
println!(
"cargo-impact: no Rust files changed relative to {}",
args.since
);
} else {
let out = render_with_budget(args.format, &[], &[], &[], args.budget)?;
println!("{out}");
}
return Ok(0);
}
if args.context {
for path in context_file_list(&report) {
println!("{}", path.display());
}
return Ok(0);
}
if args.test {
println!("{}", nextest::filter_expression(&report.findings));
return Ok(0);
}
let out = render_with_budget(
args.format,
&report.changed_files,
&report.candidate_symbols,
&report.findings,
args.budget,
)?;
println!("{out}");
if let Some(gate) = args.fail_on {
let tripped = report.findings.iter().any(|f| gate.triggers(f.severity));
if tripped {
return Ok(1);
}
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fail_on_high_triggers_on_high_only() {
assert!(FailOn::High.triggers(SeverityClass::High));
assert!(!FailOn::High.triggers(SeverityClass::Medium));
assert!(!FailOn::High.triggers(SeverityClass::Low));
assert!(!FailOn::High.triggers(SeverityClass::Unknown));
}
#[test]
fn fail_on_medium_triggers_on_medium_and_above() {
assert!(FailOn::Medium.triggers(SeverityClass::High));
assert!(FailOn::Medium.triggers(SeverityClass::Medium));
assert!(!FailOn::Medium.triggers(SeverityClass::Low));
}
#[test]
fn fail_on_low_triggers_on_everything_but_unknown() {
assert!(FailOn::Low.triggers(SeverityClass::High));
assert!(FailOn::Low.triggers(SeverityClass::Medium));
assert!(FailOn::Low.triggers(SeverityClass::Low));
assert!(!FailOn::Low.triggers(SeverityClass::Unknown));
}
}