use camino::{Utf8Path, Utf8PathBuf};
use syn::{Attribute, Item, ItemMod};
use crate::discover::record::digest;
use crate::parse::SourceFile;
use crate::{HashMap, HashSet};
#[derive(Debug, Default)]
pub struct Killers {
complete: bool,
paths: HashSet<String>,
leaves: HashSet<String>,
paths_to_file: HashMap<String, Utf8PathBuf>,
file_digests: HashMap<Utf8PathBuf, String>,
}
impl Killers {
#[cfg(test)]
#[must_use]
pub fn scan(files: &[Utf8PathBuf]) -> Self {
Self::scan_complete(files, true)
}
#[must_use]
pub(super) fn scan_complete(files: &[Utf8PathBuf], complete: bool) -> Self {
let mut found = Self {
complete,
..Self::default()
};
for file in files {
let Ok(bytes) = std::fs::read(file.as_std_path()) else {
found.complete = false;
continue;
};
let file_digest = digest(&bytes);
let _ = found.file_digests.insert(file.clone(), file_digest);
let Ok(text) = core::str::from_utf8(&bytes) else {
found.complete = false;
continue;
};
let Ok(source) = SourceFile::parse(file.as_str(), text.to_owned()) else {
found.complete = false;
continue;
};
found.absorb(&source.ast().items, "", file);
}
found
}
#[cfg(test)]
#[must_use]
pub const fn is_complete(&self) -> bool {
self.complete
}
#[cfg(test)]
#[must_use]
pub fn file_for(&self, killer: &str) -> Option<&Utf8Path> {
let path = killer.rsplit('$').next().unwrap_or(killer).trim();
if path.is_empty() {
return None;
}
if let Some(file) = self.paths_to_file.get(path) {
return Some(file.as_path());
}
let leaf = path.rsplit("::").next().unwrap_or(path);
let mut files = self
.paths
.iter()
.filter(|candidate| candidate.rsplit("::").next() == Some(leaf))
.filter_map(|candidate| self.paths_to_file.get(candidate));
let file = files.next()?;
files.next().is_none().then_some(file.as_path())
}
#[must_use]
pub fn file_digest(&self, file: &Utf8Path) -> Option<&str> {
self.file_digests.get(file).map(String::as_str)
}
#[cfg(test)]
#[must_use]
pub fn still_there(&self, killer: &str) -> bool {
let path = killer.rsplit('$').next().unwrap_or(killer).trim();
if path.is_empty() {
return false;
}
if self.paths.contains(path) {
return true;
}
self.leaves.contains(path.rsplit("::").next().unwrap_or(path))
}
#[must_use]
pub fn verdict_file_for(&self, killer: &str) -> Option<&Utf8Path> {
if !self.complete {
return None;
}
let path = killer.rsplit('$').next().unwrap_or(killer).trim();
if path.is_empty() {
return None;
}
self.paths_to_file.get(path).map(Utf8PathBuf::as_path)
}
fn absorb(&mut self, items: &[Item], prefix: &str, file: &Utf8Path) {
for item in items {
match item {
Item::Fn(function) if is_test(&function.attrs) => {
let name = function.sig.ident.to_string();
let full = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}::{name}")
};
let _added = self.paths.insert(full.clone());
let _ = self.paths_to_file.insert(full, file.to_path_buf());
let _also = self.leaves.insert(name.clone());
}
Item::Mod(module) => self.descend(module, prefix, file),
_other => {}
}
}
}
fn descend(&mut self, module: &ItemMod, prefix: &str, file: &Utf8Path) {
let Some((_brace, items)) = module.content.as_ref() else {
return;
};
let name = module.ident.to_string();
let inner = if prefix.is_empty() { name } else { format!("{prefix}::{name}") };
self.absorb(items, &inner, file);
}
}
fn is_test(attrs: &[Attribute]) -> bool {
attrs
.iter()
.filter_map(|attr| attr.path().segments.last())
.any(|segment| segment.ident == "test")
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use super::*;
fn indexed(text: &str) -> Killers {
let directory = crate::testing::workdir("killers-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("utf8");
let file = root.join("lib.rs");
std::fs::write(file.as_std_path(), text).expect("fixture");
Killers::scan(&[file])
}
#[test]
fn a_test_that_no_longer_exists_is_not_found() {
let killers = indexed("#[cfg(test)]\nmod tests {\n #[test]\n fn rejects_empty() {}\n}\n");
assert!(killers.still_there("tests::rejects_empty"));
assert!(!killers.still_there("tests::rejects_whitespace"));
}
#[test]
fn every_spelling_a_harness_uses_finds_the_same_test() {
let killers = indexed("#[cfg(test)]\nmod tests {\n #[tokio::test]\n async fn reads_it() {}\n}\n");
assert!(killers.still_there("tests::reads_it"), "libtest");
assert!(killers.still_there("my-crate::lib$tests::reads_it"), "nextest");
assert!(killers.still_there("reads_it"), "a bare name");
assert!(!killers.still_there(""), "and nothing is not a name");
}
#[test]
fn a_test_nested_in_modules_is_indexed_under_its_whole_path() {
let killers = indexed("mod outer {\n mod inner {\n #[test]\n fn deep() {}\n }\n}\n");
assert!(killers.still_there("outer::inner::deep"));
assert!(killers.still_there("somewhere::else::deep"), "the leaf is what a moved test keeps");
assert!(
!killers.still_there("outer::inner::shallow"),
"a name nothing declares is not a match"
);
}
#[test]
fn a_plain_function_is_not_a_test() {
let killers = indexed("#[cfg(test)]\nmod tests {\n fn helper() {}\n\n #[test]\n fn real() {}\n}\n");
assert!(killers.still_there("tests::real"));
assert!(!killers.still_there("tests::helper"));
}
#[test]
fn scan_completeness_is_separate_from_test_count() {
assert!(!Killers::default().is_complete());
assert!(indexed("pub fn f() {}\n").is_complete());
assert!(indexed("#[test]\nfn t() {}\n").is_complete());
}
#[test]
fn a_qualified_name_never_falls_back_to_its_leaf_for_a_verdict() {
let killers = indexed("mod left { #[test] fn same() {} }\nmod right { #[test] fn same() {} }\n");
assert!(killers.still_there("missing::same"), "hints may still use a leaf");
assert_eq!(killers.verdict_file_for("missing::same"), None);
assert_eq!(killers.verdict_file_for("same"), None, "duplicate bare names are ambiguous");
assert!(killers.verdict_file_for("left::same").is_some());
}
#[test]
fn a_nested_test_leaf_is_not_an_identity_for_a_verdict() {
let killers = indexed("mod nested { #[test] fn only() {} }\n");
assert!(killers.still_there("only"), "a probe may still try the unique leaf");
assert_eq!(killers.verdict_file_for("only"), None);
assert!(killers.verdict_file_for("nested::only").is_some());
}
}