Skip to main content

sem_core/parser/
test_detect.rs

1//! Shared test-path detection logic.
2//!
3//! Single source of truth for deciding whether a file path belongs to a
4//! test/fixture/benchmark directory. Used by `graph.rs` for test-entity
5//! filtering.
6
7/// Token-level matches: directory name is split on `-`, `_`, `.` and
8/// any resulting token that equals one of these triggers a match.
9const TEST_TOKENS: &[&str] = &["test", "tests", "spec", "specs"];
10
11/// Exact directory-name matches for well-known dirs that don't contain
12/// "test" or "spec" as a token.
13const EXACT_DIR_NAMES: &[&str] = &[
14    "e2e",
15    "cypress",
16    "playwright",
17    "testing",
18    "fixtures",
19    "fixture",
20    "benchmarks",
21    "benchmark",
22    "__tests__",
23    "__mocks__",
24];
25
26/// File-name patterns that indicate a test file regardless of directory.
27const TEST_FILE_PATTERNS: &[&str] = &["_test.", ".test.", "_spec.", ".spec."];
28
29/// Returns `true` if any path component (directory or file stem) matches
30/// built-in test heuristics.
31pub fn is_test_path(path: &str) -> bool {
32    is_test_path_with_custom_dirs(path, &[])
33}
34
35/// Like [`is_test_path`], but also matches if any path component equals one
36/// of the caller-supplied custom directory names.
37pub fn is_test_path_with_custom_dirs(path: &str, custom_dirs: &[String]) -> bool {
38    let path_lower = path.to_lowercase();
39
40    // File-name patterns (e.g. `foo_test.rs`, `bar.spec.ts`)
41    if let Some(file_name) = path_lower.rsplit('/').next() {
42        for pat in TEST_FILE_PATTERNS {
43            if file_name.contains(pat) {
44                return true;
45            }
46        }
47    }
48
49    // Check each path component (directory names)
50    for component in path_lower.split('/') {
51        if component.is_empty() {
52            continue;
53        }
54
55        // Exact well-known directory names
56        if EXACT_DIR_NAMES.contains(&component) {
57            return true;
58        }
59
60        // Custom directory names from .semrc
61        if custom_dirs
62            .iter()
63            .any(|d| d.eq_ignore_ascii_case(component))
64        {
65            return true;
66        }
67
68        // Token-based matching: split on `-`, `_`, `.` and check tokens
69        let has_test_token = component
70            .split(|c: char| c == '-' || c == '_' || c == '.')
71            .any(|token| TEST_TOKENS.contains(&token));
72        if has_test_token {
73            return true;
74        }
75    }
76
77    false
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    // ── Built-in directory patterns ──────────────────────────────────────
85
86    #[test]
87    fn classic_test_dirs() {
88        assert!(is_test_path("src/test/foo.ts"));
89        assert!(is_test_path("src/tests/foo.ts"));
90        assert!(is_test_path("src/spec/foo.ts"));
91        assert!(is_test_path("src/specs/foo.ts"));
92    }
93
94    #[test]
95    fn hyphenated_test_dirs() {
96        assert!(is_test_path("e2e-tests/foo.ts"));
97        assert!(is_test_path("integration-test/bar.py"));
98        assert!(is_test_path("unit-tests/baz.js"));
99    }
100
101    #[test]
102    fn underscored_test_dirs() {
103        assert!(is_test_path("__tests__/baz.js"));
104        assert!(is_test_path("unit_tests/foo.rs"));
105        assert!(is_test_path("integration_test/bar.go"));
106    }
107
108    #[test]
109    fn dotted_test_dirs() {
110        assert!(is_test_path("src/test.unit/foo.ts"));
111    }
112
113    #[test]
114    fn well_known_exact_dirs() {
115        assert!(is_test_path("e2e/login.spec.ts"));
116        assert!(is_test_path("cypress/e2e/login.spec.ts"));
117        assert!(is_test_path("playwright/tests/foo.ts"));
118        assert!(is_test_path("testing/helpers.py"));
119        assert!(is_test_path("fixtures/data.json"));
120        assert!(is_test_path("fixture/sample.txt"));
121        assert!(is_test_path("benchmarks/bench_main.rs"));
122        assert!(is_test_path("benchmark/perf.go"));
123        assert!(is_test_path("__mocks__/api.ts"));
124    }
125
126    // ── File-name patterns ───────────────────────────────────────────────
127
128    #[test]
129    fn test_file_name_patterns() {
130        assert!(is_test_path("src/utils_test.go"));
131        assert!(is_test_path("src/utils.test.ts"));
132        assert!(is_test_path("src/utils_spec.rb"));
133        assert!(is_test_path("src/utils.spec.js"));
134    }
135
136    // ── Negative cases ───────────────────────────────────────────────────
137
138    #[test]
139    fn no_false_positives() {
140        assert!(!is_test_path("src/main.rs"));
141        assert!(!is_test_path("src/contest/solution.py"));
142        assert!(!is_test_path("src/spectacle/viewer.ts"));
143        assert!(!is_test_path("src/attestation/verify.go"));
144        assert!(!is_test_path("src/latest/handler.js"));
145        assert!(!is_test_path("src/protest/rally.rb"));
146        assert!(!is_test_path("lib/fastest/core.ts"));
147    }
148
149    // ── Custom directories ───────────────────────────────────────────────
150
151    #[test]
152    fn custom_dir_match() {
153        let custom = vec!["qa".to_string(), "smoke".to_string()];
154        assert!(is_test_path_with_custom_dirs("qa/check.ts", &custom));
155        assert!(is_test_path_with_custom_dirs("smoke/login.py", &custom));
156    }
157
158    #[test]
159    fn custom_dir_case_insensitive() {
160        let custom = vec!["QA".to_string()];
161        assert!(is_test_path_with_custom_dirs("qa/check.ts", &custom));
162        assert!(is_test_path_with_custom_dirs("Qa/check.ts", &custom));
163    }
164
165    #[test]
166    fn custom_dir_no_false_positive() {
167        let custom = vec!["qa".to_string()];
168        assert!(!is_test_path_with_custom_dirs("src/main.rs", &custom));
169    }
170
171    #[test]
172    fn builtin_still_works_with_custom_dirs() {
173        let custom = vec!["qa".to_string()];
174        assert!(is_test_path_with_custom_dirs("src/tests/foo.ts", &custom));
175        assert!(is_test_path_with_custom_dirs("e2e-tests/bar.py", &custom));
176    }
177}