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