Skip to main content

testing_conventions/
colocated_test.rs

1//! The unit `colocated-test` check (Python — issue #15; TypeScript — issue #18;
2//! exemptions — issue #32).
3//!
4//! Convention (README "Colocated Test"; `internals/*/testing.md`): a source
5//! file is unit-tested by a *colocated* test named after it — `foo.py` →
6//! `foo_test.py` (Python), `foo-bar.ts` → `foo-bar.test.ts` (TypeScript).
7//! [`missing_unit_tests`] walks a tree for a [`Language`] and returns every
8//! source file with no such sibling — an "orphan". Test files are what the
9//! check looks *for*, never subjects.
10//!
11//! Two things are not orphans even without a colocated test (issue #32): a file
12//! that holds no code (empty or comment-only — e.g. a bare `__init__.py`), which
13//! is not a subject at all, and a file listed in the config `exempt` table,
14//! which is a deliberate, reason-required omission. Everything else must be
15//! tested — there is no automatic name- or shape-based exemption.
16
17use std::collections::{BTreeSet, HashSet};
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21
22/// A language whose colocated unit-test convention can be checked.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
24pub enum Language {
25    /// `foo.py` → colocated `foo_test.py`.
26    #[value(name = "python")]
27    Python,
28    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
29    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
30    #[value(name = "typescript")]
31    TypeScript,
32    /// Rust units are inline `#[cfg(test)]` modules, not separate files, so the
33    /// file-based colocated-test check does not apply (that's the Rust isolation
34    /// rule, #44). The variant exists so the shared `--language` flag accepts
35    /// `rust` for the rules that support it — e.g. `packaging` (#74).
36    #[value(name = "rust")]
37    Rust,
38}
39
40impl Language {
41    /// `true` for a file this language's check tracks (source *or* test).
42    fn tracks(self, path: &Path) -> bool {
43        match self {
44            Language::Python => has_extension(path, &["py"]),
45            Language::TypeScript => {
46                has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
47            }
48            // No file-based colocated convention (inline `#[cfg(test)]`), so the
49            // walk tracks nothing; `unit colocated-test` guards `--language rust`
50            // upstream, so `is_test` / `has_code` / `expected_test_path` are never
51            // reached for it.
52            Language::Rust => false,
53        }
54    }
55
56    /// `true` when `path` is itself a unit test, never a subject.
57    fn is_test(self, path: &Path) -> bool {
58        match self {
59            Language::Python => stem_of(path).ends_with("_test"),
60            Language::TypeScript => {
61                let name = file_name_of(path);
62                name.ends_with(".test.ts")
63                    || name.ends_with(".test.tsx")
64                    || name.ends_with(".test.mts")
65                    || name.ends_with(".test.cts")
66            }
67            Language::Rust => false,
68        }
69    }
70
71    /// `true` when `source` (the file's contents) holds at least one line of
72    /// code — anything beyond blank lines and comments. An empty or comment-only
73    /// file (e.g. a bare `__init__.py`) carries no logic, so it is never a
74    /// unit-test subject and needs no exemption (issue #32).
75    fn has_code(self, source: &str) -> bool {
76        match self {
77            Language::Python => python_has_code(source),
78            Language::TypeScript => typescript_has_code(source),
79            Language::Rust => false,
80        }
81    }
82
83    /// The colocated test `source` is expected to have.
84    fn expected_test_path(self, source: &Path) -> PathBuf {
85        match self {
86            Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
87            Language::TypeScript => {
88                source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
89            }
90            // Unreachable for Rust (nothing is tracked); a harmless identity.
91            Language::Rust => source.to_path_buf(),
92        }
93    }
94}
95
96/// Walk `root` recursively and return every source file (for `language`) that
97/// has no colocated unit test, sorted for deterministic output.
98///
99/// A file that is itself a test is never a subject; an empty/comment-only file
100/// holds no logic and is never a subject; a file whose `root`-relative path is
101/// in `exempt` is a deliberate, reason-required omission. Every other source
102/// file must have its colocated test sibling. `exempt` holds the
103/// `colocated-test`-rule paths resolved from config
104/// ([`crate::config::resolve_exempt`]). Returns an
105/// error if the tree under `root` cannot be read.
106pub fn missing_unit_tests(
107    root: impl AsRef<Path>,
108    language: Language,
109    exempt: &BTreeSet<String>,
110) -> Result<Vec<PathBuf>> {
111    let root = root.as_ref();
112    let mut files = Vec::new();
113    collect_files(root, language, &mut files)?;
114
115    // Every tracked path we found, so a subject's expected twin is a lookup
116    // rather than a second pass over the filesystem.
117    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
118
119    let mut orphans: Vec<PathBuf> = Vec::new();
120    for source in &files {
121        if language.is_test(source) {
122            continue;
123        }
124        if present.contains(language.expected_test_path(source).as_path()) {
125            continue;
126        }
127        // No colocated test. An empty/comment-only file is not a subject; read
128        // only now — for the handful of files that lack a twin — to find out.
129        let contents = std::fs::read_to_string(source)
130            .with_context(|| format!("reading source file `{}`", source.display()))?;
131        if !language.has_code(&contents) {
132            continue;
133        }
134        let relative = source
135            .strip_prefix(root)
136            .unwrap_or(source)
137            .to_string_lossy()
138            .replace('\\', "/");
139        if exempt.contains(&relative) {
140            continue;
141        }
142        orphans.push(source.clone());
143    }
144    orphans.sort();
145    Ok(orphans)
146}
147
148/// Recursively collect every file `language` tracks under `dir` into `out`.
149fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
150    let entries =
151        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
152    for entry in entries {
153        let path = entry
154            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
155            .path();
156        if path.is_dir() {
157            collect_files(&path, language, out)?;
158        } else if language.tracks(&path) {
159            out.push(path);
160        }
161    }
162    Ok(())
163}
164
165/// `true` when the file's extension is one of `extensions`.
166fn has_extension(path: &Path, extensions: &[&str]) -> bool {
167    path.extension()
168        .and_then(|ext| ext.to_str())
169        .is_some_and(|ext| extensions.contains(&ext))
170}
171
172/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`) —
173/// no runtime code, so never a unit-test subject.
174fn is_declaration(path: &Path) -> bool {
175    let name = file_name_of(path);
176    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
177}
178
179/// `true` when any line of Python `source` is neither blank nor a `#` comment. A
180/// module docstring counts as code (it is non-comment content).
181fn python_has_code(source: &str) -> bool {
182    source.lines().any(|line| {
183        let trimmed = line.trim_start();
184        !trimmed.is_empty() && !trimmed.starts_with('#')
185    })
186}
187
188/// `true` when TypeScript `source` holds anything beyond whitespace and comments
189/// (`//` line, `/* … */` block). Any other character — including the start of a
190/// string literal — counts as code.
191fn typescript_has_code(source: &str) -> bool {
192    let mut chars = source.chars().peekable();
193    while let Some(c) = chars.next() {
194        match c {
195            c if c.is_whitespace() => {}
196            '/' if chars.peek() == Some(&'/') => {
197                while chars.peek().is_some_and(|&n| n != '\n') {
198                    chars.next();
199                }
200            }
201            '/' if chars.peek() == Some(&'*') => {
202                chars.next();
203                let mut prev = '\0';
204                for n in chars.by_ref() {
205                    if prev == '*' && n == '/' {
206                        break;
207                    }
208                    prev = n;
209                }
210            }
211            _ => return true,
212        }
213    }
214    false
215}
216
217/// The file extension, lossily decoded (empty if there is none).
218fn extension_of(path: &Path) -> String {
219    path.extension()
220        .map(|ext| ext.to_string_lossy().into_owned())
221        .unwrap_or_default()
222}
223
224/// The file name, lossily decoded.
225fn file_name_of(path: &Path) -> String {
226    path.file_name()
227        .map(|name| name.to_string_lossy().into_owned())
228        .unwrap_or_default()
229}
230
231/// The file stem (the name without its extension), lossily decoded.
232fn stem_of(path: &Path) -> String {
233    path.file_stem()
234        .map(|stem| stem.to_string_lossy().into_owned())
235        .unwrap_or_default()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn python_tracks_py_files() {
244        assert!(Language::Python.tracks(Path::new("a.py")));
245        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
246        assert!(!Language::Python.tracks(Path::new("a.pyi")));
247        assert!(!Language::Python.tracks(Path::new("a.txt")));
248        assert!(!Language::Python.tracks(Path::new("README")));
249    }
250
251    #[test]
252    fn python_recognizes_test_files_by_stem_suffix() {
253        assert!(Language::Python.is_test(Path::new("widget_test.py")));
254        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
255        assert!(!Language::Python.is_test(Path::new("widget.py")));
256    }
257
258    #[test]
259    fn python_expected_test_path_is_the_colocated_twin() {
260        assert_eq!(
261            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
262            PathBuf::from("pkg/widget_test.py")
263        );
264        assert_eq!(
265            Language::Python.expected_test_path(Path::new("widget.py")),
266            PathBuf::from("widget_test.py")
267        );
268    }
269
270    #[test]
271    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
272        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
273        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
274        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
275        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
276        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
277        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
278        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
279        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
280        assert!(!Language::TypeScript.tracks(Path::new("README")));
281    }
282
283    #[test]
284    fn typescript_recognizes_test_files_by_suffix() {
285        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
286        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
287        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
288        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
289        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
290        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
291        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
292    }
293
294    #[test]
295    fn typescript_expected_test_path_keeps_the_extension() {
296        assert_eq!(
297            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
298            PathBuf::from("pkg/widget.test.ts")
299        );
300        assert_eq!(
301            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
302            PathBuf::from("button.test.tsx")
303        );
304        assert_eq!(
305            Language::TypeScript.expected_test_path(Path::new("service.mts")),
306            PathBuf::from("service.test.mts")
307        );
308        assert_eq!(
309            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
310            PathBuf::from("legacy.test.cts")
311        );
312    }
313
314    #[test]
315    fn python_empty_or_comment_only_files_have_no_code() {
316        assert!(!Language::Python.has_code(""));
317        assert!(!Language::Python.has_code("\n   \n"));
318        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
319    }
320
321    #[test]
322    fn python_real_content_counts_as_code() {
323        assert!(Language::Python.has_code("x = 1\n"));
324        assert!(Language::Python.has_code("# header\nimport os\n"));
325        // A docstring is non-comment content, so it counts.
326        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
327    }
328
329    #[test]
330    fn typescript_empty_or_comment_only_files_have_no_code() {
331        assert!(!Language::TypeScript.has_code(""));
332        assert!(!Language::TypeScript.has_code("   \n\t\n"));
333        assert!(!Language::TypeScript.has_code("// a line comment\n"));
334        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
335    }
336
337    #[test]
338    fn typescript_real_content_counts_as_code() {
339        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
340        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
341        // A string literal (even one that looks comment-ish) is code.
342        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
343        // A lone division slash is code, not a comment.
344        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
345    }
346
347    #[test]
348    fn rust_has_no_file_based_colocated_convention() {
349        // Rust units are inline `#[cfg(test)]`; the file-based check tracks
350        // nothing and the command guards `--language rust` upstream.
351        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
352        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
353        assert!(!Language::Rust.has_code("fn main() {}\n"));
354        assert_eq!(
355            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
356            PathBuf::from("src/lib.rs")
357        );
358    }
359}