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"];
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.", ".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!(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    /// Create `relative` below `root`: a directory when it ends with `/`,
329    /// else an empty file.
330    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}