use super::super::rust_index::{
FunctionSummary, RustIndex, TestSummary, extract_identifier_tokens,
};
use crate::domain::Probe;
use std::path::Path;
pub(in crate::analysis) fn find_related_tests<'a>(
probe: &Probe,
owner_fn: Option<&FunctionSummary>,
index: &'a RustIndex,
) -> Vec<&'a TestSummary> {
let mut related = Vec::new();
let owner_name = owner_fn.map(|f| f.name.as_str()).unwrap_or("");
let probe_tokens = extract_identifier_tokens(&probe.expression);
let file_name = probe
.location
.file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let package_prefix = owner_fn.and_then(|owner| package_prefix(&owner.file));
for test in &index.tests {
if let Some(prefix) = &package_prefix
&& !normalize_path(&test.file).starts_with(prefix)
{
continue;
}
let calls_owner = !owner_name.is_empty()
&& (test.calls.iter().any(|call| call.name == owner_name)
|| test.body.contains(owner_name));
let test_name = test.name.to_ascii_lowercase();
let owner_name = owner_name.to_ascii_lowercase();
let same_file_or_named = normalize_path(&test.file).contains(file_name)
|| (!owner_name.is_empty() && test_name.contains(&owner_name))
|| probe_tokens
.iter()
.any(|token| token.len() > 2 && test_name.contains(&token.to_ascii_lowercase()));
if calls_owner || same_file_or_named {
related.push(test);
}
}
related.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.file.cmp(&b.file)));
related.dedup_by(|a, b| a.name == b.name && a.file == b.file);
related
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.trim_start_matches("./")
.to_string()
}
fn package_prefix(path: &Path) -> Option<String> {
let normalized = normalize_path(path);
if let Some(rest) = normalized.strip_prefix("crates/")
&& let Some((crate_name, crate_relative)) = rest.split_once('/')
&& (crate_relative.starts_with("src/") || crate_relative.starts_with("tests/"))
{
return Some(format!("crates/{crate_name}/"));
}
for marker in ["/src/", "/tests/"] {
if let Some(idx) = normalized.rfind(marker) {
let prefix = &normalized[..idx];
if prefix.is_empty() {
return None;
}
return Some(format!("{prefix}/"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis::rust_index::CallFact;
use crate::domain::{DeltaKind, ProbeFamily, ProbeId, SourceLocation, SymbolId};
use std::path::PathBuf;
#[test]
fn given_owner_function_when_tests_share_name_across_packages_then_filters_to_package() {
let owner = function("crates/crate_a/src/lib.rs", "score");
let index = RustIndex {
tests: vec![
test(
"crates/crate_b/tests/score.rs",
"crate_b_score_test",
"score(2)",
),
test(
"crates/crate_a/tests/score.rs",
"crate_a_score_test",
"score(1)",
),
],
..RustIndex::default()
};
let probe = probe("crates/crate_a/src/lib.rs", "score + 1");
let related = find_related_tests(&probe, Some(&owner), &index);
assert_eq!(related.len(), 1);
assert_eq!(related[0].name, "crate_a_score_test");
}
#[test]
fn given_same_named_tests_when_finding_related_then_orders_by_file_path() {
let owner = function("src/lib.rs", "score");
let index = RustIndex {
tests: vec![
test("tests/z_case.rs", "score_shared", "score(3)"),
test("tests/a_case.rs", "score_shared", "score(1)"),
],
..RustIndex::default()
};
let probe = probe("src/lib.rs", "score + 1");
let related = find_related_tests(&probe, Some(&owner), &index);
assert_eq!(related.len(), 2);
assert_eq!(related[0].file, PathBuf::from("tests/a_case.rs"));
assert_eq!(related[1].file, PathBuf::from("tests/z_case.rs"));
}
#[test]
fn given_probe_token_in_test_name_when_owner_is_not_called_then_test_is_related() {
let owner = function("src/lib.rs", "tax_total");
let index = RustIndex {
tests: vec![test(
"tests/tax.rs",
"vat_boundary_is_checked_by_macro",
"assert_eq!(macro_tax_case!(100), 120);",
)],
..RustIndex::default()
};
let probe = probe("src/lib.rs", "vat >= threshold");
let related = find_related_tests(&probe, Some(&owner), &index);
assert_eq!(related.len(), 1);
assert_eq!(related[0].name, "vat_boundary_is_checked_by_macro");
}
#[test]
fn given_workspace_paths_when_extracting_package_prefix_then_handles_nested_markers() {
assert_eq!(
package_prefix(Path::new("crates/foo/src/support/src/lib.rs")).as_deref(),
Some("crates/foo/")
);
assert_eq!(
package_prefix(Path::new("crates/foo/tests/support/tests/cases.rs")).as_deref(),
Some("crates/foo/")
);
assert_eq!(
package_prefix(Path::new("vendor/foo/src/support/src/lib.rs")).as_deref(),
Some("vendor/foo/src/support/")
);
assert_eq!(
package_prefix(Path::new("crates/ripr/examples/sample/src/lib.rs")).as_deref(),
Some("crates/ripr/examples/sample/")
);
}
#[test]
fn given_non_workspace_paths_when_extracting_package_prefix_then_returns_none() {
assert_eq!(package_prefix(Path::new("src/lib.rs")), None);
assert_eq!(package_prefix(Path::new("tests/basic.rs")), None);
assert_eq!(package_prefix(Path::new("README.md")), None);
}
#[test]
fn given_mixed_separator_path_when_normalizing_then_uses_workspace_relative_form() {
let normalized = normalize_path(Path::new("./crates\\ripr\\src\\lib.rs"));
assert_eq!(normalized, "crates/ripr/src/lib.rs");
}
fn function(file: &str, name: &str) -> FunctionSummary {
FunctionSummary {
id: SymbolId(format!("{file}::{name}")),
name: name.to_string(),
file: PathBuf::from(file),
start_line: 1,
end_line: 3,
body: String::new(),
calls: Vec::new(),
returns: Vec::new(),
literals: Vec::new(),
is_test: false,
attrs: Vec::new(),
}
}
fn test(file: &str, name: &str, body: &str) -> TestSummary {
TestSummary {
name: name.to_string(),
file: PathBuf::from(file),
start_line: 1,
end_line: 4,
body: body.to_string(),
calls: vec![CallFact {
line: 1,
name: "score".to_string(),
text: body.to_string(),
}],
assertions: Vec::new(),
literals: Vec::new(),
attrs: Vec::new(),
}
}
fn probe(file: &str, expression: &str) -> Probe {
Probe {
id: ProbeId("probe:test".to_string()),
location: SourceLocation::new(file, 2, 1),
owner: Some(SymbolId(format!("{file}::owner"))),
family: ProbeFamily::Predicate,
delta: DeltaKind::Control,
before: None,
after: Some(expression.to_string()),
expression: expression.to_string(),
expected_sinks: Vec::new(),
required_oracles: Vec::new(),
}
}
}