Skip to main content

testing_conventions/
location.rs

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