use crate::finding::{Finding, FindingKind, Location, Tier};
use anyhow::Result;
use std::collections::BTreeSet;
use std::path::Path;
use syn::visit::Visit;
use syn::{Attribute, ExprPath, ItemFn, Path as SynPath, TypePath};
use walkdir::{DirEntry, WalkDir};
pub fn find_affected_tests(
root: &Path,
changed_symbols: &BTreeSet<String>,
) -> Result<Vec<Finding>> {
if changed_symbols.is_empty() {
return Ok(Vec::new());
}
let mut findings = Vec::new();
for entry in workspace_rust_files(root) {
let path = entry.path();
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
let Some(ast) = crate::cfg::parse_and_filter(&src) else {
continue;
};
let mut visitor = TestVisitor {
changed: changed_symbols,
hits: Vec::new(),
};
visitor.visit_file(&ast);
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
for (test_name, matched) in visitor.hits {
let matched_vec: Vec<String> = matched.into_iter().collect();
let evidence = format!(
"test body references {} (syntactic match, no name resolution)",
matched_vec.join(", ")
);
let kind = FindingKind::TestReference {
test: Location {
file: rel.clone(),
symbol: test_name.clone(),
},
matched_symbols: matched_vec,
};
findings.push(
Finding::new("", Tier::Likely, 0.85, kind, evidence)
.with_suggested_action(format!("cargo nextest run -E 'test({test_name})'")),
);
}
}
Ok(findings)
}
pub(crate) fn workspace_rust_files(root: &Path) -> impl Iterator<Item = DirEntry> {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_skippable(e))
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rs"))
}
fn is_skippable(entry: &DirEntry) -> bool {
if entry.depth() == 0 {
return false;
}
let name = entry.file_name().to_string_lossy();
(name.starts_with('.') && entry.file_type().is_dir()) || name == "target"
}
struct TestVisitor<'a> {
changed: &'a BTreeSet<String>,
hits: Vec<(String, BTreeSet<String>)>,
}
impl<'ast> Visit<'ast> for TestVisitor<'_> {
fn visit_item_fn(&mut self, f: &'ast ItemFn) {
if is_test_fn(&f.attrs) {
let mut refs = ReferenceVisitor {
changed: self.changed,
matched: BTreeSet::new(),
};
refs.visit_block(&f.block);
let matched = refs.matched;
if !matched.is_empty() {
self.hits.push((f.sig.ident.to_string(), matched));
}
}
syn::visit::visit_item_fn(self, f);
}
}
struct ReferenceVisitor<'a> {
changed: &'a BTreeSet<String>,
matched: BTreeSet<String>,
}
impl<'ast> Visit<'ast> for ReferenceVisitor<'_> {
fn visit_expr_path(&mut self, node: &'ast ExprPath) {
self.match_path(&node.path);
syn::visit::visit_expr_path(self, node);
}
fn visit_type_path(&mut self, node: &'ast TypePath) {
self.match_path(&node.path);
syn::visit::visit_type_path(self, node);
}
}
impl ReferenceVisitor<'_> {
fn match_path(&mut self, path: &SynPath) {
for segment in &path.segments {
let ident = segment.ident.to_string();
if self.changed.contains(&ident) {
self.matched.insert(ident);
}
}
}
}
pub(crate) fn is_test_fn(attrs: &[Attribute]) -> bool {
attrs.iter().any(|a| {
a.path()
.segments
.last()
.map(|s| matches!(s.ident.to_string().as_str(), "test" | "rstest" | "bench"))
.unwrap_or(false)
})
}
pub(crate) fn tokens_contain_ident(haystack: &str, needle: &str) -> bool {
haystack
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|tok| tok == needle)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().expect("tempdir");
for (rel, body) in files {
let p = dir.path().join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, body).unwrap();
}
dir
}
fn symbols(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn matches_test_that_references_changed_symbol() {
let dir = setup(&[(
"tests/smoke.rs",
r#"
#[test]
fn smoke() {
let _ = login();
}
"#,
)]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].tier, Tier::Likely);
assert_eq!(hits[0].confidence, 0.85);
match &hits[0].kind {
FindingKind::TestReference {
test,
matched_symbols,
} => {
assert_eq!(test.symbol, "smoke");
assert_eq!(matched_symbols, &vec!["login".to_string()]);
}
other => panic!("expected TestReference, got {other:?}"),
}
assert!(
hits[0]
.suggested_action
.as_deref()
.is_some_and(|s| s.contains("test(smoke)"))
);
}
#[test]
fn ignores_non_test_functions() {
let dir = setup(&[("src/lib.rs", "fn helper() { let _ = login(); }")]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
assert!(hits.is_empty());
}
#[test]
fn matches_tokio_test_and_rstest_attributes() {
let dir = setup(&[(
"tests/async.rs",
r#"
#[tokio::test]
async fn async_smoke() { login().await; }
#[rstest]
fn parametrized(#[case] _x: u32) { let _ = login; }
"#,
)]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
let names: Vec<_> = hits
.iter()
.map(|h| match &h.kind {
FindingKind::TestReference { test, .. } => test.symbol.clone(),
_ => String::new(),
})
.collect();
assert!(names.contains(&"async_smoke".to_string()));
assert!(names.contains(&"parametrized".to_string()));
}
#[test]
fn does_not_match_substring_identifiers() {
let dir = setup(&[(
"tests/false_positive.rs",
"#[test] fn t() { let login_helper = 1; let _ = login_helper; }",
)]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
assert!(hits.is_empty(), "unexpected hits: {hits:?}");
}
#[test]
fn does_not_match_method_call_names_as_free_functions() {
let dir = setup(&[(
"tests/method.rs",
"#[test] fn t() { let xs = vec![1, 2]; let _: Vec<_> = xs.iter().collect(); }",
)]);
let hits = find_affected_tests(dir.path(), &symbols(&["collect"])).unwrap();
assert!(hits.is_empty(), "unexpected hits: {hits:?}");
}
#[test]
fn matches_type_and_qualified_path_references() {
let dir = setup(&[(
"tests/path_refs.rs",
"#[test] fn t() { let _: User = crate::make_user(); let _ = User::default(); }",
)]);
let hits = find_affected_tests(dir.path(), &symbols(&["User", "make_user"])).unwrap();
assert_eq!(hits.len(), 1);
match &hits[0].kind {
FindingKind::TestReference {
matched_symbols, ..
} => {
assert!(matched_symbols.contains(&"User".to_string()));
assert!(matched_symbols.contains(&"make_user".to_string()));
}
other => panic!("expected TestReference, got {other:?}"),
}
}
#[test]
fn empty_changed_set_returns_empty() {
let dir = TempDir::new().unwrap();
let hits = find_affected_tests(dir.path(), &BTreeSet::new()).unwrap();
assert!(hits.is_empty());
}
#[test]
fn traverses_multi_crate_workspace_members() {
let dir = setup(&[
(
"crate-a/src/lib.rs",
"#[test] fn test_a() { let _ = login(); }",
),
(
"crate-b/tests/integration.rs",
"#[test] fn test_b() { let _ = login(); }",
),
(
"crate-c/nested/deep/src/thing.rs",
"#[test] fn test_c() { let _ = login(); }",
),
]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
let mut names: Vec<_> = hits
.iter()
.map(|h| match &h.kind {
FindingKind::TestReference { test, .. } => test.symbol.clone(),
_ => String::new(),
})
.collect();
names.sort();
assert_eq!(
names,
vec![
"test_a".to_string(),
"test_b".to_string(),
"test_c".to_string(),
]
);
}
#[test]
fn skips_target_and_hidden_directories() {
let dir = setup(&[
(
"target/debug/build.rs",
"#[test] fn should_not_match() { login(); }",
),
(
".git/hooks/pre-commit.rs",
"#[test] fn should_not_match_either() { login(); }",
),
("tests/real.rs", "#[test] fn real() { let _ = login(); }"),
]);
let hits = find_affected_tests(dir.path(), &symbols(&["login"])).unwrap();
let names: Vec<_> = hits
.iter()
.map(|h| match &h.kind {
FindingKind::TestReference { test, .. } => test.symbol.clone(),
_ => String::new(),
})
.collect();
assert_eq!(names, vec!["real".to_string()]);
}
}