Skip to main content

testing_conventions/
location.rs

1//! Unit-test location/naming check (Python — issue #15; TypeScript — issue #18).
2//!
3//! Convention (README "Location & Naming"; `internals/*/testing.md`): a source
4//! file is unit-tested by a *colocated* test named after it — `foo.py` →
5//! `foo_test.py` (Python), `foo-bar.ts` → `foo-bar.test.ts` (TypeScript).
6//! [`missing_unit_tests`] walks a tree for a [`Language`] and returns every
7//! source file with no such sibling — an "orphan". Test files are what the
8//! check looks *for*, never subjects.
9
10use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14
15/// A language whose unit-test location/naming convention can be checked.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
17pub enum Language {
18    /// `foo.py` → colocated `foo_test.py`; `__init__.py` is exempt.
19    #[value(name = "python")]
20    Python,
21    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
22    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
23    #[value(name = "typescript")]
24    TypeScript,
25}
26
27impl Language {
28    /// `true` for a file this language's check tracks (source *or* test).
29    fn tracks(self, path: &Path) -> bool {
30        match self {
31            Language::Python => has_extension(path, &["py"]),
32            Language::TypeScript => {
33                has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
34            }
35        }
36    }
37
38    /// `true` when `path` is itself a unit test, never a subject.
39    fn is_test(self, path: &Path) -> bool {
40        match self {
41            Language::Python => stem_of(path).ends_with("_test"),
42            Language::TypeScript => {
43                let name = file_name_of(path);
44                name.ends_with(".test.ts")
45                    || name.ends_with(".test.tsx")
46                    || name.ends_with(".test.mts")
47                    || name.ends_with(".test.cts")
48            }
49        }
50    }
51
52    /// `true` for a file exempt from needing a colocated test.
53    fn is_exempt(self, path: &Path) -> bool {
54        match self {
55            Language::Python => file_name_of(path) == "__init__.py",
56            Language::TypeScript => false,
57        }
58    }
59
60    /// The colocated test `source` is expected to have.
61    fn expected_test_path(self, source: &Path) -> PathBuf {
62        match self {
63            Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
64            Language::TypeScript => {
65                source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
66            }
67        }
68    }
69}
70
71/// Walk `root` recursively and return every source file (for `language`) that
72/// has no colocated unit test, sorted for deterministic output.
73///
74/// A file that is itself a test is never treated as a subject; every other
75/// source file must have its colocated test sibling. Returns an error if the
76/// tree under `root` cannot be read.
77pub fn missing_unit_tests(root: impl AsRef<Path>, language: Language) -> Result<Vec<PathBuf>> {
78    let mut files = Vec::new();
79    collect_files(root.as_ref(), language, &mut files)?;
80
81    // Every tracked path we found, so a subject's expected twin is a lookup
82    // rather than a second pass over the filesystem.
83    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
84
85    let mut orphans: Vec<PathBuf> = Vec::new();
86    for source in &files {
87        if language.is_test(source) || language.is_exempt(source) {
88            continue;
89        }
90        if !present.contains(language.expected_test_path(source).as_path()) {
91            orphans.push(source.clone());
92        }
93    }
94    orphans.sort();
95    Ok(orphans)
96}
97
98/// Recursively collect every file `language` tracks under `dir` into `out`.
99fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
100    let entries =
101        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
102    for entry in entries {
103        let path = entry
104            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
105            .path();
106        if path.is_dir() {
107            collect_files(&path, language, out)?;
108        } else if language.tracks(&path) {
109            out.push(path);
110        }
111    }
112    Ok(())
113}
114
115/// `true` when the file's extension is one of `extensions`.
116fn has_extension(path: &Path, extensions: &[&str]) -> bool {
117    path.extension()
118        .and_then(|ext| ext.to_str())
119        .is_some_and(|ext| extensions.contains(&ext))
120}
121
122/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`) —
123/// no runtime code, so never a unit-test subject.
124fn is_declaration(path: &Path) -> bool {
125    let name = file_name_of(path);
126    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
127}
128
129/// The file extension, lossily decoded (empty if there is none).
130fn extension_of(path: &Path) -> String {
131    path.extension()
132        .map(|ext| ext.to_string_lossy().into_owned())
133        .unwrap_or_default()
134}
135
136/// The file name, lossily decoded.
137fn file_name_of(path: &Path) -> String {
138    path.file_name()
139        .map(|name| name.to_string_lossy().into_owned())
140        .unwrap_or_default()
141}
142
143/// The file stem (the name without its extension), lossily decoded.
144fn stem_of(path: &Path) -> String {
145    path.file_stem()
146        .map(|stem| stem.to_string_lossy().into_owned())
147        .unwrap_or_default()
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn python_tracks_py_files() {
156        assert!(Language::Python.tracks(Path::new("a.py")));
157        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
158        assert!(!Language::Python.tracks(Path::new("a.pyi")));
159        assert!(!Language::Python.tracks(Path::new("a.txt")));
160        assert!(!Language::Python.tracks(Path::new("README")));
161    }
162
163    #[test]
164    fn python_recognizes_test_files_by_stem_suffix() {
165        assert!(Language::Python.is_test(Path::new("widget_test.py")));
166        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
167        assert!(!Language::Python.is_test(Path::new("widget.py")));
168    }
169
170    #[test]
171    fn python_exempts_the_package_marker() {
172        assert!(Language::Python.is_exempt(Path::new("__init__.py")));
173        assert!(Language::Python.is_exempt(Path::new("pkg/__init__.py")));
174        assert!(!Language::Python.is_exempt(Path::new("conftest.py")));
175        assert!(!Language::Python.is_exempt(Path::new("widget.py")));
176    }
177
178    #[test]
179    fn python_expected_test_path_is_the_colocated_twin() {
180        assert_eq!(
181            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
182            PathBuf::from("pkg/widget_test.py")
183        );
184        assert_eq!(
185            Language::Python.expected_test_path(Path::new("widget.py")),
186            PathBuf::from("widget_test.py")
187        );
188    }
189
190    #[test]
191    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
192        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
193        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
194        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
195        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
196        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
197        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
198        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
199        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
200        assert!(!Language::TypeScript.tracks(Path::new("README")));
201    }
202
203    #[test]
204    fn typescript_recognizes_test_files_by_suffix() {
205        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
206        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
207        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
208        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
209        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
210        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
211        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
212    }
213
214    #[test]
215    fn typescript_has_no_exemptions() {
216        // Unlike Python's `__init__.py`, nothing in TS is language-mandated;
217        // `index.ts` is a deliberate file and therefore a subject.
218        assert!(!Language::TypeScript.is_exempt(Path::new("index.ts")));
219        assert!(!Language::TypeScript.is_exempt(Path::new("pkg/index.ts")));
220    }
221
222    #[test]
223    fn typescript_expected_test_path_keeps_the_extension() {
224        assert_eq!(
225            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
226            PathBuf::from("pkg/widget.test.ts")
227        );
228        assert_eq!(
229            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
230            PathBuf::from("button.test.tsx")
231        );
232        assert_eq!(
233            Language::TypeScript.expected_test_path(Path::new("service.mts")),
234            PathBuf::from("service.test.mts")
235        );
236        assert_eq!(
237            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
238            PathBuf::from("legacy.test.cts")
239        );
240    }
241}