1use anyhow::{Context, Result};
77use clap::Parser;
78use std::collections::BTreeSet;
79use std::path::PathBuf;
80
81mod adapters;
82mod cache;
83mod cfg;
84mod config;
85mod dedup;
86mod derive;
87mod diff;
88mod doc_drift;
89mod dyn_dispatch;
90mod ffi;
91pub mod finding;
92pub mod format;
93mod git;
94mod ignore;
95pub mod log_miss;
96mod macro_expand;
97pub mod mcp;
98mod nextest;
99mod ref_context;
100mod rust_analyzer;
101mod semver_checks;
102mod symbols;
103mod tests_scan;
104mod trait_methods;
105mod traits;
106
107pub use finding::{Finding, FindingKind, Location, SeverityClass, Tier, TierSummary};
108pub use format::{Format, render as render_report, render_with_budget};
109pub use nextest::filter_expression as nextest_filter;
110
111pub fn context_file_list(report: &AnalysisReport) -> Vec<std::path::PathBuf> {
117 let mut set: std::collections::BTreeSet<std::path::PathBuf> =
118 report.changed_files.iter().cloned().collect();
119 for f in &report.findings {
120 if let Some(p) = f.primary_path() {
121 set.insert(p.to_path_buf());
122 }
123 }
124 set.into_iter().collect()
125}
126
127#[derive(Parser, Debug, Clone)]
129#[command(
130 name = "cargo-impact",
131 bin_name = "cargo-impact",
132 version,
133 about = "Blast-radius analysis for Rust workspaces",
134 long_about = None,
135)]
136pub struct ImpactArgs {
137 #[arg(long)]
140 pub test: bool,
141
142 #[arg(long, value_enum, default_value_t = Format::Text)]
144 pub format: Format,
145
146 #[arg(long, default_value = "HEAD")]
149 pub since: String,
150
151 #[arg(long)]
153 pub manifest_dir: Option<PathBuf>,
154
155 #[arg(long, default_value_t = 0.0)]
157 pub confidence_min: f64,
158
159 #[arg(long, value_enum)]
163 pub fail_on: Option<FailOn>,
164
165 #[arg(long)]
171 pub semver_checks: bool,
172
173 #[arg(long)]
179 pub rust_analyzer: bool,
180
181 #[arg(long, default_value_t = 0)]
192 pub budget: usize,
193
194 #[arg(long)]
203 pub context: bool,
204
205 #[arg(long = "features", value_delimiter = ',')]
211 pub features: Vec<String>,
212
213 #[arg(long, conflicts_with = "no_default_features")]
217 pub all_features: bool,
218
219 #[arg(long)]
222 pub no_default_features: bool,
223
224 #[arg(long)]
232 pub macro_expand: bool,
233
234 #[arg(long)]
245 pub feature_powerset: bool,
246
247 #[arg(long)]
252 pub cache: bool,
253}
254
255#[derive(Debug, Clone, Copy, clap::ValueEnum)]
256#[value(rename_all = "lowercase")]
257pub enum FailOn {
258 High,
259 Medium,
260 Low,
261}
262
263impl FailOn {
264 fn triggers(self, sev: SeverityClass) -> bool {
265 matches!(
266 (self, sev),
267 (Self::High, SeverityClass::High)
268 | (Self::Medium, SeverityClass::High | SeverityClass::Medium)
269 | (
270 Self::Low,
271 SeverityClass::High | SeverityClass::Medium | SeverityClass::Low,
272 )
273 )
274 }
275}
276
277#[derive(Debug, Clone)]
285pub struct AnalysisReport {
286 pub changed_files: Vec<PathBuf>,
287 pub candidate_symbols: Vec<String>,
288 pub findings: Vec<Finding>,
289}
290
291#[derive(Debug, Clone)]
311pub struct ProgressEvent<'a> {
312 pub stage: &'a str,
313 pub current: usize,
314 pub total: usize,
315 pub detail: Option<&'a str>,
316}
317
318pub fn analyze(args: &ImpactArgs) -> Result<AnalysisReport> {
327 analyze_with_progress(args, |_| {})
328}
329
330pub fn analyze_with_progress<F>(args: &ImpactArgs, mut progress: F) -> Result<AnalysisReport>
341where
342 F: FnMut(&ProgressEvent<'_>),
343{
344 let root = match &args.manifest_dir {
345 Some(p) => p.clone(),
346 None => std::env::current_dir().context("reading current directory")?,
347 };
348
349 let cfg_file = config::ConfigFile::load(&root);
353 let mut args = args.clone();
354 config::apply_config(&cfg_file.defaults, &mut args);
355 let args = &args;
356
357 let baseline_features = cfg::resolve_features(
362 &root,
363 &args.features,
364 args.no_default_features,
365 args.all_features,
366 )?;
367 let baseline = cfg::with_features(baseline_features, || {
368 analyze_inner(args, &root, &mut progress)
369 })?;
370
371 if !args.feature_powerset {
372 return Ok(baseline);
373 }
374
375 progress(&ProgressEvent {
381 stage: "powerset_no_default",
382 current: 1,
383 total: 3,
384 detail: None,
385 });
386 let nodef_features = cfg::resolve_features(&root, &[], true, false)?;
387 let nodef_report =
388 cfg::with_features(nodef_features, || analyze_inner(args, &root, &mut progress))?;
389
390 progress(&ProgressEvent {
391 stage: "powerset_all_features",
392 current: 2,
393 total: 3,
394 detail: None,
395 });
396 let all_features_set = cfg::resolve_features(&root, &[], false, true)?;
397 let all_report = cfg::with_features(all_features_set, || {
398 analyze_inner(args, &root, &mut progress)
399 })?;
400
401 Ok(merge_powerset_reports(baseline, nodef_report, all_report))
402}
403
404fn merge_powerset_reports(
418 baseline: AnalysisReport,
419 nodef: AnalysisReport,
420 all: AnalysisReport,
421) -> AnalysisReport {
422 use std::collections::BTreeSet;
423
424 let baseline_ids: BTreeSet<String> = baseline.findings.iter().map(|f| f.id.clone()).collect();
425 let mut combined = baseline.findings;
426 let mut added_ids = baseline_ids.clone();
427
428 for (extra_report, label) in [(nodef, "--no-default-features"), (all, "--all-features")] {
429 for mut f in extra_report.findings {
430 if added_ids.contains(&f.id) {
431 continue;
432 }
433 f.evidence = format!("{} (only visible with {label})", f.evidence);
434 added_ids.insert(f.id.clone());
435 combined.push(f);
436 }
437 }
438
439 combined.sort_by(|a, b| {
442 a.severity
443 .cmp(&b.severity)
444 .then_with(|| b.tier.rank().cmp(&a.tier.rank()))
445 .then_with(|| a.kind.tag().cmp(b.kind.tag()))
446 .then_with(|| a.evidence.cmp(&b.evidence))
447 .then_with(|| a.id.cmp(&b.id))
448 });
449
450 AnalysisReport {
451 changed_files: baseline.changed_files,
452 candidate_symbols: baseline.candidate_symbols,
453 findings: combined,
454 }
455}
456
457fn analyze_inner<F>(
458 args: &ImpactArgs,
459 root: &std::path::Path,
460 progress: &mut F,
461) -> Result<AnalysisReport>
462where
463 F: FnMut(&ProgressEvent<'_>),
464{
465 let changed_files = git::changed_rust_files(root, &args.since)?;
466
467 let mut symbol_cache = if args.cache {
468 Some(
469 cache::ContentHashCache::<Vec<symbols::TopLevelSymbol>>::new(root, "top-level-symbols"),
470 )
471 } else {
472 None
473 };
474
475 if changed_files.is_empty() {
476 progress(&ProgressEvent {
477 stage: "done",
478 current: 1,
479 total: 1,
480 detail: None,
481 });
482 return Ok(AnalysisReport {
483 changed_files,
484 candidate_symbols: Vec::new(),
485 findings: Vec::new(),
486 });
487 }
488
489 let mut all_symbols: Vec<symbols::TopLevelSymbol> = Vec::new();
492 let total_files = changed_files.len();
493 for (i, rel) in changed_files.iter().enumerate() {
494 progress(&ProgressEvent {
495 stage: "symbols",
496 current: i,
497 total: total_files,
498 detail: rel.to_str(),
499 });
500 match diff::diff_file(root, rel, &args.since) {
501 Ok(Some(items)) => {
502 for it in items {
503 all_symbols.push(symbols::TopLevelSymbol {
504 name: it.name,
505 kind: it.kind,
506 });
507 }
508 }
509 Ok(None) => {
510 let abs = root.join(rel);
511 match top_level_symbols_cached(&abs, symbol_cache.as_mut()) {
512 Ok(syms) => all_symbols.extend(syms),
513 Err(e) => eprintln!("cargo-impact: skipping {}: {e:#}", rel.display()),
514 }
515 }
516 Err(e) => eprintln!("cargo-impact: diff failed for {}: {e:#}", rel.display()),
517 }
518 }
519 let symbol_names: BTreeSet<String> = all_symbols.iter().map(|s| s.name.clone()).collect();
520 let changed_trait_names = traits::changed_trait_names(&all_symbols);
521
522 const ANALYZER_STAGES: &[&str] = &[
526 "tests_scan",
527 "traits",
528 "derive",
529 "dyn_dispatch",
530 "doc_drift",
531 "adapters",
532 ];
533 let emit_analyzer = |i: usize, progress: &mut F| {
534 progress(&ProgressEvent {
535 stage: "analyzers",
536 current: i,
537 total: ANALYZER_STAGES.len(),
538 detail: Some(ANALYZER_STAGES[i]),
539 });
540 };
541
542 let mut findings = Vec::new();
543 emit_analyzer(0, progress);
544 findings.extend(tests_scan::find_affected_tests(root, &symbol_names)?);
545 emit_analyzer(1, progress);
546 findings.extend(traits::find_trait_impls(root, &changed_trait_names)?);
547 emit_analyzer(2, progress);
548 findings.extend(derive::find_derive_impls(root, &changed_trait_names)?);
549 emit_analyzer(3, progress);
550 findings.extend(dyn_dispatch::find_dyn_dispatch_sites(
551 root,
552 &changed_trait_names,
553 )?);
554 emit_analyzer(4, progress);
555 findings.extend(doc_drift::find_doc_drift(root, &symbol_names)?);
556 emit_analyzer(5, progress);
557 findings.extend(adapters::find_runtime_surfaces(root, &symbol_names)?);
558
559 for rel in &changed_files {
560 match ffi::find_ffi_changes(root, rel, &args.since) {
561 Ok(hits) => findings.extend(hits),
562 Err(e) => eprintln!("cargo-impact: ffi scan failed for {}: {e:#}", rel.display()),
563 }
564 }
565
566 for rel in &changed_files {
567 match trait_methods::classify_changes_in_file(root, rel, &args.since) {
568 Ok(records) => findings.extend(records.into_iter().map(|r| r.into_finding())),
569 Err(e) => eprintln!(
570 "cargo-impact: trait-method classification failed for {}: {e:#}",
571 rel.display()
572 ),
573 }
574 }
575
576 for rel in &changed_files {
577 let is_build_rs = rel
578 .file_name()
579 .and_then(|n| n.to_str())
580 .is_some_and(|n| n == "build.rs");
581 if is_build_rs {
582 let evidence = format!(
583 "build script `{}` changed — build scripts can invalidate \
584 downstream compilation in non-obvious ways (env vars, \
585 rerun-if-*, generated code, linker flags)",
586 rel.display()
587 );
588 let kind = FindingKind::BuildScriptChanged { file: rel.clone() };
589 findings.push(Finding::new("", Tier::Likely, 0.90, kind, evidence));
590 }
591 }
592
593 if args.semver_checks {
594 progress(&ProgressEvent {
595 stage: "semver_checks",
596 current: 0,
597 total: 1,
598 detail: None,
599 });
600 }
601 match semver_checks::run(root, &args.since, args.semver_checks) {
602 Ok(hits) => findings.extend(hits),
603 Err(e) => eprintln!("cargo-impact: semver-checks failed: {e:#}"),
604 }
605
606 if args.rust_analyzer {
607 progress(&ProgressEvent {
608 stage: "rust_analyzer",
609 current: 0,
610 total: 1,
611 detail: None,
612 });
613 }
614 match rust_analyzer::run(root, &changed_files, &symbol_names, args.rust_analyzer) {
615 Ok(hits) => findings.extend(hits),
616 Err(e) => eprintln!("cargo-impact: rust-analyzer failed: {e:#}"),
617 }
618
619 if args.macro_expand {
620 progress(&ProgressEvent {
621 stage: "macro_expand",
622 current: 0,
623 total: 1,
624 detail: None,
625 });
626 }
627 match macro_expand::run(root, &changed_trait_names, &symbol_names, args.macro_expand) {
628 Ok(hits) => findings.extend(hits),
629 Err(e) => eprintln!("cargo-impact: macro-expand failed: {e:#}"),
630 }
631
632 dedup::dedup_syn_under_proven(&mut findings);
638
639 dedup::dedup_expanded_under_raw(&mut findings);
645
646 let ignore_set = ignore::IgnoreSet::load(root);
651 if !ignore_set.is_empty() {
652 findings.retain(|f| match f.primary_path() {
653 Some(p) => !ignore_set.is_ignored(p),
654 None => true,
655 });
656 }
657
658 findings.retain(|f| f.confidence >= args.confidence_min);
659
660 for f in &mut findings {
665 f.id = f.content_id();
666 }
667
668 findings.sort_by(|a, b| {
669 a.severity
670 .cmp(&b.severity)
671 .then_with(|| b.tier.rank().cmp(&a.tier.rank()))
672 .then_with(|| a.kind.tag().cmp(b.kind.tag()))
673 .then_with(|| a.evidence.cmp(&b.evidence))
674 .then_with(|| a.id.cmp(&b.id))
675 });
676
677 let mut candidate_symbols: Vec<String> = symbol_names.into_iter().collect();
678 candidate_symbols.sort();
679
680 progress(&ProgressEvent {
681 stage: "done",
682 current: 1,
683 total: 1,
684 detail: None,
685 });
686
687 if let Some(cache) = &symbol_cache {
688 cache.save();
689 }
690
691 Ok(AnalysisReport {
692 changed_files,
693 candidate_symbols,
694 findings,
695 })
696}
697
698fn top_level_symbols_cached(
699 file: &std::path::Path,
700 cache: Option<&mut cache::ContentHashCache<Vec<symbols::TopLevelSymbol>>>,
701) -> Result<Vec<symbols::TopLevelSymbol>> {
702 let Some(cache) = cache else {
703 return symbols::top_level_symbols(file);
704 };
705 let Some(hash) = cache::file_hash(file) else {
706 return symbols::top_level_symbols(file);
707 };
708 let cache_key = format!("{:?}:{hash}", cfg::current_features());
709 if let Some(symbols) = cache.get(&cache_key) {
710 return Ok(symbols.clone());
711 }
712 let symbols = symbols::top_level_symbols(file)?;
713 cache.insert(cache_key, symbols.clone());
714 Ok(symbols)
715}
716
717pub fn run(args: &ImpactArgs) -> Result<i32> {
721 let report = analyze(args)?;
722
723 if args.context {
724 for path in context_file_list(&report) {
725 println!("{}", path.display());
726 }
727 return Ok(0);
728 }
729
730 if report.changed_files.is_empty() {
731 if args.test {
732 println!();
733 } else if matches!(args.format, Format::Text) {
734 println!(
735 "cargo-impact: no Rust files changed relative to {}",
736 args.since
737 );
738 } else {
739 let out = render_with_budget(args.format, &[], &[], &[], args.budget)?;
740 println!("{out}");
741 }
742 return Ok(0);
743 }
744
745 if args.test {
746 println!("{}", nextest::filter_expression(&report.findings));
747 return Ok(0);
748 }
749
750 let out = render_with_budget(
751 args.format,
752 &report.changed_files,
753 &report.candidate_symbols,
754 &report.findings,
755 args.budget,
756 )?;
757 println!("{out}");
758
759 if let Some(gate) = args.fail_on {
760 let tripped = report.findings.iter().any(|f| gate.triggers(f.severity));
761 if tripped {
762 return Ok(1);
763 }
764 }
765 Ok(0)
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use std::path::Path;
772 use std::process::Command;
773
774 fn git(dir: &Path, args: &[&str]) {
775 let status = Command::new("git")
776 .arg("-C")
777 .arg(dir)
778 .args(args)
779 .status()
780 .unwrap();
781 assert!(status.success(), "git {args:?} failed");
782 }
783
784 fn clean_repo() -> tempfile::TempDir {
785 let dir = tempfile::TempDir::new().unwrap();
786 git(dir.path(), &["init", "-q"]);
787 git(dir.path(), &["config", "user.email", "t@t"]);
788 git(dir.path(), &["config", "user.name", "t"]);
789 git(dir.path(), &["config", "commit.gpgsign", "false"]);
790 git(dir.path(), &["config", "core.autocrlf", "false"]);
791 std::fs::write(
792 dir.path().join("Cargo.toml"),
793 "[package]\nname=\"fixture\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
794 )
795 .unwrap();
796 std::fs::create_dir_all(dir.path().join("src")).unwrap();
797 std::fs::write(dir.path().join("src/lib.rs"), "pub fn untouched() {}\n").unwrap();
798 git(dir.path(), &["add", "-A"]);
799 git(dir.path(), &["commit", "-q", "-m", "init"]);
800 dir
801 }
802
803 fn args_for(root: &Path) -> ImpactArgs {
804 ImpactArgs {
805 test: false,
806 format: Format::Json,
807 since: "HEAD".into(),
808 manifest_dir: Some(root.to_path_buf()),
809 confidence_min: 0.0,
810 fail_on: None,
811 semver_checks: false,
812 rust_analyzer: false,
813 features: Vec::new(),
814 all_features: false,
815 no_default_features: false,
816 budget: 0,
817 context: false,
818 feature_powerset: false,
819 macro_expand: false,
820 cache: false,
821 }
822 }
823
824 #[test]
825 fn fail_on_high_triggers_on_high_only() {
826 assert!(FailOn::High.triggers(SeverityClass::High));
827 assert!(!FailOn::High.triggers(SeverityClass::Medium));
828 assert!(!FailOn::High.triggers(SeverityClass::Low));
829 assert!(!FailOn::High.triggers(SeverityClass::Unknown));
830 }
831
832 #[test]
833 fn fail_on_medium_triggers_on_medium_and_above() {
834 assert!(FailOn::Medium.triggers(SeverityClass::High));
835 assert!(FailOn::Medium.triggers(SeverityClass::Medium));
836 assert!(!FailOn::Medium.triggers(SeverityClass::Low));
837 }
838
839 #[test]
840 fn fail_on_low_triggers_on_everything_but_unknown() {
841 assert!(FailOn::Low.triggers(SeverityClass::High));
842 assert!(FailOn::Low.triggers(SeverityClass::Medium));
843 assert!(FailOn::Low.triggers(SeverityClass::Low));
844 assert!(!FailOn::Low.triggers(SeverityClass::Unknown));
845 }
846
847 #[test]
848 fn clean_workspace_progress_still_emits_done() {
849 let dir = clean_repo();
850 let args = args_for(dir.path());
851 let mut stages = Vec::new();
852 let report = analyze_with_progress(&args, |ev| stages.push(ev.stage.to_string())).unwrap();
853
854 assert!(report.changed_files.is_empty());
855 assert_eq!(stages, vec!["done"]);
856 }
857
858 mod powerset {
859 use super::*;
860 use crate::finding::Location;
861
862 fn mk_finding(id: &str, evidence: &str) -> Finding {
863 let mut f = Finding::new(
864 "",
865 Tier::Likely,
866 0.85,
867 FindingKind::TestReference {
868 test: Location {
869 file: PathBuf::from("tests/a.rs"),
870 symbol: format!("test_{id}"),
871 },
872 matched_symbols: vec![id.to_string()],
873 },
874 evidence,
875 );
876 f.id = id.to_string();
877 f
878 }
879
880 fn report(findings: Vec<Finding>) -> AnalysisReport {
881 AnalysisReport {
882 changed_files: Vec::new(),
883 candidate_symbols: Vec::new(),
884 findings,
885 }
886 }
887
888 #[test]
889 fn baseline_findings_pass_through_unchanged() {
890 let baseline = report(vec![mk_finding("a", "base evidence")]);
891 let nodef = report(vec![]);
892 let all = report(vec![]);
893 let merged = merge_powerset_reports(baseline, nodef, all);
894 assert_eq!(merged.findings.len(), 1);
895 assert_eq!(merged.findings[0].evidence, "base evidence");
896 }
897
898 #[test]
899 fn finding_only_in_nodef_is_annotated_and_appended() {
900 let baseline = report(vec![mk_finding("a", "base")]);
901 let nodef = report(vec![mk_finding("b", "from-nodef")]);
902 let all = report(vec![]);
903 let merged = merge_powerset_reports(baseline, nodef, all);
904 assert_eq!(merged.findings.len(), 2);
905 let b = merged.findings.iter().find(|f| f.id == "b").unwrap();
906 assert!(
907 b.evidence.contains("--no-default-features"),
908 "expected no-default annotation in evidence: {}",
909 b.evidence
910 );
911 assert!(b.evidence.starts_with("from-nodef"));
912 }
913
914 #[test]
915 fn finding_only_in_all_features_is_annotated_and_appended() {
916 let baseline = report(vec![]);
917 let nodef = report(vec![]);
918 let all = report(vec![mk_finding("c", "from-all")]);
919 let merged = merge_powerset_reports(baseline, nodef, all);
920 assert_eq!(merged.findings.len(), 1);
921 let c = &merged.findings[0];
922 assert!(c.evidence.contains("--all-features"));
923 assert!(c.evidence.starts_with("from-all"));
924 }
925
926 #[test]
927 fn finding_visible_in_baseline_and_nodef_keeps_baseline_evidence() {
928 let shared = mk_finding("dup", "baseline-text");
931 let also_shared = mk_finding("dup", "nodef-text");
932 let baseline = report(vec![shared]);
933 let nodef = report(vec![also_shared]);
934 let merged = merge_powerset_reports(baseline, nodef, report(vec![]));
935 assert_eq!(merged.findings.len(), 1);
936 assert_eq!(merged.findings[0].evidence, "baseline-text");
937 }
938
939 #[test]
940 fn same_id_in_both_extras_only_annotates_once() {
941 let baseline = report(vec![]);
945 let nodef = report(vec![mk_finding("x", "ev")]);
946 let all = report(vec![mk_finding("x", "ev")]);
947 let merged = merge_powerset_reports(baseline, nodef, all);
948 assert_eq!(merged.findings.len(), 1);
949 assert!(
950 merged.findings[0]
951 .evidence
952 .contains("--no-default-features")
953 );
954 assert!(!merged.findings[0].evidence.contains("--all-features"));
955 }
956
957 #[test]
958 fn merged_results_stay_sorted_by_severity_then_tier() {
959 let high = {
964 let mut f = Finding::new(
965 "",
966 Tier::Likely,
967 0.9,
968 FindingKind::BuildScriptChanged {
969 file: PathBuf::from("build.rs"),
970 },
971 "build.rs changed",
972 );
973 f.id = "high".into();
974 f
975 };
976 let med = mk_finding("med", "medium finding");
977 let low = {
978 let mut f = Finding::new(
979 "",
980 Tier::Likely,
981 0.4,
982 FindingKind::DocDriftKeyword {
983 symbol: "foo".into(),
984 doc: Location {
985 file: PathBuf::from("README.md"),
986 symbol: "foo".into(),
987 },
988 line: 1,
989 },
990 "doc drift",
991 );
992 f.id = "low".into();
993 f
994 };
995 let baseline = report(vec![low]);
996 let nodef = report(vec![med]);
997 let all = report(vec![high]);
998 let merged = merge_powerset_reports(baseline, nodef, all);
999 let severities: Vec<_> = merged.findings.iter().map(|f| f.severity).collect();
1000 assert_eq!(
1001 severities,
1002 vec![
1003 SeverityClass::High,
1004 SeverityClass::Medium,
1005 SeverityClass::Low
1006 ]
1007 );
1008 }
1009 }
1010}