_diffctx/testfiles.rs
1//! One answer to "is this a test file".
2//!
3//! There used to be two: a per-language dispatch in
4//! `edges::structural::testing` gating `TestEdge` emission, and a flat suffix
5//! list in `utility::needs` gating test-need match strength. They disagreed —
6//! a `.kts` file was a test to one and not the other — so the two halves of
7//! "this is a test for the changed code" could each hold independently (#182).
8//!
9//! Both also accepted any stem ending in `test`, because the JVM and Scala
10//! conventions carry no separator (`FooTest.java`) and both lowercased the name
11//! before comparing, which destroys the CamelCase boundary that makes the
12//! convention readable. That matched `latest`, `greatest`, `contest` and
13//! `attest`. The rule here is that a `test`/`spec` marker counts only at a
14//! **word boundary**: its own segment between separators, or a capitalised
15//! `Test`/`Spec` in the original name.
16//!
17//! Two families are accepted false positives, because no name-based rule
18//! separates them: `PodSpec`/`JobSpec` (Kubernetes API types) look exactly like
19//! `AuthSpec` (a Scala test), and `ABTest` (an experiment) looks exactly like
20//! `FooTest`. Both over-classify — a K8s model contributes test-need strength
21//! it should not — and both are preferred to under-classifying the conventions
22//! they collide with, which are far more common in the corpora this feeds.
23
24use std::path::Path;
25
26/// Directory names that mark everything beneath them as test material.
27const TEST_DIRS: &[&str] = &["test", "tests", "__tests__", "spec", "specs"];
28
29/// Segment markers, compared case-insensitively against a whole segment.
30const SEGMENT_MARKERS: &[&str] = &["test", "tests", "spec", "specs"];
31
32/// CamelCase markers, compared against the original case — the capital letter
33/// *is* the word boundary.
34const CAMEL_MARKERS: &[&str] = &["Test", "Tests", "Spec", "Specs"];
35
36fn has_test_directory(path: &Path) -> bool {
37 path.components().any(|c| {
38 let segment = c.as_os_str().to_string_lossy().to_lowercase();
39 TEST_DIRS.contains(&segment.as_str())
40 })
41}
42
43/// True when `stem` carries a test marker as its own `_`/`-`/`.`-delimited
44/// segment: `test_auth`, `auth_test`, `widget.spec`, `widget-test`, `tests`.
45fn has_marker_segment(stem: &str) -> bool {
46 stem.split(['_', '-', '.'])
47 .any(|segment| SEGMENT_MARKERS.contains(&segment.to_lowercase().as_str()))
48}
49
50/// True when `stem` carries a capitalised marker at a CamelCase boundary:
51/// `FooTest`, `TestFoo`, `AuthSpec`. Deliberately case-sensitive — lowercasing
52/// first is what made `latest` look like a test.
53fn has_camel_marker(stem: &str) -> bool {
54 for marker in CAMEL_MARKERS {
55 // A capital `T` *is* the word boundary: in CamelCase an uppercase letter
56 // always starts a new word, and these markers are compared case
57 // sensitively, so a match cannot be the tail of a longer word.
58 // `latest`/`contest`/`attest` carry a lowercase `t` and never reach here.
59 //
60 // This used to also require the preceding character to be lowercase,
61 // which rejected every acronym-prefixed name the JVM conventions are
62 // full of — `XMLTest`, `HTTPTest`, `DBTest`, `UITest`, `IOTest` were all
63 // classified as ordinary source. The guard prevented no false positive:
64 // the only stems it excluded were exactly those acronyms.
65 if stem.strip_suffix(marker).is_some() {
66 return true;
67 }
68 if let Some(rest) = stem.strip_prefix(marker) {
69 // `TestFoo` — the next character starts a new word. A bare `Test`
70 // stem is already covered by the segment rule.
71 if rest.starts_with(char::is_uppercase) {
72 return true;
73 }
74 }
75 }
76 false
77}
78
79/// Whether `path` is test material.
80pub fn is_test_path(path: &Path) -> bool {
81 if has_test_directory(path) {
82 return true;
83 }
84 // `file_stem` strips one extension, so `widget.test.ts` yields
85 // `widget.test` and the marker survives as a segment.
86 let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
87 return false;
88 };
89 has_marker_segment(&stem) || has_camel_marker(&stem)
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::path::Path;
96
97 fn is_test(p: &str) -> bool {
98 is_test_path(Path::new(p))
99 }
100
101 #[test]
102 fn every_supported_naming_convention_is_recognised() {
103 for path in [
104 "tests/test_auth.py",
105 "src/auth_test.py",
106 "web/handler_test.go",
107 "ui/widget.test.ts",
108 "ui/widget.spec.tsx",
109 "ui/widget-spec.js",
110 "src/FooTest.java",
111 "src/FooTest.kt",
112 "src/AuthSpec.scala",
113 "src/TestHelpers.kt",
114 // Acronym + marker. Every one of these was misclassified as
115 // ordinary source while the camel rule demanded a lowercase
116 // character before the marker.
117 "src/XMLTest.java",
118 "src/HTTPTest.go",
119 "src/DBTest.kt",
120 "src/UITest.swift",
121 "src/IOTest.scala",
122 "src/MyXMLTest.java",
123 "src/JSONSpec.scala",
124 "crates/x/tests/integration.rs",
125 "src/tests.rs",
126 "app/__tests__/widget.jsx",
127 "spec/models/user_spec.rb",
128 ] {
129 assert!(is_test(path), "not recognised as a test: {path}");
130 }
131 }
132
133 /// The false positive both old implementations shared: a stem that merely
134 /// *ends* in the letters `test`, with no word boundary.
135 #[test]
136 fn an_ordinary_word_ending_in_test_is_not_a_test_file() {
137 for path in [
138 "src/latest.rs",
139 "src/latest.java",
140 "src/latest.kt",
141 "src/greatest.scala",
142 "src/contest.py",
143 "src/attest.go",
144 "src/testing.rs",
145 "src/tester.py",
146 "src/manifest.json",
147 ] {
148 assert!(!is_test(path), "wrongly classified as a test: {path}");
149 }
150 }
151
152 /// `conftest.py` is pytest infrastructure rather than a test module, and
153 /// both previous implementations classified it as non-test. Corpus cases
154 /// depend on that, so it must not change.
155 #[test]
156 fn conftest_is_not_itself_a_test_file() {
157 assert!(!is_test("tests_helpers/conftest.py"));
158 assert!(!is_test("src/conftest.py"));
159 }
160
161 /// A `tests/` directory anywhere in the path wins regardless of filename —
162 /// that is how every ecosystem separates its test tree.
163 #[test]
164 fn a_test_directory_marks_everything_beneath_it() {
165 assert!(is_test("tests/fixtures/data_loader.py"));
166 assert!(is_test("crates/x/tests/common/mod.rs"));
167 assert!(!is_test("src/testdata/loader.py"));
168 }
169}