Skip to main content

declutter/
test_files.rs

1use crate::lang::Lang;
2
3/// Whether a changed file belongs to the test layer: by path convention first, then,
4/// for files outside test directories, by importing a test framework.
5pub fn is_test_file(path: &str, content: &str) -> bool {
6    is_test_path(path)
7        || Lang::from_path(path).is_some_and(|lang| imports_test_framework(lang, content))
8}
9
10pub fn is_test_path(path: &str) -> bool {
11    let mut components: Vec<&str> = path.split('/').collect();
12    let Some(file) = components.pop() else {
13        return false;
14    };
15    components.iter().any(|dir| is_test_dir(dir)) || is_test_file_name(file)
16}
17
18fn is_test_dir(dir: &str) -> bool {
19    let lower = dir.to_ascii_lowercase();
20    matches!(
21        lower.as_str(),
22        "test" | "tests" | "__tests__" | "spec" | "specs" | "__mocks__" | "__snapshots__"
23    ) || dir.ends_with("Tests")
24}
25
26fn is_test_file_name(file: &str) -> bool {
27    let stem = file.split('.').next().unwrap_or(file);
28    // CamelCase suffixes: FooTests.swift, FooTest.kt, FooTests.cs. Not `FooSpec`: too
29    // many real types end in Spec (`TypeSpec`); Quick and Kotest specs are found by import.
30    let camel = ["Tests", "Test"]
31        .iter()
32        .any(|suffix| stem.len() > suffix.len() && stem.ends_with(suffix));
33    // Dotted and snake_case markers: foo.test.ts, foo.spec.tsx, foo_test.go, test_foo.py.
34    let marked = file.contains(".test.")
35        || file.contains(".spec.")
36        || stem.ends_with("_test")
37        || stem.ends_with("_spec")
38        // `test_foo.py` is pytest's convention; elsewhere `test_` is just a name.
39        || (stem.starts_with("test_") && file.ends_with(".py"))
40        || file == "conftest.py";
41    camel || marked
42}
43
44fn imports_test_framework(lang: Lang, content: &str) -> bool {
45    content.lines().map(str::trim_start).any(|line| match lang {
46        Lang::Swift => {
47            line.starts_with("@testable import ")
48                || matches!(
49                    line,
50                    "import XCTest" | "import Testing" | "import Quick" | "import Nimble"
51                )
52        }
53        Lang::Python => [
54            "import pytest",
55            "from pytest",
56            "import unittest",
57            "from unittest",
58        ]
59        .iter()
60        .any(|import| line.starts_with(import)),
61        Lang::TypeScript | Lang::Tsx | Lang::JavaScript => {
62            line.starts_with("import")
63                && ["vitest", "@jest/globals", "node:test", "@testing-library/"]
64                    .iter()
65                    .any(|module| {
66                        line.contains(&format!("'{module}"))
67                            || line.contains(&format!("\"{module}"))
68                    })
69        }
70        Lang::Kotlin => ["org.junit", "kotlin.test", "io.kotest", "io.mockk"]
71            .iter()
72            .any(|package| line.starts_with(&format!("import {package}"))),
73        // Go keeps tests in `_test.go` files, and Rust inside source files, which the
74        // test-block spans cover.
75        Lang::Go | Lang::Rust => false,
76    })
77}