Skip to main content

testing_conventions/
colocated_test.rs

1//! The unit `colocated-test` check.
2//!
3//! Convention (README "Colocated Test"; `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//!
10//! Two things are not orphans even without a colocated test: a file
11//! that holds no code (empty or comment-only — e.g. a bare `__init__.py`), which
12//! is not a subject at all, and a file listed in the config `exempt` table,
13//! which is a deliberate, reason-required omission. Everything else must be
14//! tested — there is no automatic name- or shape-based exemption.
15
16use std::collections::{BTreeSet, HashSet};
17use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Context, Result};
20use syn::visit::{self, Visit};
21
22/// A language whose colocated unit-test convention can be checked.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
24pub enum Language {
25    /// `foo.py` → colocated `foo_test.py`.
26    #[value(name = "python")]
27    Python,
28    /// `foo-bar.ts` → colocated `foo-bar.test.ts`, across `.ts`/`.tsx`/`.mts`/`.cts`;
29    /// declaration files (`.d.ts`/`.d.mts`/`.d.cts`) are ignored.
30    #[value(name = "typescript")]
31    TypeScript,
32    /// Rust units are inline `#[cfg(test)]` modules, not separate files, so the
33    /// file-pairing walk below does not apply to Rust; its arm of the rule checks
34    /// inline-`#[cfg(test)]` *presence* instead ([`missing_inline_tests`]). The
35    /// variant is also accepted by the other `--language` rules (e.g. `packaging`).
36    #[value(name = "rust")]
37    Rust,
38}
39
40impl Language {
41    /// `true` for a file this language's check tracks (source *or* test).
42    pub(crate) 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            // Rust uses [`missing_inline_tests`] (inline `#[cfg(test)]` presence),
49            // not this file-pairing walk, so nothing is tracked here and `is_test`
50            // / `has_code` / `expected_test_path` are never reached for Rust.
51            Language::Rust => false,
52        }
53    }
54
55    /// `true` when `path` is itself a unit test, never a subject.
56    pub(crate) fn is_test(self, path: &Path) -> bool {
57        match self {
58            Language::Python => stem_of(path).ends_with("_test"),
59            Language::TypeScript => {
60                let name = file_name_of(path);
61                name.ends_with(".test.ts")
62                    || name.ends_with(".test.tsx")
63                    || name.ends_with(".test.mts")
64                    || name.ends_with(".test.cts")
65            }
66            Language::Rust => false,
67        }
68    }
69
70    /// `true` when `path` is test *support* — not a unit under test, but not a
71    /// subject either. Python's `conftest.py` (pytest fixtures) is the only such
72    /// file: there is no `conftest_test.py`, and it is never a coverage subject.
73    pub(crate) fn is_support(self, path: &Path) -> bool {
74        match self {
75            Language::Python => file_name_of(path) == "conftest.py",
76            Language::TypeScript | Language::Rust => false,
77        }
78    }
79
80    /// `true` when `source` (the file's contents) holds at least one line of
81    /// code — anything beyond blank lines and comments. An empty or comment-only
82    /// file (e.g. a bare `__init__.py`) carries no logic, so it is never a
83    /// unit-test subject and needs no exemption.
84    pub(crate) fn has_code(self, source: &str) -> bool {
85        match self {
86            Language::Python => python_has_code(source),
87            Language::TypeScript => typescript_has_code(source),
88            Language::Rust => false,
89        }
90    }
91
92    /// The colocated test `source` is expected to have.
93    pub(crate) fn expected_test_path(self, source: &Path) -> PathBuf {
94        match self {
95            Language::Python => source.with_file_name(format!("{}_test.py", stem_of(source))),
96            Language::TypeScript => {
97                source.with_file_name(format!("{}.test.{}", stem_of(source), extension_of(source)))
98            }
99            // Unreachable for Rust (nothing is tracked); a harmless identity.
100            Language::Rust => source.to_path_buf(),
101        }
102    }
103}
104
105/// Walk `root` recursively and return every source file (for `language`) that
106/// has no colocated unit test, sorted for deterministic output.
107///
108/// A file that is itself a test is never a subject; an empty/comment-only file
109/// holds no logic and is never a subject; a file whose `root`-relative path is
110/// in `exempt` is a deliberate, reason-required omission. Every other source
111/// file must have its colocated test sibling. `exempt` holds the
112/// `colocated-test`-rule paths resolved from config
113/// ([`crate::config::resolve_exempt`]). Returns an
114/// error if the tree under `root` cannot be read.
115pub fn missing_unit_tests(
116    root: impl AsRef<Path>,
117    language: Language,
118    exempt: &BTreeSet<String>,
119) -> Result<Vec<PathBuf>> {
120    let root = root.as_ref();
121    let mut files = Vec::new();
122    collect_files(root, language, &mut files)?;
123    // `<package root>/tests/` belongs to the suite tiers (integration / e2e),
124    // so nothing under it is a colocated-unit subject. Rust's walk already
125    // skips `tests/` (an integration-crate directory in cargo's own layout).
126    let manifest = match language {
127        Language::Python => Some("pyproject.toml"),
128        Language::TypeScript => Some("package.json"),
129        Language::Rust => None,
130    };
131    if let Some(tests) = manifest.and_then(|m| crate::tiers::suite_tests_dir(root, m)) {
132        files.retain(|file| !file.starts_with(&tests));
133    }
134
135    // Every tracked path we found, so a subject's expected twin is a lookup
136    // rather than a second pass over the filesystem.
137    let present: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
138
139    let mut orphans: Vec<PathBuf> = Vec::new();
140    for source in &files {
141        // A test file and a support file (Python `conftest.py`) are never subjects.
142        if language.is_test(source) || language.is_support(source) {
143            continue;
144        }
145        if present.contains(language.expected_test_path(source).as_path()) {
146            continue;
147        }
148        // No colocated test. An empty/comment-only file is not a subject; read
149        // only now — for the handful of files that lack a twin — to find out.
150        let contents = std::fs::read_to_string(source)
151            .with_context(|| format!("reading source file `{}`", source.display()))?;
152        if !language.has_code(&contents) {
153            continue;
154        }
155        let relative = source
156            .strip_prefix(root)
157            .unwrap_or(source)
158            .to_string_lossy()
159            .replace('\\', "/");
160        if exempt.contains(&relative) {
161            continue;
162        }
163        orphans.push(source.clone());
164    }
165    orphans.sort();
166    Ok(orphans)
167}
168
169/// Recursively collect every file `language` tracks under `dir` into `out`.
170fn collect_files(dir: &Path, language: Language, out: &mut Vec<PathBuf>) -> Result<()> {
171    let entries =
172        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
173    for entry in entries {
174        let path = entry
175            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
176            .path();
177        if path.is_dir() {
178            collect_files(&path, language, out)?;
179        } else if language.tracks(&path) {
180            out.push(path);
181        }
182    }
183    Ok(())
184}
185
186/// Walk `root` for Rust source files and return every one that defines testable
187/// behavior — a function with a body, outside any `#[cfg(test)]` module — but
188/// carries no inline `#[cfg(test)]` module, sorted for deterministic output.
189///
190/// The Rust arm of the colocated-test rule: Rust units are inline
191/// `#[cfg(test)]` modules, so "colocated" means a test module in the *same file*,
192/// not a sibling file. A file with no testable function (only `mod` / `use`
193/// declarations, types, or constants) is not a subject; integration crates under
194/// `tests/` (and `benches/` / `examples/`) are not unit sources and are skipped; a
195/// file whose `root`-relative path is in `exempt` is a deliberate, reason-required
196/// omission. Errors if the tree can't be read or a file can't be parsed.
197pub fn missing_inline_tests(
198    root: impl AsRef<Path>,
199    exempt: &BTreeSet<String>,
200) -> Result<Vec<PathBuf>> {
201    let root = root.as_ref();
202    let mut files = Vec::new();
203    collect_rust_source_files(root, &mut files)?;
204    files.sort();
205
206    let mut orphans = Vec::new();
207    for file in &files {
208        let source = std::fs::read_to_string(file)
209            .with_context(|| format!("reading source file `{}`", file.display()))?;
210        let ast = syn::parse_file(&source)
211            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
212        let mut visitor = PresenceVisitor::default();
213        visitor.visit_file(&ast);
214        // No behavior to test → not a subject; an inline `#[cfg(test)]` module → covered.
215        if !visitor.has_testable_fn || visitor.has_test_module {
216            continue;
217        }
218        let relative = file
219            .strip_prefix(root)
220            .unwrap_or(file)
221            .to_string_lossy()
222            .replace('\\', "/");
223        if exempt.contains(&relative) {
224            continue;
225        }
226        orphans.push(file.clone());
227    }
228    // `files` is already sorted, so `orphans` is in order.
229    Ok(orphans)
230}
231
232/// Recursively collect `*.rs` unit-source files under `dir` into `out`, skipping
233/// the non-unit trees — `tests/` (integration crates), `benches/`, `examples/`,
234/// `target/` — and the `build.rs` build script. Inline `#[cfg(test)]` tests live in
235/// the library/binary source, so only those files are presence subjects. Shared with
236/// the unit-isolation scan ([`crate::isolation::find_violations`]), which walks the
237/// same unit source.
238pub(crate) fn collect_rust_source_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
239    let entries =
240        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
241    for entry in entries {
242        let path = entry
243            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
244            .path();
245        if path.is_dir() {
246            let skip = matches!(
247                path.file_name().and_then(|name| name.to_str()),
248                Some("tests" | "benches" | "examples" | "target")
249            );
250            if !skip {
251                collect_rust_source_files(&path, out)?;
252            }
253        } else if has_extension(&path, &["rs"]) && file_name_of(&path) != "build.rs" {
254            out.push(path);
255        }
256    }
257    Ok(())
258}
259
260/// Walks a parsed Rust file to answer two questions for the inline-`#[cfg(test)]`
261/// presence rule: does the file define testable behavior — a function with a
262/// body outside any `#[cfg(test)]` module — and does it carry an inline
263/// `#[cfg(test)]` module? `test_depth` tracks nesting inside test modules so the
264/// test functions themselves never count as subjects.
265#[derive(Default)]
266struct PresenceVisitor {
267    test_depth: usize,
268    has_testable_fn: bool,
269    has_test_module: bool,
270}
271
272impl<'ast> Visit<'ast> for PresenceVisitor {
273    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
274        let is_test = crate::isolation::has_cfg_test(&node.attrs);
275        if is_test {
276            self.has_test_module = true;
277            self.test_depth += 1;
278        }
279        visit::visit_item_mod(self, node);
280        if is_test {
281            self.test_depth -= 1;
282        }
283    }
284
285    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
286        // A free `fn` with a body is testable behavior — unless it is itself
287        // `#[cfg(test)]`-gated (test-only code, not a shipping subject).
288        if self.test_depth == 0 && !crate::isolation::has_cfg_test(&node.attrs) {
289            self.has_testable_fn = true;
290        }
291        visit::visit_item_fn(self, node);
292    }
293
294    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
295        if self.test_depth == 0 {
296            self.has_testable_fn = true;
297        }
298        visit::visit_impl_item_fn(self, node);
299    }
300
301    fn visit_trait_item_fn(&mut self, node: &'ast syn::TraitItemFn) {
302        // Only a default method (with a body) is behavior to test; a bare signature
303        // is not.
304        if self.test_depth == 0 && node.default.is_some() {
305            self.has_testable_fn = true;
306        }
307        visit::visit_trait_item_fn(self, node);
308    }
309}
310
311/// `true` when the file's extension is one of `extensions`.
312fn has_extension(path: &Path, extensions: &[&str]) -> bool {
313    path.extension()
314        .and_then(|ext| ext.to_str())
315        .is_some_and(|ext| extensions.contains(&ext))
316}
317
318/// `true` for a TypeScript declaration file (`*.d.ts` / `*.d.mts` / `*.d.cts`) —
319/// no runtime code, so never a unit-test subject.
320fn is_declaration(path: &Path) -> bool {
321    let name = file_name_of(path);
322    name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
323}
324
325/// `true` when any line of Python `source` is neither blank nor a `#` comment. A
326/// module docstring counts as code (it is non-comment content).
327fn python_has_code(source: &str) -> bool {
328    source.lines().any(|line| {
329        let trimmed = line.trim_start();
330        !trimmed.is_empty() && !trimmed.starts_with('#')
331    })
332}
333
334/// `true` when TypeScript `source` holds anything beyond whitespace and comments
335/// (`//` line, `/* … */` block). Any other character — including the start of a
336/// string literal — counts as code.
337fn typescript_has_code(source: &str) -> bool {
338    let mut chars = source.chars().peekable();
339    while let Some(c) = chars.next() {
340        match c {
341            c if c.is_whitespace() => {}
342            '/' if chars.peek() == Some(&'/') => {
343                while chars.peek().is_some_and(|&n| n != '\n') {
344                    chars.next();
345                }
346            }
347            '/' if chars.peek() == Some(&'*') => {
348                chars.next();
349                let mut prev = '\0';
350                for n in chars.by_ref() {
351                    if prev == '*' && n == '/' {
352                        break;
353                    }
354                    prev = n;
355                }
356            }
357            _ => return true,
358        }
359    }
360    false
361}
362
363/// The file extension, lossily decoded (empty if there is none).
364fn extension_of(path: &Path) -> String {
365    path.extension()
366        .map(|ext| ext.to_string_lossy().into_owned())
367        .unwrap_or_default()
368}
369
370/// The file name, lossily decoded.
371fn file_name_of(path: &Path) -> String {
372    path.file_name()
373        .map(|name| name.to_string_lossy().into_owned())
374        .unwrap_or_default()
375}
376
377/// The file stem (the name without its extension), lossily decoded.
378fn stem_of(path: &Path) -> String {
379    path.file_stem()
380        .map(|stem| stem.to_string_lossy().into_owned())
381        .unwrap_or_default()
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn python_tracks_py_files() {
390        assert!(Language::Python.tracks(Path::new("a.py")));
391        assert!(Language::Python.tracks(Path::new("pkg/widget.py")));
392        assert!(!Language::Python.tracks(Path::new("a.pyi")));
393        assert!(!Language::Python.tracks(Path::new("a.txt")));
394        assert!(!Language::Python.tracks(Path::new("README")));
395    }
396
397    #[test]
398    fn python_recognizes_test_files_by_stem_suffix() {
399        assert!(Language::Python.is_test(Path::new("widget_test.py")));
400        assert!(Language::Python.is_test(Path::new("pkg/helper_test.py")));
401        assert!(!Language::Python.is_test(Path::new("widget.py")));
402    }
403
404    #[test]
405    fn python_conftest_is_support_not_a_subject() {
406        // conftest.py holds pytest fixtures — support, never a subject.
407        assert!(Language::Python.is_support(Path::new("conftest.py")));
408        assert!(Language::Python.is_support(Path::new("pkg/conftest.py")));
409        assert!(!Language::Python.is_support(Path::new("widget.py")));
410        assert!(!Language::Python.is_support(Path::new("widget_test.py")));
411        // Support is Python-only; TypeScript/Rust have no conftest concept.
412        assert!(!Language::TypeScript.is_support(Path::new("conftest.ts")));
413    }
414
415    #[test]
416    fn python_expected_test_path_is_the_colocated_twin() {
417        assert_eq!(
418            Language::Python.expected_test_path(Path::new("pkg/widget.py")),
419            PathBuf::from("pkg/widget_test.py")
420        );
421        assert_eq!(
422            Language::Python.expected_test_path(Path::new("widget.py")),
423            PathBuf::from("widget_test.py")
424        );
425    }
426
427    #[test]
428    fn typescript_tracks_ts_tsx_mts_cts_but_not_declarations() {
429        assert!(Language::TypeScript.tracks(Path::new("widget.ts")));
430        assert!(Language::TypeScript.tracks(Path::new("pkg/button.tsx")));
431        assert!(Language::TypeScript.tracks(Path::new("service.mts")));
432        assert!(Language::TypeScript.tracks(Path::new("legacy.cts")));
433        assert!(!Language::TypeScript.tracks(Path::new("types.d.ts")));
434        assert!(!Language::TypeScript.tracks(Path::new("ambient.d.mts")));
435        assert!(!Language::TypeScript.tracks(Path::new("globals.d.cts")));
436        assert!(!Language::TypeScript.tracks(Path::new("widget.py")));
437        assert!(!Language::TypeScript.tracks(Path::new("README")));
438    }
439
440    #[test]
441    fn typescript_recognizes_test_files_by_suffix() {
442        assert!(Language::TypeScript.is_test(Path::new("widget.test.ts")));
443        assert!(Language::TypeScript.is_test(Path::new("pkg/button.test.tsx")));
444        assert!(Language::TypeScript.is_test(Path::new("service.test.mts")));
445        assert!(Language::TypeScript.is_test(Path::new("legacy.test.cts")));
446        assert!(!Language::TypeScript.is_test(Path::new("widget.ts")));
447        assert!(!Language::TypeScript.is_test(Path::new("button.tsx")));
448        assert!(!Language::TypeScript.is_test(Path::new("service.mts")));
449    }
450
451    #[test]
452    fn typescript_expected_test_path_keeps_the_extension() {
453        assert_eq!(
454            Language::TypeScript.expected_test_path(Path::new("pkg/widget.ts")),
455            PathBuf::from("pkg/widget.test.ts")
456        );
457        assert_eq!(
458            Language::TypeScript.expected_test_path(Path::new("button.tsx")),
459            PathBuf::from("button.test.tsx")
460        );
461        assert_eq!(
462            Language::TypeScript.expected_test_path(Path::new("service.mts")),
463            PathBuf::from("service.test.mts")
464        );
465        assert_eq!(
466            Language::TypeScript.expected_test_path(Path::new("legacy.cts")),
467            PathBuf::from("legacy.test.cts")
468        );
469    }
470
471    #[test]
472    fn python_empty_or_comment_only_files_have_no_code() {
473        assert!(!Language::Python.has_code(""));
474        assert!(!Language::Python.has_code("\n   \n"));
475        assert!(!Language::Python.has_code("# just a comment\n   # another\n"));
476    }
477
478    #[test]
479    fn python_real_content_counts_as_code() {
480        assert!(Language::Python.has_code("x = 1\n"));
481        assert!(Language::Python.has_code("# header\nimport os\n"));
482        // A docstring is non-comment content, so it counts.
483        assert!(Language::Python.has_code("\"\"\"Package docstring.\"\"\"\n"));
484    }
485
486    #[test]
487    fn typescript_empty_or_comment_only_files_have_no_code() {
488        assert!(!Language::TypeScript.has_code(""));
489        assert!(!Language::TypeScript.has_code("   \n\t\n"));
490        assert!(!Language::TypeScript.has_code("// a line comment\n"));
491        assert!(!Language::TypeScript.has_code("/* a\n   block\n   comment */\n"));
492    }
493
494    #[test]
495    fn typescript_real_content_counts_as_code() {
496        assert!(Language::TypeScript.has_code("export const x = 1;\n"));
497        assert!(Language::TypeScript.has_code("// note\nexport * from './a';\n"));
498        // A string literal (even one that looks comment-ish) is code.
499        assert!(Language::TypeScript.has_code("const s = '// not a comment';\n"));
500        // A lone division slash is code, not a comment.
501        assert!(Language::TypeScript.has_code("const r = a / b;\n"));
502    }
503
504    #[test]
505    fn rust_has_no_file_based_colocated_convention() {
506        // Rust units are inline `#[cfg(test)]`; the file-based check tracks
507        // nothing and the command guards `--language rust` upstream.
508        assert!(!Language::Rust.tracks(Path::new("lib.rs")));
509        assert!(!Language::Rust.is_test(Path::new("lib_test.rs")));
510        assert!(!Language::Rust.has_code("fn main() {}\n"));
511        assert_eq!(
512            Language::Rust.expected_test_path(Path::new("src/lib.rs")),
513            PathBuf::from("src/lib.rs")
514        );
515    }
516
517    /// `(has_testable_fn, has_test_module)` for a Rust source snippet — the two
518    /// signals the inline-`#[cfg(test)]` presence rule decides on.
519    fn presence(src: &str) -> (bool, bool) {
520        let ast = syn::parse_file(src).expect("snippet parses");
521        let mut visitor = PresenceVisitor::default();
522        visitor.visit_file(&ast);
523        (visitor.has_testable_fn, visitor.has_test_module)
524    }
525
526    #[test]
527    fn rust_presence_free_fn_with_test_module_is_covered() {
528        assert_eq!(
529            presence(
530                "pub fn make(n: u8) -> u8 { n + 1 }\n\
531                 #[cfg(test)]\nmod tests { #[test] fn t() {} }\n"
532            ),
533            (true, true)
534        );
535    }
536
537    #[test]
538    fn rust_presence_free_fn_without_test_module_needs_one() {
539        assert_eq!(
540            presence("pub fn make(n: u8) -> u8 { n + 1 }\n"),
541            (true, false)
542        );
543    }
544
545    #[test]
546    fn rust_presence_type_only_file_is_not_a_subject() {
547        assert_eq!(presence("pub struct Point { pub x: u8 }\n"), (false, false));
548    }
549
550    #[test]
551    fn rust_presence_impl_method_is_testable() {
552        assert_eq!(
553            presence("pub struct W;\nimpl W { pub fn go(&self) -> u8 { 1 } }\n"),
554            (true, false)
555        );
556    }
557
558    #[test]
559    fn rust_presence_trait_default_is_testable_but_bare_signature_is_not() {
560        assert_eq!(
561            presence("pub trait T { fn d(&self) -> u8 { 1 } }\n"),
562            (true, false)
563        );
564        assert_eq!(
565            presence("pub trait T { fn s(&self) -> u8; }\n"),
566            (false, false)
567        );
568    }
569
570    #[test]
571    fn rust_presence_test_module_functions_are_not_subjects() {
572        // Only a test module: its functions are at test depth, so the file has no
573        // shipping subject and needs no further inline test.
574        assert_eq!(
575            presence("#[cfg(test)]\nmod tests { fn helper() {} #[test] fn t() {} }\n"),
576            (false, true)
577        );
578    }
579
580    #[test]
581    fn rust_presence_cfg_test_gated_free_fn_is_not_a_subject() {
582        // A directly `#[cfg(test)]`-gated free fn is test-only code, not a subject,
583        // and is not a `#[cfg(test)] mod`.
584        assert_eq!(
585            presence("#[cfg(test)]\nfn only_in_tests() {}\n"),
586            (false, false)
587        );
588    }
589}