Skip to main content

cargo_impact/
lib.rs

1//! `cargo-impact` — blast-radius analysis for Rust workspaces.
2//!
3//! This is v0.4 per the README §11 roadmap. The headline shipped
4//! surface, end-to-end:
5//!
6//! Core analyzers — [`Finding`], [`FindingKind`], [`Tier`]
7//! * Confidence tiers (`Proven` / `Likely` / `Possible` / `Unknown`)
8//!   with numeric scores; only RA-backed resolution reaches `Proven`.
9//! * Test-reference detection, trait ripple (`impl Trait for T`),
10//!   `dyn Trait` dispatch, derive-macro impl fan-out, documentation
11//!   drift (intra-doc links + keyword fallback), FFI signature
12//!   changes, `build.rs` change detection, per-method trait-definition
13//!   classification (required vs. default vs. signature vs. body).
14//! * Framework adapters: axum / clap (v0.3) + actix-web / rocket
15//!   (v0.4-stretch), HTTP-verb attribute macros shared across.
16//!
17//! Public-API precision
18//! * `cargo-semver-checks` integration (opt-in via `--semver-checks`).
19//! * rust-analyzer LSP client for `Proven`-tier resolved references;
20//!   per-reference severity refinement based on enclosing container
21//!   (test fn → `Low`, impl block → `High`, caller → `Medium`).
22//! * Macro expansion via `cargo expand` (opt-in via `--macro-expand`)
23//!   for derive/attribute-macro impls that syn-only analysis can't see.
24//!
25//! Orchestration
26//! * Content-hashed finding IDs, stable across runs — powers
27//!   `impact_explain` round-trip by ID.
28//! * syn/RA dedup: syn-only findings covered by a Proven
29//!   `ResolvedReference` at the same `(name, file)` pair are dropped.
30//! * Depth-1 `--feature-powerset` (baseline + no-default + all-features)
31//!   with evidence annotation identifying the set that revealed each
32//!   finding.
33//! * cfg-aware AST filtering against the resolved feature set
34//!   (`--features` / `--all-features` / `--no-default-features`).
35//!
36//! Output
37//! * `--format={text,markdown,json,sarif,pr-comment}` — SARIF v2.1.0
38//!   renders on GitHub code scanning; pr-comment is optimized for
39//!   sticky PR comments (collapsed `<details>` per severity).
40//! * Deterministic: two runs over the same diff produce byte-identical
41//!   output across every format.
42//! * `--budget=<N>` chars for rendered markdown, for agent context
43//!   windows.
44//! * `--context` emits a newline-delimited file list for piping into
45//!   `cargo-context --files-from -`.
46//! * `--confidence-min` and `--fail-on={high,medium,low}` for CI gating.
47//!
48//! MCP surface (`cargo impact mcp`)
49//! * Six tools: `impact_analyze`, `impact_test_filter`, `impact_surface`,
50//!   `impact_semver`, `impact_explain`, `impact_version`.
51//! * `impact_analyze` streams `notifications/message` progress events
52//!   at analyzer stage boundaries so long runs give live feedback.
53//!
54//! Honest caveats (surface when asked "why didn't cargo-impact flag X?"):
55//! * `cfg_attr(feature = "x", derive(…))` is invisible to our analyzer
56//!   — over-counts slightly when users conditionally derive.
57//! * Macro expansion is opt-in and points to a synthetic `<expanded>`
58//!   file rather than source-mapping back to the derive site.
59//! * `log-miss` records stay on disk only (`target/ai-tools-cache/`);
60//!   we never phone home.
61//!
62//! # Programmatic use
63//!
64//! ```
65//! use cargo_impact::{nextest_filter, Finding, FindingKind, Location, Tier};
66//! use std::path::PathBuf;
67//!
68//! let kind = FindingKind::TestReference {
69//!     test: Location { file: PathBuf::from("tests/a.rs"), symbol: "smoke".into() },
70//!     matched_symbols: vec!["login".into()],
71//! };
72//! let findings = [Finding::new("f-0001", Tier::Likely, 0.85, kind, "ref")];
73//! assert_eq!(nextest_filter(&findings), "test(smoke)");
74//! ```
75
76use 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
111/// Deduped list of files implicated by the blast radius. Combines the
112/// raw `changed_files` from git with each finding's `primary_path`.
113/// Used by the `--context` short-circuit and exposed publicly so
114/// downstream tooling can compute the same set without re-running the
115/// analyzers.
116pub 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/// Command-line arguments for `cargo impact`.
128#[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    /// Emit a `cargo-nextest` filter expression instead of the structured
138    /// report. Overrides `--format`.
139    #[arg(long)]
140    pub test: bool,
141
142    /// Output format for the structured report.
143    #[arg(long, value_enum, default_value_t = Format::Text)]
144    pub format: Format,
145
146    /// Git ref to diff against. Uncommitted (staged + unstaged) changes are
147    /// always included regardless of this value.
148    #[arg(long, default_value = "HEAD")]
149    pub since: String,
150
151    /// Repository root. Defaults to the current working directory.
152    #[arg(long)]
153    pub manifest_dir: Option<PathBuf>,
154
155    /// Hide findings whose confidence is below this threshold (0.0–1.0).
156    #[arg(long, default_value_t = 0.0)]
157    pub confidence_min: f64,
158
159    /// Exit non-zero if any finding at this severity class or above is
160    /// emitted. Useful for CI gating. `high` only gates on high-severity
161    /// findings; `medium` gates on medium+high; `low` gates on any non-unknown.
162    #[arg(long, value_enum)]
163    pub fail_on: Option<FailOn>,
164
165    /// Opt in to public-API breakage detection via `cargo-semver-checks`.
166    /// Off by default because the underlying tool builds rustdoc JSON twice
167    /// and typically takes 10–30 seconds. Requires `cargo-semver-checks` on
168    /// `PATH`; if absent, a stderr warning is printed and the check is
169    /// skipped (non-fatal).
170    #[arg(long)]
171    pub semver_checks: bool,
172
173    /// Opt in to `rust-analyzer`-backed analysis for `Proven`-tier findings.
174    /// v0.3-alpha scaffolding: the flag is wired through and the tool's
175    /// presence on `PATH` is detected, but the LSP integration itself is a
176    /// stub that returns no findings. Full implementation lands in a
177    /// follow-up v0.3 release (see README §11).
178    #[arg(long)]
179    pub rust_analyzer: bool,
180
181    /// Character budget for the rendered output — useful for keeping
182    /// `--format markdown` inside an AI agent's context window. `0` (the
183    /// default) means unlimited. Only affects the markdown renderer;
184    /// text is for human terminals, JSON is for programmatic consumers
185    /// who can filter themselves. Chars ≈ ¼ token for mainstream models
186    /// (claude, gpt-4-ish tokenizers), so `--budget=32000` fits ≈ 8k
187    /// tokens. The header + summary always render even if they alone
188    /// exceed the budget; severity sections and the checklist are
189    /// truncated in priority order (severity → tier → confidence) with
190    /// a footer noting how many findings were dropped.
191    #[arg(long, default_value_t = 0)]
192    pub budget: usize,
193
194    /// Emit a newline-delimited list of files implicated by the blast
195    /// radius (one repo-relative path per line) instead of the normal
196    /// report. Pipes directly into
197    /// [`cargo-context`](https://github.com/asmuelle/cargo-context)'s
198    /// `--files-from -` flag for the canonical handoff:
199    /// `cargo impact --context | cargo context --files-from -`.
200    /// Also consumable by any file-list tool (`xargs cat`, `grep -l`,
201    /// etc.). Unique paths only. Overrides `--format` and `--test`.
202    #[arg(long)]
203    pub context: bool,
204
205    /// Activate these Cargo features for cfg evaluation. Accepts a
206    /// comma-separated list and/or repeated flags. Takes precedence over
207    /// the manifest's `default` set and is unioned with it unless
208    /// `--no-default-features` is also supplied. Transitively expands
209    /// feature dependencies per the manifest's `[features]` table.
210    #[arg(long = "features", value_delimiter = ',')]
211    pub features: Vec<String>,
212
213    /// Activate every feature declared in the manifest's `[features]`
214    /// table. Mutually useful with `--no-default-features` when you want
215    /// to audit the full surface instead of just the default view.
216    #[arg(long, conflicts_with = "no_default_features")]
217    pub all_features: bool,
218
219    /// Skip the manifest's `default` feature list. Mirrors cargo's
220    /// `--no-default-features`.
221    #[arg(long)]
222    pub no_default_features: bool,
223
224    /// Opt in to `cargo-expand`-backed trait-impl detection for impls
225    /// synthesized by derive and attribute macros (serde, tokio,
226    /// clap, thiserror, …). Requires `cargo-expand` on PATH; install
227    /// via `cargo install cargo-expand`. When absent or expansion
228    /// fails, a stderr warning is printed and the check is skipped
229    /// (non-fatal). Off by default because the expansion pass adds
230    /// 10-60s depending on crate size.
231    #[arg(long)]
232    pub macro_expand: bool,
233
234    /// Run analysis across a depth-1 feature powerset (baseline +
235    /// `--no-default-features` + `--all-features`) and surface
236    /// findings that the baseline view missed. Intended for CI —
237    /// triples analyzer cost so off by default. Findings visible
238    /// only under a non-baseline set are annotated in evidence
239    /// with the feature set that revealed them. A full feature
240    /// powerset (O(2^N) over N features) is out of scope; the
241    /// depth-1 view catches the usual feature-gated blast radius
242    /// (std/no_std, sync/async, feature = "foo" paths) without
243    /// combinatorial blow-up.
244    #[arg(long)]
245    pub feature_powerset: bool,
246
247    /// Enable content-hash caching for incremental analysis.
248    /// Caches top-level symbol extraction by file content hash, avoiding
249    /// re-parsing unchanged fallback files across runs. Cache lives at
250    /// `target/cargo-impact/cache/`.
251    #[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/// Result of running every analyzer against the workspace. Produced by
278/// [`analyze`] and consumed by both the CLI ([`run`]) and the MCP server
279/// (`cargo impact mcp`).
280///
281/// Findings already carry stable IDs, are sorted by severity / tier, and
282/// have been filtered against `args.confidence_min` — downstream renderers
283/// can emit them directly.
284#[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/// Single progress update emitted during an analysis run. Surfaced via
292/// [`analyze_with_progress`] so long-running invocations (typically
293/// `--rust-analyzer` or `--semver-checks`) can give the caller a live
294/// signal instead of a 30-second silence.
295///
296/// Stages are a small fixed vocabulary so consumers can map them to
297/// UI strings or progress bars without scraping free text:
298///
299/// * `"symbols"` — collecting top-level items from changed files.
300///   `current`/`total` count files processed.
301/// * `"analyzers"` — running the per-file/per-symbol analyzers.
302///   `total` is the number of analyzer passes; `current` is how many
303///   have completed.
304/// * `"semver_checks"` — invoking `cargo-semver-checks`. Only emitted
305///   when the flag is on; `current`/`total` are both 1 (start/done).
306/// * `"rust_analyzer"` — driving the RA LSP subprocess. Only emitted
307///   when the flag is on; `current`/`total` are both 1.
308/// * `"done"` — final emit, always sent at the end of a successful
309///   run. `current == total`.
310#[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
318/// Run every analyzer and return a structured report.
319///
320/// This is the single source of truth for "what does cargo-impact think
321/// about this diff?" — [`run`] wraps it with CLI printing / exit-code
322/// handling, the MCP server serializes its output into JSON content, and
323/// integration tests call it directly.
324///
325/// Equivalent to [`analyze_with_progress`] with a no-op callback.
326pub fn analyze(args: &ImpactArgs) -> Result<AnalysisReport> {
327    analyze_with_progress(args, |_| {})
328}
329
330/// Run every analyzer with a progress callback invoked at stage
331/// boundaries. Use this instead of [`analyze`] when the caller wants
332/// live feedback during long invocations — typically the MCP server
333/// bridging the callback to `notifications/message` or an interactive
334/// CLI printing to stderr.
335///
336/// The callback is synchronous: it runs on the analyzer thread, so
337/// keep it cheap (writing a few hundred bytes is fine; blocking on a
338/// network call is not). Order of `stage` values is stable; see
339/// [`ProgressEvent`] for the vocabulary.
340pub 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    // Merge cargo-impact.toml defaults into the args struct before
350    // anything else consults those fields. apply_config only overrides
351    // values that look clap-default, so explicit CLI flags always win.
352    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    // Resolve features once, install as thread-local for the whole analyzer
358    // block. `cfg::parse_and_filter` — used by every analyzer in place of
359    // `syn::parse_file` — reads the thread-local to strip items whose cfg
360    // gates don't match.
361    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    // Depth-1 powerset: baseline + no-default-features + all-features.
376    // Each is a separate analyzer run (git diff, symbol extraction,
377    // all analyzer passes) under a different cfg view; combined cost
378    // is roughly 3x a normal run, which is why this is flagged off by
379    // default and documented as CI-only.
380    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
404/// Combine three analyzer reports (baseline, no-default-features,
405/// all-features) into one. The baseline's findings are kept verbatim;
406/// findings that appear in one of the other passes but NOT in the
407/// baseline are appended with an evidence suffix identifying the
408/// feature set that revealed them. Dedup is by finding ID (content-
409/// hashed, so identical findings across passes carry identical IDs).
410///
411/// A finding that appears only under `--no-default-features` indicates
412/// a code path that your baseline analysis misses — typically a
413/// `#[cfg(not(feature = "std"))]` branch or a fallback path. A finding
414/// that appears only under `--all-features` means some optional feature
415/// introduces blast radius the default view doesn't see. Both are the
416/// signal a CI-gated powerset run is supposed to catch.
417fn 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    // Re-sort so feature-revealed findings interleave by severity with
440    // baseline findings rather than always landing at the bottom.
441    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    // Collect symbols per changed file, diff-aware when possible; fall back
490    // to blanket file-level analysis when the diff can't be computed.
491    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    // Six syn-based analyzers run in sequence. Emit a stage start per
523    // analyzer so consumers can render a progress bar. Names are
524    // stable; keep them aligned with the source order below.
525    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    // Drop syn-only Likely findings that a Proven RA ResolvedReference
633    // already covers at the same (name, file) pair. Runs before ignore /
634    // confidence filtering and ID assignment so shadowed syn findings
635    // never consume IDs or affect summary counts. No-op when RA is off
636    // or returned nothing.
637    dedup::dedup_syn_under_proven(&mut findings);
638
639    // Drop macro-expansion TestReference findings whose test name is
640    // already covered by a raw-source TestReference. Expansion-backed
641    // findings share the `<expanded>` sentinel path so they'd otherwise
642    // double-count tests the syn-only walker already caught. No-op when
643    // --macro-expand is off or expansion produced no test-refs.
644    dedup::dedup_expanded_under_raw(&mut findings);
645
646    // Apply .impactignore filtering before confidence threshold and ID
647    // assignment — ignored findings shouldn't consume ID slots or affect
648    // the summary counts. Findings with no primary path (SemverCheck) are
649    // never ignored; the ignore file is about file-scoped noise.
650    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    // Assign content-hashed IDs *before* sorting so the same finding
661    // receives the same ID across runs — required for impact_explain to
662    // round-trip by ID. Ties on (severity, tier, kind, evidence) are
663    // broken by ID as a deterministic last-resort key.
664    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
717/// CLI entry: runs [`analyze`], prints the configured format, honors the
718/// `--test` short-circuit and `--fail-on` gate. Returns the intended exit
719/// code (0 = clean / no gate tripped, 1 = `--fail-on` matched).
720pub 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            // Same ID in baseline and nodef — baseline wins, no
929            // annotation. The ID dedup prevents double-counting.
930            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            // A finding absent from baseline, present in both extras,
942            // gets appended exactly once — with whichever annotation
943            // the first-encountered extra (nodef) supplied.
944            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            // High+Likely < Medium+Likely < Low+Likely in our sort
960            // (severity ascending, then tier desc by rank). Feed them
961            // in reverse order across the three reports and verify
962            // the output ordering.
963            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}