testing_conventions/
colocated_test.rs1use std::collections::{BTreeSet, HashSet};
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
24pub enum Language {
25 #[value(name = "python")]
27 Python,
28 #[value(name = "typescript")]
31 TypeScript,
32}
33
34impl Language {
35 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 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 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 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
81pub 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 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 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
133fn 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
150fn 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
157fn 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
164fn 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
173fn 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
202fn extension_of(path: &Path) -> String {
204 path.extension()
205 .map(|ext| ext.to_string_lossy().into_owned())
206 .unwrap_or_default()
207}
208
209fn 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
216fn 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 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 assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
328 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
330 }
331}