1use std::path::{Path, PathBuf};
34
35const TEST_CODE_DIR_NAMES: &[&str] = &["test", "tests", "__tests__", "__test__", "e2e"];
37
38const TEST_ROOT_DIR_NAMES: &[&str] = &["spec", "specs"];
40
41const TEST_SUPPORT_DIR_NAMES: &[&str] = &["__mocks__", "__fixtures__", "fixtures", "__snapshots__"];
43
44const TEST_CODE_FILE_MARKERS: &[&str] = &[".test.", ".spec.", ".e2e.", ".e2e-spec."];
46
47const TEST_SUPPORT_FILE_MARKERS: &[&str] = &[".fixture."];
49
50const CYPRESS_FILE_MARKER: &str = ".cy.";
52
53const CYPRESS_SPEC_EXTENSIONS: &[&str] = &["js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts"];
55
56const CYPRESS_DIR_NAME: &str = "cypress";
58
59const CYPRESS_CONFIG_FILES: &[&str] = &[
61 "cypress.config.ts",
62 "cypress.config.js",
63 "cypress.config.mjs",
64 "cypress.config.cjs",
65 "cypress.config.mts",
66 "cypress.config.cts",
67 "cypress.json",
68];
69
70const PACKAGE_MANIFEST: &str = "package.json";
72
73const SOURCE_DIR_NAMES: &[&str] = &["src", "lib"];
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum TestPathKind {
79 Code,
81 Support,
83}
84
85#[must_use]
90pub fn is_test_path(root: &Path, relative: &Path) -> bool {
91 is_test_path_str(root, &relative.to_string_lossy())
92}
93
94#[must_use]
99pub fn is_test_path_str(root: &Path, relative: &str) -> bool {
100 classify(root, relative).is_some()
101}
102
103#[must_use]
109pub fn is_test_code_path(root: &Path, relative: &Path) -> bool {
110 is_test_code_path_str(root, &relative.to_string_lossy())
111}
112
113#[must_use]
118pub fn is_test_code_path_str(root: &Path, relative: &str) -> bool {
119 classify(root, relative) == Some(TestPathKind::Code)
120}
121
122fn classify(root: &Path, path: &str) -> Option<TestPathKind> {
125 let mut segments: Vec<&str> = path
126 .split(['/', '\\'])
127 .filter(|segment| !segment.is_empty())
128 .collect();
129 let file_name = segments.pop()?;
130 let dirs = segments;
131 let has_marker = |markers: &[&str]| {
132 markers
133 .iter()
134 .any(|marker| contains_ignore_ascii_case(file_name, marker))
135 };
136 if has_marker(TEST_CODE_FILE_MARKERS) || is_cypress_spec(root, &dirs, file_name) {
137 return Some(TestPathKind::Code);
138 }
139 let mut kind = has_marker(TEST_SUPPORT_FILE_MARKERS).then_some(TestPathKind::Support);
140 for (depth, segment) in dirs.iter().enumerate() {
141 if is_one_of(segment, TEST_CODE_DIR_NAMES)
142 || (is_one_of(segment, TEST_ROOT_DIR_NAMES) && is_test_root(root, &dirs[..depth]))
143 {
144 return Some(TestPathKind::Code);
145 }
146 if is_one_of(segment, TEST_SUPPORT_DIR_NAMES) {
147 kind = Some(TestPathKind::Support);
148 }
149 }
150 kind
151}
152
153fn is_cypress_spec(root: &Path, dirs: &[&str], file_name: &str) -> bool {
157 if !contains_ignore_ascii_case(file_name, CYPRESS_FILE_MARKER) {
158 return false;
159 }
160 let is_script = Path::new(file_name)
161 .extension()
162 .and_then(|extension| extension.to_str())
163 .is_some_and(|extension| is_one_of(extension, CYPRESS_SPEC_EXTENSIONS));
164 if !is_script {
165 return false;
166 }
167 if dirs
168 .iter()
169 .any(|segment| segment.eq_ignore_ascii_case(CYPRESS_DIR_NAME))
170 {
171 return true;
172 }
173 (0..=dirs.len()).any(|depth| holds_cypress_setup(&join_below(root, &dirs[..depth])))
174}
175
176fn holds_cypress_setup(dir: &Path) -> bool {
178 dir.join(CYPRESS_DIR_NAME).is_dir()
179 || CYPRESS_CONFIG_FILES
180 .iter()
181 .any(|name| dir.join(name).is_file())
182}
183
184fn is_test_root(root: &Path, dirs: &[&str]) -> bool {
187 if dirs.is_empty() {
188 return true;
189 }
190 let dir = join_below(root, dirs);
191 dir.join(PACKAGE_MANIFEST).is_file()
192 || SOURCE_DIR_NAMES.iter().any(|name| dir.join(name).is_dir())
193}
194
195fn join_below(root: &Path, dirs: &[&str]) -> PathBuf {
197 let mut dir = root.to_path_buf();
198 dir.extend(dirs);
199 dir
200}
201
202fn is_one_of(segment: &str, names: &[&str]) -> bool {
204 names.iter().any(|name| name.eq_ignore_ascii_case(segment))
205}
206
207fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
209 if needle.is_empty() {
210 return true;
211 }
212 haystack
213 .as_bytes()
214 .windows(needle.len())
215 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 const NO_ROOT: &str = "/fallow-test-paths-missing-root";
224
225 fn root() -> &'static Path {
226 Path::new(NO_ROOT)
227 }
228
229 #[test]
230 fn path_verdicts_over_shared_corpus() {
231 let corpus = include_str!(concat!(
232 env!("CARGO_MANIFEST_DIR"),
233 "/tests/fixtures/test-path-corpus.txt"
234 ));
235 let rendered = corpus
236 .lines()
237 .filter(|line| !line.is_empty() && !line.starts_with('#'))
238 .map(|path| {
239 let path = Path::new(path);
240 let verdict = match (is_test_code_path(root(), path), is_test_path(root(), path)) {
241 (true, _) => "code",
242 (false, true) => "supp",
243 (false, false) => "- ",
244 };
245 format!("{verdict} {}", path.display())
246 })
247 .collect::<Vec<_>>()
248 .join("\n");
249 insta::assert_snapshot!(rendered);
250 }
251
252 #[test]
253 fn path_and_string_forms_agree() {
254 for path in [
255 "src/app.test.ts",
256 "tests/unit/widget.ts",
257 "src/__mocks__/api.ts",
258 "src/app.ts",
259 ] {
260 assert_eq!(
261 is_test_path(root(), Path::new(path)),
262 is_test_path_str(root(), path)
263 );
264 assert_eq!(
265 is_test_code_path(root(), Path::new(path)),
266 is_test_code_path_str(root(), path)
267 );
268 }
269 }
270
271 #[test]
272 fn test_support_is_a_test_path_but_not_test_code() {
273 for path in [
274 "src/__mocks__/api.ts",
275 "src/__fixtures__/user.ts",
276 "fixtures/user.ts",
277 "src/__snapshots__/api.ts.snap",
278 "src/user.fixture.ts",
279 ] {
280 assert!(is_test_path_str(root(), path), "{path} is a test path");
281 assert!(
282 !is_test_code_path_str(root(), path),
283 "{path} is not test code"
284 );
285 }
286 }
287
288 #[test]
289 fn test_code_wins_over_test_support() {
290 assert!(is_test_code_path_str(root(), "test/fixtures/user.ts"));
291 assert!(is_test_code_path_str(
292 root(),
293 "src/__fixtures__/user.test.ts"
294 ));
295 assert!(is_test_code_path_str(root(), "src/__mocks__/tests/api.ts"));
296 }
297
298 #[test]
299 fn backslash_separates_segments_on_every_platform() {
300 assert!(is_test_path_str(root(), r"src\__tests__\widget.ts"));
301 assert!(is_test_path_str(root(), r"packages\core\tests\widget.ts"));
302 assert!(!is_test_path_str(root(), r"src\components\widget.ts"));
303 }
304
305 #[test]
306 fn marker_in_a_directory_name_does_not_match() {
307 assert!(!is_test_path_str(root(), "src/foo.test.d/widget.ts"));
308 assert!(is_test_path_str(root(), "src/foo.test.d/widget.test.ts"));
309 }
310
311 #[test]
312 fn empty_and_root_only_paths_are_not_tests() {
313 assert!(!is_test_path_str(root(), ""));
314 assert!(!is_test_path_str(root(), "/"));
315 assert!(!is_test_path_str(root(), "tests/"));
316 }
317
318 #[test]
319 fn welsh_locale_file_is_not_test_code() {
320 assert!(!is_test_path_str(root(), "src/i18n/strings.cy.ts"));
321 }
322
323 #[test]
324 fn spec_directory_below_source_is_not_test_code() {
325 assert!(!is_test_path_str(root(), "src/spec/schema.ts"));
326 }
327
328 fn touch(root: &Path, relative: &str) {
331 let path = root.join(relative);
332 if relative.ends_with('/') {
333 std::fs::create_dir_all(path).unwrap();
334 return;
335 }
336 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
337 std::fs::write(path, "").unwrap();
338 }
339
340 #[test]
341 fn cypress_spec_needs_cypress_evidence() {
342 assert!(is_test_code_path_str(root(), "cypress/e2e/login.cy.ts"));
343 assert!(!is_test_path_str(root(), "src/components/Button.cy.tsx"));
344
345 let dir = tempfile::tempdir().unwrap();
346 touch(dir.path(), "cypress.config.ts");
347 assert!(is_test_code_path_str(
348 dir.path(),
349 "src/components/Button.cy.tsx"
350 ));
351 assert!(!is_test_path_str(dir.path(), "src/i18n/strings.cy.json"));
352
353 let dir = tempfile::tempdir().unwrap();
354 touch(dir.path(), "packages/web/cypress/");
355 assert!(is_test_code_path_str(
356 dir.path(),
357 "packages/web/src/Button.cy.jsx"
358 ));
359 assert!(!is_test_path_str(
360 dir.path(),
361 "packages/api/src/strings.cy.ts"
362 ));
363 }
364
365 #[test]
366 fn spec_directory_counts_only_at_a_test_root() {
367 let dir = tempfile::tempdir().unwrap();
368 touch(dir.path(), "packages/core/package.json");
369 assert!(is_test_code_path_str(dir.path(), "spec/widget.ts"));
370 assert!(is_test_code_path_str(dir.path(), "specs/widget.ts"));
371 assert!(is_test_code_path_str(
372 dir.path(),
373 "packages/core/spec/widget.ts"
374 ));
375 assert!(!is_test_path_str(
376 dir.path(),
377 "packages/core/src/spec/schema.ts"
378 ));
379 assert!(!is_test_path_str(
380 dir.path(),
381 "packages/other/spec/schema.ts"
382 ));
383
384 touch(dir.path(), "examples/basic/src/");
385 assert!(is_test_code_path_str(
386 dir.path(),
387 "examples/basic/spec/widget.ts"
388 ));
389 }
390
391 #[test]
392 fn substring_search_ignores_ascii_case() {
393 assert!(contains_ignore_ascii_case("App.TEST.ts", ".test."));
394 assert!(!contains_ignore_ascii_case("ab", "abc"));
395 assert!(contains_ignore_ascii_case("anything", ""));
396 }
397}