testing_conventions/
location.rs1use std::collections::HashSet;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
17pub enum Language {
18 #[value(name = "python")]
20 Python,
21 #[value(name = "typescript")]
24 TypeScript,
25}
26
27impl Language {
28 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 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 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 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
71pub 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 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
98fn 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
115fn 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
122fn 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
129fn extension_of(path: &Path) -> String {
131 path.extension()
132 .map(|ext| ext.to_string_lossy().into_owned())
133 .unwrap_or_default()
134}
135
136fn 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
143fn 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 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}