Skip to main content

fallow_engine/
test_paths.rs

1//! Shared classification of test paths.
2//!
3//! Two sets of paths exist:
4//!
5//! - Test code holds the tests: specs, test directories and end-to-end suites.
6//! - Test support helps the tests: mocks, fixtures and snapshots.
7//!
8//! [`is_test_code_path`] matches test code only. Use it when the question is
9//! "does a test exist" or "did a test change": audit test adjacency, audit
10//! test-weakening signals and similar-code related tests.
11//!
12//! [`is_test_path`] matches test code and test support. Use it when the
13//! question is "is this production code": health hotspots, the human check
14//! split, orientation and the audit branching split.
15//!
16//! The match is mostly syntactic and ASCII case-insensitive. A directory
17//! segment matches by its full name, and a file-name marker matches only in
18//! the last segment. Both `/` and `\` separate segments, so the verdict does
19//! not depend on the platform.
20//!
21//! Two rules also read the file system below the project root, because the
22//! path alone is not sufficient:
23//!
24//! - A `.cy.` file is a Cypress spec only when it is a script file below a
25//!   `cypress` directory, or below a directory that holds a Cypress config or
26//!   a `cypress` directory. `.cy.` is also the Welsh language code, so
27//!   `src/i18n/strings.cy.ts` in a project without Cypress is not a test.
28//! - A `spec` or `specs` directory holds tests only at a test root: the project
29//!   root, a package root (a directory with a `package.json`), or a directory
30//!   with a `src` or `lib` directory next to the `spec` directory. A
31//!   `src/spec/` module is production code.
32
33use std::path::{Path, PathBuf};
34
35/// Directory names that hold test code at every depth.
36const TEST_CODE_DIR_NAMES: &[&str] = &["test", "tests", "__tests__", "__test__", "e2e", "test-d"];
37
38/// Directory names that hold test code only at a test root.
39const TEST_ROOT_DIR_NAMES: &[&str] = &["spec", "specs"];
40
41/// Directory names that hold test support: mocks, fixtures and snapshots.
42const TEST_SUPPORT_DIR_NAMES: &[&str] = &["__mocks__", "__fixtures__", "fixtures", "__snapshots__"];
43
44/// File-name markers of test code (`app.test.ts`, `app.spec.ts`).
45const TEST_CODE_FILE_MARKERS: &[&str] = &[".test.", ".test-d.", ".spec.", ".e2e.", ".e2e-spec."];
46
47/// File-name markers of test support (`user.fixture.ts`).
48const TEST_SUPPORT_FILE_MARKERS: &[&str] = &[".fixture."];
49
50/// File-name marker of a Cypress spec (`login.cy.ts`).
51const CYPRESS_FILE_MARKER: &str = ".cy.";
52
53/// Script extensions that a Cypress spec can have.
54const CYPRESS_SPEC_EXTENSIONS: &[&str] = &["js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts"];
55
56/// The directory name of a Cypress suite.
57const CYPRESS_DIR_NAME: &str = "cypress";
58
59/// File names of a Cypress config.
60const 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
70/// The manifest file that marks a package root.
71const PACKAGE_MANIFEST: &str = "package.json";
72
73/// Source directory names. A `spec` directory next to one of them is a test root.
74const SOURCE_DIR_NAMES: &[&str] = &["src", "lib"];
75
76/// The role of a test path.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum TestPathKind {
79    /// The path holds tests.
80    Code,
81    /// The path helps tests: a mock, a fixture or a snapshot.
82    Support,
83}
84
85/// Whether a project-relative path is test code or test support.
86///
87/// `root` is the project root. Pass `relative` relative to `root`. An
88/// absolute path also matches on the directories above the project root.
89#[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/// Whether a project-relative path string is test code or test support.
95///
96/// The string form of [`is_test_path`] for callers that hold
97/// forward-slash path strings.
98#[must_use]
99pub fn is_test_path_str(root: &Path, relative: &str) -> bool {
100    classify(root, relative).is_some()
101}
102
103/// Whether a project-relative path is test code. Test support (mocks,
104/// fixtures and snapshots) does not match, except below a test directory.
105///
106/// `root` is the project root. Pass `relative` relative to `root`. An
107/// absolute path also matches on the directories above the project root.
108#[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/// Whether a project-relative path string is test code.
114///
115/// The string form of [`is_test_code_path`] for callers that hold
116/// forward-slash path strings.
117#[must_use]
118pub fn is_test_code_path_str(root: &Path, relative: &str) -> bool {
119    classify(root, relative) == Some(TestPathKind::Code)
120}
121
122/// The role of a path, or `None` for a path that is not a test path. A
123/// test-code match wins over a test-support match.
124fn 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
153/// Whether `file_name` below `dirs` is a Cypress spec: a script file with the
154/// `.cy.` marker below a `cypress` directory, or below a directory that holds
155/// a Cypress config or a `cypress` directory.
156fn 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
176/// Whether `dir` holds a Cypress config or a `cypress` directory.
177fn 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
184/// Whether the directory `dirs` below `root` is a test root: the project
185/// root, a package root, or a directory that holds a source directory.
186fn 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
195/// The directory `dirs` below `root`.
196fn join_below(root: &Path, dirs: &[&str]) -> PathBuf {
197    let mut dir = root.to_path_buf();
198    dir.extend(dirs);
199    dir
200}
201
202/// Whether a directory segment equals one of `names`, ignoring ASCII case.
203fn is_one_of(segment: &str, names: &[&str]) -> bool {
204    names.iter().any(|name| name.eq_ignore_ascii_case(segment))
205}
206
207/// ASCII case-insensitive substring search without an allocation.
208fn 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    /// A project root that does not exist, so no rule finds file-system evidence.
223    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!("../tests/fixtures/test-path-corpus.txt");
232        let rendered = corpus
233            .lines()
234            .filter(|line| !line.is_empty() && !line.starts_with('#'))
235            .map(|path| {
236                let path = Path::new(path);
237                let verdict = match (is_test_code_path(root(), path), is_test_path(root(), path)) {
238                    (true, _) => "code",
239                    (false, true) => "supp",
240                    (false, false) => "-   ",
241                };
242                format!("{verdict} {}", path.display())
243            })
244            .collect::<Vec<_>>()
245            .join("\n");
246        insta::assert_snapshot!(rendered);
247    }
248
249    #[test]
250    fn path_and_string_forms_agree() {
251        for path in [
252            "src/app.test.ts",
253            "tests/unit/widget.ts",
254            "src/__mocks__/api.ts",
255            "src/app.ts",
256        ] {
257            assert_eq!(
258                is_test_path(root(), Path::new(path)),
259                is_test_path_str(root(), path)
260            );
261            assert_eq!(
262                is_test_code_path(root(), Path::new(path)),
263                is_test_code_path_str(root(), path)
264            );
265        }
266    }
267
268    #[test]
269    fn test_support_is_a_test_path_but_not_test_code() {
270        for path in [
271            "src/__mocks__/api.ts",
272            "src/__fixtures__/user.ts",
273            "fixtures/user.ts",
274            "src/__snapshots__/api.ts.snap",
275            "src/user.fixture.ts",
276        ] {
277            assert!(is_test_path_str(root(), path), "{path} is a test path");
278            assert!(
279                !is_test_code_path_str(root(), path),
280                "{path} is not test code"
281            );
282        }
283    }
284
285    #[test]
286    fn declaration_test_directory_is_test_code() {
287        assert!(is_test_code_path_str(root(), "test-d/options.ts"));
288        assert!(is_test_path_str(root(), "packages/core/test-d/options.ts"));
289        assert!(!is_test_path_str(root(), "src/type-tests/options.ts"));
290    }
291
292    #[test]
293    fn declaration_test_suffix_is_test_code_outside_test_d_directory() {
294        assert!(is_test_code_path_str(root(), "types/options.test-d.ts"));
295        assert!(!is_test_path_str(root(), "types/options.d.ts"));
296    }
297
298    #[test]
299    fn test_code_wins_over_test_support() {
300        assert!(is_test_code_path_str(root(), "test/fixtures/user.ts"));
301        assert!(is_test_code_path_str(
302            root(),
303            "src/__fixtures__/user.test.ts"
304        ));
305        assert!(is_test_code_path_str(root(), "src/__mocks__/tests/api.ts"));
306    }
307
308    #[test]
309    fn backslash_separates_segments_on_every_platform() {
310        assert!(is_test_path_str(root(), r"src\__tests__\widget.ts"));
311        assert!(is_test_path_str(root(), r"packages\core\tests\widget.ts"));
312        assert!(!is_test_path_str(root(), r"src\components\widget.ts"));
313    }
314
315    #[test]
316    fn marker_in_a_directory_name_does_not_match() {
317        assert!(!is_test_path_str(root(), "src/foo.test.d/widget.ts"));
318        assert!(is_test_path_str(root(), "src/foo.test.d/widget.test.ts"));
319    }
320
321    #[test]
322    fn empty_and_root_only_paths_are_not_tests() {
323        assert!(!is_test_path_str(root(), ""));
324        assert!(!is_test_path_str(root(), "/"));
325        assert!(!is_test_path_str(root(), "tests/"));
326    }
327
328    #[test]
329    fn welsh_locale_file_is_not_test_code() {
330        assert!(!is_test_path_str(root(), "src/i18n/strings.cy.ts"));
331    }
332
333    #[test]
334    fn spec_directory_below_source_is_not_test_code() {
335        assert!(!is_test_path_str(root(), "src/spec/schema.ts"));
336    }
337
338    /// Create `relative` below `root`: a directory when it ends with `/`,
339    /// else an empty file.
340    fn touch(root: &Path, relative: &str) {
341        let path = root.join(relative);
342        if relative.ends_with('/') {
343            std::fs::create_dir_all(path).unwrap();
344            return;
345        }
346        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
347        std::fs::write(path, "").unwrap();
348    }
349
350    #[test]
351    fn cypress_spec_needs_cypress_evidence() {
352        assert!(is_test_code_path_str(root(), "cypress/e2e/login.cy.ts"));
353        assert!(!is_test_path_str(root(), "src/components/Button.cy.tsx"));
354
355        let dir = tempfile::tempdir().unwrap();
356        touch(dir.path(), "cypress.config.ts");
357        assert!(is_test_code_path_str(
358            dir.path(),
359            "src/components/Button.cy.tsx"
360        ));
361        assert!(!is_test_path_str(dir.path(), "src/i18n/strings.cy.json"));
362
363        let dir = tempfile::tempdir().unwrap();
364        touch(dir.path(), "packages/web/cypress/");
365        assert!(is_test_code_path_str(
366            dir.path(),
367            "packages/web/src/Button.cy.jsx"
368        ));
369        assert!(!is_test_path_str(
370            dir.path(),
371            "packages/api/src/strings.cy.ts"
372        ));
373    }
374
375    #[test]
376    fn spec_directory_counts_only_at_a_test_root() {
377        let dir = tempfile::tempdir().unwrap();
378        touch(dir.path(), "packages/core/package.json");
379        assert!(is_test_code_path_str(dir.path(), "spec/widget.ts"));
380        assert!(is_test_code_path_str(dir.path(), "specs/widget.ts"));
381        assert!(is_test_code_path_str(
382            dir.path(),
383            "packages/core/spec/widget.ts"
384        ));
385        assert!(!is_test_path_str(
386            dir.path(),
387            "packages/core/src/spec/schema.ts"
388        ));
389        assert!(!is_test_path_str(
390            dir.path(),
391            "packages/other/spec/schema.ts"
392        ));
393
394        touch(dir.path(), "examples/basic/src/");
395        assert!(is_test_code_path_str(
396            dir.path(),
397            "examples/basic/spec/widget.ts"
398        ));
399    }
400
401    #[test]
402    fn substring_search_ignores_ascii_case() {
403        assert!(contains_ignore_ascii_case("App.TEST.ts", ".test."));
404        assert!(!contains_ignore_ascii_case("ab", "abc"));
405        assert!(contains_ignore_ascii_case("anything", ""));
406    }
407}