testing_conventions/
location.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(
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 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 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
132fn 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
149fn 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
156fn 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
163fn 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
172fn 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
201fn extension_of(path: &Path) -> String {
203 path.extension()
204 .map(|ext| ext.to_string_lossy().into_owned())
205 .unwrap_or_default()
206}
207
208fn 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
215fn 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 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 assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
327 assert!(Language::TypeScript.has_code("const r = a / b;\n"));
329 }
330}