use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
pub struct TestCouplingDetector {
min_confidence: f64,
}
impl TestCouplingDetector {
pub fn new() -> Self {
Self {
min_confidence: 0.5,
}
}
pub fn with_min_confidence(mut self, min: f64) -> Self {
self.min_confidence = min;
self
}
pub fn detect_couplings(&self, files: &[impl AsRef<Path>]) -> Vec<(Arc<str>, Arc<str>, f64)> {
let file_set: HashMap<String, &Path> = files
.iter()
.map(|f| {
let p = f.as_ref();
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
(name, p)
})
.collect();
let stem_map: HashMap<String, Vec<&Path>> = files
.iter()
.map(|f| f.as_ref())
.filter_map(|p| {
let stem = p.file_stem()?.to_string_lossy().to_string();
Some((stem, p))
})
.fold(HashMap::new(), |mut map, (stem, path)| {
map.entry(stem).or_default().push(path);
map
});
let mut couplings = Vec::new();
for file in files {
let path = file.as_ref();
let path_str = path.to_string_lossy();
if let Some((source_stem, confidence_base)) = self.is_test_file(path) {
if let Some(source_candidates) = stem_map.get(&source_stem) {
for source_path in source_candidates {
if self.is_test_file(source_path).is_some() {
continue;
}
let confidence =
self.calculate_confidence(path, source_path, confidence_base);
if confidence >= self.min_confidence {
couplings.push((
Arc::from(path_str.as_ref()),
Arc::from(source_path.to_string_lossy().as_ref()),
confidence,
));
}
}
}
}
}
couplings
}
fn is_test_file(&self, path: &Path) -> Option<(String, f64)> {
let file_name = path.file_name()?.to_string_lossy();
let stem = path.file_stem()?.to_string_lossy();
let path_str = path.to_string_lossy();
if file_name.ends_with(".rs") {
if stem.starts_with("test_") {
return Some((stem[5..].to_string(), 0.9));
}
if stem.ends_with("_test") {
return Some((stem[..stem.len() - 5].to_string(), 0.9));
}
if path_str.contains("/tests/") || path_str.contains("\\tests\\") {
return Some((stem.to_string(), 0.7));
}
}
if file_name.ends_with(".py") {
if stem.starts_with("test_") {
return Some((stem[5..].to_string(), 0.9));
}
if stem.ends_with("_test") {
return Some((stem[..stem.len() - 5].to_string(), 0.9));
}
}
if file_name.ends_with(".ts")
|| file_name.ends_with(".tsx")
|| file_name.ends_with(".js")
|| file_name.ends_with(".jsx")
{
if stem.ends_with(".spec") {
return Some((stem[..stem.len() - 5].to_string(), 0.9));
}
if stem.ends_with(".test") {
return Some((stem[..stem.len() - 5].to_string(), 0.9));
}
if path_str.contains("/__tests__/") || path_str.contains("\\__tests__\\") {
return Some((stem.to_string(), 0.8));
}
}
if file_name.ends_with(".go") && stem.ends_with("_test") {
return Some((stem[..stem.len() - 5].to_string(), 0.9));
}
None
}
fn calculate_confidence(&self, test_path: &Path, source_path: &Path, base: f64) -> f64 {
let test_parent = test_path.parent().map(|p| p.to_string_lossy().to_string());
let source_parent = source_path
.parent()
.map(|p| p.to_string_lossy().to_string());
match (test_parent, source_parent) {
(Some(tp), Some(sp)) => {
if tp == sp {
return (base + 0.1).min(1.0);
}
if (tp.contains("/tests") || tp.contains("\\tests"))
&& (sp.contains("/src") || sp.contains("\\src"))
{
return (base + 0.05).min(1.0);
}
let tp_parts: Vec<_> = tp.split(['/', '\\']).collect();
let sp_parts: Vec<_> = sp.split(['/', '\\']).collect();
if tp_parts.len() > 1 && sp_parts.len() > 1 {
if tp_parts[..tp_parts.len() - 1] == sp_parts[..sp_parts.len() - 1] {
return (base + 0.05).min(1.0);
}
}
base
}
_ => base,
}
}
pub fn as_symbol_edges(
&self,
couplings: &[(Arc<str>, Arc<str>, f64)],
) -> Vec<(Arc<str>, Arc<str>, Arc<str>, Arc<str>)> {
let file_symbol: Arc<str> = Arc::from("__file__");
couplings
.iter()
.flat_map(|(test, source, _conf)| {
vec![
(
test.clone(),
file_symbol.clone(),
source.clone(),
file_symbol.clone(),
),
(
source.clone(),
file_symbol.clone(),
test.clone(),
file_symbol.clone(),
),
]
})
.collect()
}
}
impl Default for TestCouplingDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_detect_rust_test_files() {
let detector = TestCouplingDetector::new();
let files: Vec<PathBuf> = vec![
"src/parser.rs".into(),
"tests/test_parser.rs".into(),
"src/lexer.rs".into(),
"src/lexer_test.rs".into(),
];
let couplings = detector.detect_couplings(&files);
assert_eq!(couplings.len(), 2);
let test_files: Vec<_> = couplings.iter().map(|(t, _, _)| t.as_ref()).collect();
assert!(test_files.contains(&"tests/test_parser.rs"));
assert!(test_files.contains(&"src/lexer_test.rs"));
}
#[test]
fn test_detect_python_test_files() {
let detector = TestCouplingDetector::new();
let files: Vec<PathBuf> = vec![
"mymodule.py".into(),
"test_mymodule.py".into(),
"utils.py".into(),
"utils_test.py".into(),
];
let couplings = detector.detect_couplings(&files);
assert_eq!(couplings.len(), 2);
}
#[test]
fn test_detect_js_spec_files() {
let detector = TestCouplingDetector::new();
let files: Vec<PathBuf> = vec![
"Button.tsx".into(),
"Button.spec.tsx".into(),
"utils.ts".into(),
"utils.test.ts".into(),
];
let couplings = detector.detect_couplings(&files);
assert_eq!(couplings.len(), 2);
}
#[test]
fn test_no_self_coupling() {
let detector = TestCouplingDetector::new();
let files: Vec<PathBuf> = vec!["test_foo.py".into(), "test_bar.py".into()];
let couplings = detector.detect_couplings(&files);
assert!(couplings.is_empty());
}
#[test]
fn test_confidence_same_directory() {
let detector = TestCouplingDetector::new();
let files: Vec<PathBuf> = vec!["src/foo.rs".into(), "src/foo_test.rs".into()];
let couplings = detector.detect_couplings(&files);
assert_eq!(couplings.len(), 1);
assert!(couplings[0].2 > 0.9);
}
#[test]
fn test_as_symbol_edges() {
let detector = TestCouplingDetector::new();
let couplings = vec![(Arc::from("test_foo.py"), Arc::from("foo.py"), 0.9)];
let edges = detector.as_symbol_edges(&couplings);
assert_eq!(edges.len(), 2);
}
}