use std::collections::BTreeSet;
use std::path::Path;
const BORING_SEGMENTS: &[&str] = &[
"src",
"lib",
"app",
"pkg",
"tests",
"test",
"spec",
"specs",
"integration",
"e2e",
"api",
"apis",
"services",
"service",
"handlers",
"handler",
"controllers",
"controller",
"routes",
"route",
"common",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathMatch {
Direct,
SharedTopic { segment: String },
}
pub fn match_paths(changed: &str, test_file: &str) -> Option<PathMatch> {
if paths_equal(changed, test_file) {
return Some(PathMatch::Direct);
}
let changed_segments = topic_segments(changed);
let test_segments = topic_segments(test_file);
for seg in &changed_segments {
if test_segments.contains(seg) {
return Some(PathMatch::SharedTopic {
segment: seg.clone(),
});
}
}
None
}
fn paths_equal(a: &str, b: &str) -> bool {
if a == b {
return true;
}
normalize_lexical(a) == normalize_lexical(b)
}
fn normalize_lexical(p: &str) -> String {
let unified = p.replace('\\', "/");
let trimmed = unified.trim_start_matches("./");
let normalized: String =
trimmed
.chars()
.fold(String::with_capacity(trimmed.len()), |mut acc, ch| {
if ch == '/' && acc.ends_with('/') {
return acc;
}
acc.push(ch);
acc
});
normalized
}
fn topic_segments(path: &str) -> BTreeSet<String> {
let p = Path::new(path);
let mut out = BTreeSet::new();
for component in p.components() {
let raw = component.as_os_str().to_string_lossy();
let stem = raw.trim_end_matches(".tarn.yaml");
let without_ext = match stem.rfind('.') {
Some(i) => &stem[..i],
None => stem,
};
let lower = without_ext.to_ascii_lowercase();
if lower.is_empty() {
continue;
}
if BORING_SEGMENTS.iter().any(|b| *b == lower) {
continue;
}
out.insert(lower);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direct_match_on_identical_paths() {
assert_eq!(
match_paths("tests/users.tarn.yaml", "tests/users.tarn.yaml"),
Some(PathMatch::Direct)
);
}
#[test]
fn direct_match_ignores_leading_dot_slash() {
assert_eq!(
match_paths("./tests/users.tarn.yaml", "tests/users.tarn.yaml"),
Some(PathMatch::Direct)
);
}
#[test]
fn shared_topic_segment_links_source_and_test() {
let m = match_paths("src/users/service.ts", "tests/users/crud.tarn.yaml").unwrap();
assert_eq!(
m,
PathMatch::SharedTopic {
segment: "users".into()
}
);
}
#[test]
fn boring_segments_do_not_create_false_positives() {
assert_eq!(match_paths("src/utils.ts", "tests/smoke.tarn.yaml"), None);
}
#[test]
fn filename_stem_counts_as_a_segment() {
let m = match_paths("src/users.ts", "tests/users/flow.tarn.yaml").unwrap();
assert_eq!(
m,
PathMatch::SharedTopic {
segment: "users".into()
}
);
}
#[test]
fn unrelated_paths_do_not_match() {
assert_eq!(
match_paths("src/orders/checkout.ts", "tests/users.tarn.yaml"),
None
);
}
#[test]
fn direct_match_normalizes_windows_separators() {
assert_eq!(
match_paths("tests/users.tarn.yaml", r"tests\users.tarn.yaml"),
Some(PathMatch::Direct)
);
assert_eq!(
match_paths(r"tests\users.tarn.yaml", "tests/users.tarn.yaml"),
Some(PathMatch::Direct)
);
}
}