use crate::finding::{Finding, FindingKind, Location, Tier};
use anyhow::Result;
use std::collections::BTreeSet;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use syn::visit::Visit;
use syn::{ItemImpl, Path as SynPath, Type, TypePath};
const TOOL_BIN: &str = "cargo-expand";
const MACRO_EXPAND_TIMEOUT: Duration = Duration::from_secs(90);
pub fn run(root: &Path, changed_traits: &BTreeSet<String>, enabled: bool) -> Result<Vec<Finding>> {
if !enabled {
return Ok(Vec::new());
}
if changed_traits.is_empty() {
return Ok(Vec::new());
}
if !is_installed() {
eprintln!(
"cargo-impact: --macro-expand requested but `{TOOL_BIN}` not found on PATH. \
Install it via `cargo install cargo-expand`; skipping."
);
return Ok(Vec::new());
}
let expanded = match run_cargo_expand(root) {
Ok(s) => s,
Err(e) => {
eprintln!("cargo-impact: cargo-expand invocation failed: {e:#}; skipping.");
return Ok(Vec::new());
}
};
Ok(find_impls_in_expanded(&expanded, changed_traits))
}
pub(crate) fn find_impls_in_expanded(
expanded: &str,
changed_traits: &BTreeSet<String>,
) -> Vec<Finding> {
let Ok(ast) = syn::parse_file(expanded) else {
eprintln!(
"cargo-impact: cargo-expand output didn't parse as a syn::File; skipping. \
This is usually a stability bug in the expansion; report with the expanded \
output attached."
);
return Vec::new();
};
let mut visitor = ImplVisitor {
changed_traits,
hits: Vec::new(),
};
visitor.visit_file(&ast);
visitor
.hits
.into_iter()
.map(|(trait_name, impl_for)| {
let evidence = format!(
"`impl {trait_name} for {impl_for}` — revealed by macro expansion (syn-only \
analysis doesn't see impls synthesized by derive/attribute macros like \
serde, tokio, clap, thiserror)"
);
let kind = FindingKind::TraitImpl {
trait_name: trait_name.clone(),
impl_for: impl_for.clone(),
impl_site: Location {
file: std::path::PathBuf::from("<expanded>"),
symbol: format!("impl {trait_name} for {impl_for}"),
},
};
Finding::new("", Tier::Likely, 0.75, kind, evidence)
})
.collect()
}
fn is_installed() -> bool {
which(TOOL_BIN).is_some()
}
fn which(name: &str) -> Option<std::path::PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let with_exe = candidate.with_extension("exe");
if with_exe.is_file() {
return Some(with_exe);
}
}
}
None
}
fn run_cargo_expand(root: &Path) -> Result<String> {
let mut cmd = Command::new("cargo");
cmd.arg("expand")
.arg("--lib")
.arg("--color=never")
.arg("--ugly") .current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let out = child.wait_with_output()?;
if !status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("cargo expand exited with status {status:?}; stderr:\n{stderr}");
}
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
if start.elapsed() > MACRO_EXPAND_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"cargo expand did not finish within {:?}",
MACRO_EXPAND_TIMEOUT
);
}
std::thread::sleep(Duration::from_millis(100));
}
}
struct ImplVisitor<'a> {
changed_traits: &'a BTreeSet<String>,
hits: Vec<(String, String)>,
}
impl<'ast> Visit<'ast> for ImplVisitor<'_> {
fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
if let Some((_, trait_path, _)) = &node.trait_
&& let Some(trait_name) = last_ident(trait_path)
&& self.changed_traits.contains(&trait_name)
{
let impl_for = type_to_string(&node.self_ty);
self.hits.push((trait_name, impl_for));
}
syn::visit::visit_item_impl(self, node);
}
}
fn last_ident(path: &SynPath) -> Option<String> {
path.segments.last().map(|s| s.ident.to_string())
}
fn type_to_string(ty: &Type) -> String {
use quote::ToTokens;
if let Type::Path(TypePath { qself: None, path }) = ty
&& let Some(seg) = path.segments.last()
{
return seg.ident.to_string();
}
ty.to_token_stream().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn changed(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn empty_changed_set_returns_no_findings() {
let src = "impl Serialize for S {}";
let hits = find_impls_in_expanded(src, &BTreeSet::new());
assert!(hits.is_empty());
}
#[test]
fn matches_derived_impl_on_changed_trait() {
let src = "struct S; impl Greeter for S { fn hi(&self) {} }";
let hits = find_impls_in_expanded(src, &changed(&["Greeter"]));
assert_eq!(hits.len(), 1);
let FindingKind::TraitImpl {
trait_name,
impl_for,
..
} = &hits[0].kind
else {
panic!("wrong kind");
};
assert_eq!(trait_name, "Greeter");
assert_eq!(impl_for, "S");
}
#[test]
fn evidence_calls_out_macro_expansion_source() {
let src = "impl Greeter for S { fn hi(&self) {} }";
let hits = find_impls_in_expanded(src, &changed(&["Greeter"]));
assert!(
hits[0].evidence.contains("revealed by macro expansion"),
"evidence should mark the finding as expansion-derived: {}",
hits[0].evidence
);
}
#[test]
fn ignores_impls_on_unchanged_traits() {
let src = "impl Unrelated for S { }";
let hits = find_impls_in_expanded(src, &changed(&["Greeter"]));
assert!(hits.is_empty());
}
#[test]
fn matches_impl_via_last_path_segment() {
let src = "impl ::serde::Serialize for S { }";
let hits = find_impls_in_expanded(src, &changed(&["Serialize"]));
assert_eq!(hits.len(), 1);
}
#[test]
fn multiple_matches_in_one_stream_all_emitted() {
let src = "
impl A for X { }
impl A for Y { }
impl B for Z { }
";
let hits = find_impls_in_expanded(src, &changed(&["A", "B"]));
assert_eq!(hits.len(), 3);
}
#[test]
fn unparseable_input_returns_empty_without_panicking() {
let hits = find_impls_in_expanded("this is {{ not syn parseable", &changed(&["X"]));
assert!(hits.is_empty());
}
#[test]
fn disabled_flag_short_circuits_before_calling_cargo() {
let findings = run(Path::new("/nonexistent"), &changed(&["X"]), false).unwrap();
assert!(findings.is_empty());
}
#[test]
fn empty_changed_traits_short_circuits_before_spawning() {
let findings = run(Path::new("/nonexistent"), &BTreeSet::new(), true).unwrap();
assert!(findings.is_empty());
}
}