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 #[value(name = "rust")]
37 Rust,
38}
39
40impl Language {
41 fn tracks(self, path: &Path) -> bool {
43 match self {
44 Language::Python => has_extension(path, &["py"]),
45 Language::TypeScript => {
46 has_extension(path, &["ts", "tsx", "mts", "cts"]) && !is_declaration(path)
47 }
48 Language::Rust => false,
53 }
54 }
55
56 fn is_test(self, path: &Path) -> bool {
58 match self {
59 Language::Python => stem_of(path).ends_with("_test"),
60 Language::TypeScript => {
61 let name = file_name_of(path);
62 name.ends_with(".test.ts")
63 || name.ends_with(".test.tsx")
64 || name.ends_with(".test.mts")
65 || name.ends_with(".test.cts")
66 }
67 Language::Rust => false,
68 }
69 }
70
71 fn has_code(self, source: &str) -> bool {
76 match self {
77 Language::Python => python_has_code(source),
78 Language::TypeScript => typescript_has_code(source),
79 Language::Rust => false,
80 }
81 }
82
83 fn expected_test_path(self, source: &Path) -> PathBuf {
85 match self {
86 Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
87 Language::TypeScript => {
88 source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
89 }
90 Language::Rust => source.to_path_buf(),
92 }
93 }
94}
95
96pub fn missing_unit_tests(
107 root: impl AsRef<Path>,
108 language: Language,
109 exempt: &BTreeSet<String>,
110) -> Result<Vec<PathBuf>> {
111 let root = root.as_ref();
112 let mut files = Vec::new();
113 collect_files(root, language, &mut files)?;
114
115 let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
118
119 let mut orphans: Vec<PathBuf> = Vec::new();
120 for source in &files {
121 if language.is_test(source) {
122 continue;
123 }
124 if present.contains(language.expected_test_path(source).as_path()) {
125 continue;
126 }
127 let contents = std::fs::read_to_string(source)
130 .with_context(|| format!("reading source file `{}`", source.display()))?;
131 if !language.has_code(&contents) {
132 continue;
133 }
134 let relative = source
135 .strip_prefix(root)
136 .unwrap_or(source)
137 .to_string_lossy()
138 .replace('\\', "/");
139 if exempt.contains(&relative) {
140 continue;
141 }
142 orphans.push(source.clone());
143 }
144 orphans.sort();
145 Ok(orphans)
146}
147
148fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
150 let entries =
151 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
152 for entry in entries {
153 let path = entry
154 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
155 .path();
156 if path.is_dir() {
157 collect_files(&path, language, out)?;
158 } else if language.tracks(&path) {
159 out.push(path);
160 }
161 }
162 Ok(())
163}
164
165fn has_extension(path: &Path, extensions: &[&str]) -> bool {
167 path.extension()
168 .and_then(|ext| ext.to_str())
169 .is_some_and(|ext| extensions.contains(&ext))
170}
171
172fn is_declaration(path: &Path) -> bool {
175 let name = file_name_of(path);
176 name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
177}
178
179fn python_has_code(source: &str) -> bool {
182 source.lines().any(|line| {
183 let trimmed = line.trim_start();
184 !trimmed.is_empty() && !trimmed.starts_with('#')
185 })
186}
187
188fn typescript_has_code(source: &str) -> bool {
192 let mut chars = source.chars().peekable();
193 while let Some(c) = chars.next() {
194 match c {
195 c if c.is_whitespace() => {}
196 '/' if chars.peek() == Some(&'/') => {
197 while chars.peek().is_some_and(|&n| n != '\n') {
198 chars.next();
199 }
200 }
201 '/' if chars.peek() == Some(&'*') => {
202 chars.next();
203 let mut prev = '\0';
204 for n in chars.by_ref() {
205 if prev == '*' && n == '/' {
206 break;
207 }
208 prev = n;
209 }
210 }
211 _ => return true,
212 }
213 }
214 false
215}
216
217fn extension_of(path: &Path) -> String {
219 path.extension()
220 .map(|ext| ext.to_string_lossy().into_owned())
221 .unwrap_or_default()
222}
223
224fn file_name_of(path: &Path) -> String {
226 path.file_name()
227 .map(|name| name.to_string_lossy().into_owned())
228 .unwrap_or_default()
229}
230
231fn stem_of(path: &Path) -> String {
233 path.file_stem()
234 .map(|stem| stem.to_string_lossy().into_owned())
235 .unwrap_or_default()
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn python_tracks_py_files() {
244 assert!(Language::Python.tracks(Path::new("a.py")));
245 assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
246 assert!(!Language::Python.tracks(Path::new("a.pyi")));
247 assert!(!Language::Python.tracks(Path::new("a.txt")));
248 assert!(!Language::Python.tracks(Path::new("README")));
249 }
250
251 #[test]
252 fn python_recognizes_test_files_by_stem_suffix() {
253 assert!(Language::Python.is_test(Path::new("widget_test.py")));
254 assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
255 assert!(!Language::Python.is_test(Path::new("widget.py")));
256 }
257
258 #[test]
259 fn python_expected_test_path_is_the_colocated_twin() {
260 assert_eq!(
261 Language::Python.expected_test_path(Path::new("pkg/widget.py")),
262 PathBuf::from("pkg/widget_test.py")
263 );
264 assert_eq!(
265 Language::Python.expected_test_path(Path::new("widget.py")),
266 PathBuf::from("widget_test.py")
267 );
268 }
269
270 #[test]
271 fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
272 assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
273 assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
274 assert!(Language::TypeScript.tracks(Path::new("service.mts")));
275 assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
276 assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
277 assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
278 assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
279 assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
280 assert!(!Language::TypeScript.tracks(Path::new("README")));
281 }
282
283 #[test]
284 fn typescript_recognizes_test_files_by_suffix() {
285 assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
286 assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
287 assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
288 assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
289 assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
290 assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
291 assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
292 }
293
294 #[test]
295 fn typescript_expected_test_path_keeps_the_extension() {
296 assert_eq!(
297 Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
298 PathBuf::from("pkg/widget.test.ts")
299 );
300 assert_eq!(
301 Language::TypeScript.expected_test_path(Path::new("button.tsx")),
302 PathBuf::from("button.test.tsx")
303 );
304 assert_eq!(
305 Language::TypeScript.expected_test_path(Path::new("service.mts")),
306 PathBuf::from("service.test.mts")
307 );
308 assert_eq!(
309 Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
310 PathBuf::from("legacy.test.cts")
311 );
312 }
313
314 #[test]
315 fn python_empty_or_comment_only_files_have_no_code() {
316 assert!(!Language::Python.has_code(""));
317 assert!(!Language::Python.has_code("\n \n"));
318 assert!(!Language::Python.has_code("# just a comment\n # another\n"));
319 }
320
321 #[test]
322 fn python_real_content_counts_as_code() {
323 assert!(Language::Python.has_code("x = 1\n"));
324 assert!(Language::Python.has_code("# header\nimport os\n"));
325 assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
327 }
328
329 #[test]
330 fn typescript_empty_or_comment_only_files_have_no_code() {
331 assert!(!Language::TypeScript.has_code(""));
332 assert!(!Language::TypeScript.has_code(" \n\t\n"));
333 assert!(!Language::TypeScript.has_code("// a line comment\n"));
334 assert!(!Language::TypeScript.has_code("/* a\n block\n comment */\n"));
335 }
336
337 #[test]
338 fn typescript_real_content_counts_as_code() {
339 assert!(Language::TypeScript.has_code("export const x = 1;\n"));
340 assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
341 assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
343 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
345 }
346
347 #[test]
348 fn rust_has_no_file_based_colocated_convention() {
349 assert!(!Language::Rust.tracks(Path::new("lib.rs")));
352 assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
353 assert!(!Language::Rust.has_code("fn main() {}\n"));
354 assert_eq!(
355 Language::Rust.expected_test_path(Path::new("src/lib.rs")),
356 PathBuf::from("src/lib.rs")
357 );
358 }
359}