use anyhow::{Context, Result};
use clap::Parser;
use std::collections::BTreeSet;
use std::path::PathBuf;
mod adapters;
mod cache;
mod cfg;
mod config;
mod dedup;
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;
mod macro_expand;
pub mod mcp;
mod nextest;
mod ref_context;
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,
#[arg(long)]
pub macro_expand: bool,
#[arg(long)]
pub feature_powerset: bool,
#[arg(long)]
pub cache: 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>,
}
#[derive(Debug, Clone)]
pub struct ProgressEvent<'a> {
pub stage: &'a str,
pub current: usize,
pub total: usize,
pub detail: Option<&'a str>,
}
pub fn analyze(args: &ImpactArgs) -> Result<AnalysisReport> {
analyze_with_progress(args, |_| {})
}
pub fn analyze_with_progress<F>(args: &ImpactArgs, mut progress: F) -> Result<AnalysisReport>
where
F: FnMut(&ProgressEvent<'_>),
{
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 baseline_features = cfg::resolve_features(
&root,
&args.features,
args.no_default_features,
args.all_features,
)?;
let baseline = cfg::with_features(baseline_features, || {
analyze_inner(args, &root, &mut progress)
})?;
if !args.feature_powerset {
return Ok(baseline);
}
progress(&ProgressEvent {
stage: "powerset_no_default",
current: 1,
total: 3,
detail: None,
});
let nodef_features = cfg::resolve_features(&root, &[], true, false)?;
let nodef_report =
cfg::with_features(nodef_features, || analyze_inner(args, &root, &mut progress))?;
progress(&ProgressEvent {
stage: "powerset_all_features",
current: 2,
total: 3,
detail: None,
});
let all_features_set = cfg::resolve_features(&root, &[], false, true)?;
let all_report = cfg::with_features(all_features_set, || {
analyze_inner(args, &root, &mut progress)
})?;
Ok(merge_powerset_reports(baseline, nodef_report, all_report))
}
fn merge_powerset_reports(
baseline: AnalysisReport,
nodef: AnalysisReport,
all: AnalysisReport,
) -> AnalysisReport {
use std::collections::BTreeSet;
let baseline_ids: BTreeSet<String> = baseline.findings.iter().map(|f| f.id.clone()).collect();
let mut combined = baseline.findings;
let mut added_ids = baseline_ids.clone();
for (extra_report, label) in [(nodef, "--no-default-features"), (all, "--all-features")] {
for mut f in extra_report.findings {
if added_ids.contains(&f.id) {
continue;
}
f.evidence = format!("{} (only visible with {label})", f.evidence);
added_ids.insert(f.id.clone());
combined.push(f);
}
}
combined.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))
});
AnalysisReport {
changed_files: baseline.changed_files,
candidate_symbols: baseline.candidate_symbols,
findings: combined,
}
}
fn analyze_inner<F>(
args: &ImpactArgs,
root: &std::path::Path,
progress: &mut F,
) -> Result<AnalysisReport>
where
F: FnMut(&ProgressEvent<'_>),
{
let changed_files = git::changed_rust_files(root, &args.since)?;
let mut symbol_cache = if args.cache {
Some(
cache::ContentHashCache::<Vec<symbols::TopLevelSymbol>>::new(root, "top-level-symbols"),
)
} else {
None
};
if changed_files.is_empty() {
progress(&ProgressEvent {
stage: "done",
current: 1,
total: 1,
detail: None,
});
return Ok(AnalysisReport {
changed_files,
candidate_symbols: Vec::new(),
findings: Vec::new(),
});
}
let mut all_symbols: Vec<symbols::TopLevelSymbol> = Vec::new();
let total_files = changed_files.len();
for (i, rel) in changed_files.iter().enumerate() {
progress(&ProgressEvent {
stage: "symbols",
current: i,
total: total_files,
detail: rel.to_str(),
});
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 top_level_symbols_cached(&abs, symbol_cache.as_mut()) {
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);
const ANALYZER_STAGES: &[&str] = &[
"tests_scan",
"traits",
"derive",
"dyn_dispatch",
"doc_drift",
"adapters",
];
let emit_analyzer = |i: usize, progress: &mut F| {
progress(&ProgressEvent {
stage: "analyzers",
current: i,
total: ANALYZER_STAGES.len(),
detail: Some(ANALYZER_STAGES[i]),
});
};
let mut findings = Vec::new();
emit_analyzer(0, progress);
findings.extend(tests_scan::find_affected_tests(root, &symbol_names)?);
emit_analyzer(1, progress);
findings.extend(traits::find_trait_impls(root, &changed_trait_names)?);
emit_analyzer(2, progress);
findings.extend(derive::find_derive_impls(root, &changed_trait_names)?);
emit_analyzer(3, progress);
findings.extend(dyn_dispatch::find_dyn_dispatch_sites(
root,
&changed_trait_names,
)?);
emit_analyzer(4, progress);
findings.extend(doc_drift::find_doc_drift(root, &symbol_names)?);
emit_analyzer(5, progress);
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));
}
}
if args.semver_checks {
progress(&ProgressEvent {
stage: "semver_checks",
current: 0,
total: 1,
detail: None,
});
}
match semver_checks::run(root, &args.since, args.semver_checks) {
Ok(hits) => findings.extend(hits),
Err(e) => eprintln!("cargo-impact: semver-checks failed: {e:#}"),
}
if args.rust_analyzer {
progress(&ProgressEvent {
stage: "rust_analyzer",
current: 0,
total: 1,
detail: None,
});
}
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:#}"),
}
if args.macro_expand {
progress(&ProgressEvent {
stage: "macro_expand",
current: 0,
total: 1,
detail: None,
});
}
match macro_expand::run(root, &changed_trait_names, &symbol_names, args.macro_expand) {
Ok(hits) => findings.extend(hits),
Err(e) => eprintln!("cargo-impact: macro-expand failed: {e:#}"),
}
dedup::dedup_syn_under_proven(&mut findings);
dedup::dedup_expanded_under_raw(&mut findings);
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();
progress(&ProgressEvent {
stage: "done",
current: 1,
total: 1,
detail: None,
});
if let Some(cache) = &symbol_cache {
cache.save();
}
Ok(AnalysisReport {
changed_files,
candidate_symbols,
findings,
})
}
fn top_level_symbols_cached(
file: &std::path::Path,
cache: Option<&mut cache::ContentHashCache<Vec<symbols::TopLevelSymbol>>>,
) -> Result<Vec<symbols::TopLevelSymbol>> {
let Some(cache) = cache else {
return symbols::top_level_symbols(file);
};
let Some(hash) = cache::file_hash(file) else {
return symbols::top_level_symbols(file);
};
let cache_key = format!("{:?}:{hash}", cfg::current_features());
if let Some(symbols) = cache.get(&cache_key) {
return Ok(symbols.clone());
}
let symbols = symbols::top_level_symbols(file)?;
cache.insert(cache_key, symbols.clone());
Ok(symbols)
}
pub fn run(args: &ImpactArgs) -> Result<i32> {
let report = analyze(args)?;
if args.context {
for path in context_file_list(&report) {
println!("{}", path.display());
}
return Ok(0);
}
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.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::*;
use std::path::Path;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed");
}
fn clean_repo() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().unwrap();
git(dir.path(), &["init", "-q"]);
git(dir.path(), &["config", "user.email", "t@t"]);
git(dir.path(), &["config", "user.name", "t"]);
git(dir.path(), &["config", "commit.gpgsign", "false"]);
git(dir.path(), &["config", "core.autocrlf", "false"]);
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname=\"fixture\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "pub fn untouched() {}\n").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "init"]);
dir
}
fn args_for(root: &Path) -> ImpactArgs {
ImpactArgs {
test: false,
format: Format::Json,
since: "HEAD".into(),
manifest_dir: Some(root.to_path_buf()),
confidence_min: 0.0,
fail_on: None,
semver_checks: false,
rust_analyzer: false,
features: Vec::new(),
all_features: false,
no_default_features: false,
budget: 0,
context: false,
feature_powerset: false,
macro_expand: false,
cache: false,
}
}
#[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));
}
#[test]
fn clean_workspace_progress_still_emits_done() {
let dir = clean_repo();
let args = args_for(dir.path());
let mut stages = Vec::new();
let report = analyze_with_progress(&args, |ev| stages.push(ev.stage.to_string())).unwrap();
assert!(report.changed_files.is_empty());
assert_eq!(stages, vec!["done"]);
}
mod powerset {
use super::*;
use crate::finding::Location;
fn mk_finding(id: &str, evidence: &str) -> Finding {
let mut f = Finding::new(
"",
Tier::Likely,
0.85,
FindingKind::TestReference {
test: Location {
file: PathBuf::from("tests/a.rs"),
symbol: format!("test_{id}"),
},
matched_symbols: vec![id.to_string()],
},
evidence,
);
f.id = id.to_string();
f
}
fn report(findings: Vec<Finding>) -> AnalysisReport {
AnalysisReport {
changed_files: Vec::new(),
candidate_symbols: Vec::new(),
findings,
}
}
#[test]
fn baseline_findings_pass_through_unchanged() {
let baseline = report(vec![mk_finding("a", "base evidence")]);
let nodef = report(vec![]);
let all = report(vec![]);
let merged = merge_powerset_reports(baseline, nodef, all);
assert_eq!(merged.findings.len(), 1);
assert_eq!(merged.findings[0].evidence, "base evidence");
}
#[test]
fn finding_only_in_nodef_is_annotated_and_appended() {
let baseline = report(vec![mk_finding("a", "base")]);
let nodef = report(vec![mk_finding("b", "from-nodef")]);
let all = report(vec![]);
let merged = merge_powerset_reports(baseline, nodef, all);
assert_eq!(merged.findings.len(), 2);
let b = merged.findings.iter().find(|f| f.id == "b").unwrap();
assert!(
b.evidence.contains("--no-default-features"),
"expected no-default annotation in evidence: {}",
b.evidence
);
assert!(b.evidence.starts_with("from-nodef"));
}
#[test]
fn finding_only_in_all_features_is_annotated_and_appended() {
let baseline = report(vec![]);
let nodef = report(vec![]);
let all = report(vec![mk_finding("c", "from-all")]);
let merged = merge_powerset_reports(baseline, nodef, all);
assert_eq!(merged.findings.len(), 1);
let c = &merged.findings[0];
assert!(c.evidence.contains("--all-features"));
assert!(c.evidence.starts_with("from-all"));
}
#[test]
fn finding_visible_in_baseline_and_nodef_keeps_baseline_evidence() {
let shared = mk_finding("dup", "baseline-text");
let also_shared = mk_finding("dup", "nodef-text");
let baseline = report(vec![shared]);
let nodef = report(vec![also_shared]);
let merged = merge_powerset_reports(baseline, nodef, report(vec![]));
assert_eq!(merged.findings.len(), 1);
assert_eq!(merged.findings[0].evidence, "baseline-text");
}
#[test]
fn same_id_in_both_extras_only_annotates_once() {
let baseline = report(vec![]);
let nodef = report(vec![mk_finding("x", "ev")]);
let all = report(vec![mk_finding("x", "ev")]);
let merged = merge_powerset_reports(baseline, nodef, all);
assert_eq!(merged.findings.len(), 1);
assert!(
merged.findings[0]
.evidence
.contains("--no-default-features")
);
assert!(!merged.findings[0].evidence.contains("--all-features"));
}
#[test]
fn merged_results_stay_sorted_by_severity_then_tier() {
let high = {
let mut f = Finding::new(
"",
Tier::Likely,
0.9,
FindingKind::BuildScriptChanged {
file: PathBuf::from("build.rs"),
},
"build.rs changed",
);
f.id = "high".into();
f
};
let med = mk_finding("med", "medium finding");
let low = {
let mut f = Finding::new(
"",
Tier::Likely,
0.4,
FindingKind::DocDriftKeyword {
symbol: "foo".into(),
doc: Location {
file: PathBuf::from("README.md"),
symbol: "foo".into(),
},
line: 1,
},
"doc drift",
);
f.id = "low".into();
f
};
let baseline = report(vec![low]);
let nodef = report(vec![med]);
let all = report(vec![high]);
let merged = merge_powerset_reports(baseline, nodef, all);
let severities: Vec<_> = merged.findings.iter().map(|f| f.severity).collect();
assert_eq!(
severities,
vec![
SeverityClass::High,
SeverityClass::Medium,
SeverityClass::Low
]
);
}
}
}