use std::collections::{HashMap, VecDeque};
use crate::store::{CallGraph, ChunkSummary};
#[derive(Debug, Clone)]
pub struct TestMatch {
pub name: String,
pub file: String,
pub line: u32,
pub depth: usize,
pub chain: Vec<String>,
}
pub fn find_test_matches(
graph: &CallGraph,
test_chunks: &[ChunkSummary],
target_name: &str,
max_depth: usize,
rel_display: impl Fn(&ChunkSummary) -> String,
) -> Vec<TestMatch> {
let _span = tracing::info_span!("find_test_matches", target = target_name).entered();
let mut ancestors: HashMap<String, (usize, String)> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
ancestors.insert(target_name.to_string(), (0, String::new()));
queue.push_back((target_name.to_string(), 0));
while let Some((current, depth)) = queue.pop_front() {
if depth >= max_depth {
continue;
}
if let Some(callers) = graph.reverse.get(current.as_str()) {
for caller in callers {
if !ancestors.contains_key(caller.as_ref()) {
ancestors.insert(caller.to_string(), (depth + 1, current.clone()));
queue.push_back((caller.to_string(), depth + 1));
}
}
}
}
let chain_limit = max_depth + 1;
let mut matches: Vec<TestMatch> = Vec::new();
for test in test_chunks {
if let Some((depth, _)) = ancestors.get(&test.name) {
if *depth > 0 {
let mut chain = Vec::new();
let mut current = test.name.clone();
while !current.is_empty() && chain.len() < chain_limit {
chain.push(current.clone());
if current == target_name {
break;
}
current = match ancestors.get(¤t) {
Some((_, p)) if !p.is_empty() => p.clone(),
_ => {
tracing::debug!(node = %current, "Chain walk hit dead end");
break;
}
};
}
matches.push(TestMatch {
name: test.name.clone(),
file: rel_display(test),
line: test.line_start,
depth: *depth,
chain,
});
}
}
}
matches.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.name.cmp(&b.name)));
matches
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{ChunkType, Language};
use std::path::PathBuf;
fn make_test_chunk(name: &str) -> ChunkSummary {
ChunkSummary {
id: name.to_string(),
file: PathBuf::from(format!("tests/{name}.rs")),
language: Language::Rust,
chunk_type: ChunkType::Function,
name: name.to_string(),
signature: format!("fn {name}()"),
content: String::new(),
doc: None,
line_start: 1,
line_end: 10,
content_hash: String::new(),
window_idx: None,
parent_id: None,
parent_type_name: None,
}
}
#[test]
fn test_find_test_matches_simple_chain() {
let mut reverse = HashMap::new();
reverse.insert("target".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["test_foo".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let test_chunks = vec![make_test_chunk("test_foo")];
let matches = find_test_matches(&graph, &test_chunks, "target", 5, |c| {
c.file.display().to_string()
});
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].name, "test_foo");
assert_eq!(matches[0].depth, 2);
assert_eq!(matches[0].chain, vec!["test_foo", "mid", "target"]);
}
#[test]
fn test_find_test_matches_depth_limit() {
let mut reverse = HashMap::new();
reverse.insert("target".to_string(), vec!["mid".to_string()]);
reverse.insert("mid".to_string(), vec!["test_foo".to_string()]);
let graph = CallGraph::from_string_maps(HashMap::new(), reverse);
let test_chunks = vec![make_test_chunk("test_foo")];
let matches = find_test_matches(&graph, &test_chunks, "target", 1, |c| {
c.file.display().to_string()
});
assert!(
matches.is_empty(),
"test_foo at depth 2 exceeds max_depth 1"
);
}
#[test]
fn test_find_test_matches_no_tests() {
let graph = CallGraph::from_string_maps(HashMap::new(), HashMap::new());
let matches = find_test_matches(&graph, &[], "target", 5, |c| c.file.display().to_string());
assert!(matches.is_empty());
}
}