stern4rust/rules/layout/paired_test_file_rule.rs
1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::collections::BTreeSet;
6
7use crate::reporting::offence::Offence;
8use crate::rule::Rule;
9use crate::source_file::SourceFile;
10
11// A `<X>_tests.rs` names the source file it exercises, and that file exists.
12//
13// This is the other half of the mirrored pairing, and the half nothing checked.
14// twin4rust starts at a source file and looks for its test, so it reports a
15// source file with no tests. Nothing started at a test file and asked whether
16// the source it is named after still exists -- and a test file outlives the
17// module it was named for silently, because it still compiles, still runs, and
18// still passes.
19//
20// The failure is a reader's, not the compiler's: somebody looking for the tests
21// of `retention_window.rs` finds `retention_window_proptest_tests.rs` and never
22// learns that `retention_tests.rs` holds seven more.
23//
24// `_proptest_tests.rs` is exempt. It is a second suite for a module it does not
25// name, so its stem resolves to a file nobody ever meant to write. The pairing
26// question cannot be asked of it, and asking anyway produced three wrong answers
27// out of seven when this was measured.
28//
29// The rule assumes the package is mirrored. A harness crate -- one whose `src/`
30// is apparatus and whose `tests/` are scenarios named after behaviours rather
31// than files -- is not, and every one of its test files would be reported.
32// `--skip paired-test-file` is the answer there, which is what rule selection is
33// for.
34pub struct PairedTestFileRule;
35
36impl PairedTestFileRule {
37 pub const PROPTEST_POSTFIX: &'static str = "_proptest_tests.rs";
38 pub const REGISTRY: &'static str = "all_tests.rs";
39 pub const SOURCE_ROOT: &'static str = "src/";
40 pub const TESTS_POSTFIX: &'static str = "_tests.rs";
41 pub const TESTS_ROOT: &'static str = "tests/";
42
43 pub fn new() -> Self {
44 Self
45 }
46
47 fn is_test_file(path: &str) -> bool {
48 path.starts_with(Self::TESTS_ROOT)
49 && path.ends_with(Self::TESTS_POSTFIX)
50 && !path.ends_with(Self::PROPTEST_POSTFIX)
51 && Self::file_name(path) != Self::REGISTRY
52 }
53
54 fn file_name(path: &str) -> &str {
55 path.rsplit('/').next().unwrap_or(path)
56 }
57
58 // tests/a/b_tests.rs pairs with src/a/b.rs. By path rather than by name, so
59 // a test file in the wrong directory is as unpaired as one whose source is
60 // gone -- both leave a reader looking in the wrong place.
61 fn source_of(path: &str) -> String {
62 let without_root = path.strip_prefix(Self::TESTS_ROOT).unwrap_or(path);
63 let stem = without_root
64 .strip_suffix(Self::TESTS_POSTFIX)
65 .unwrap_or(without_root);
66 format!("{}{stem}.rs", Self::SOURCE_ROOT)
67 }
68
69 fn present(files: &[SourceFile]) -> BTreeSet<&str> {
70 files
71 .iter()
72 .map(SourceFile::relative_path)
73 .filter(|path| path.starts_with(Self::SOURCE_ROOT))
74 .collect()
75 }
76
77 // The correction does not say "create the missing file". Measured across a
78 // real tree, every unpaired file tested something real under a name that had
79 // drifted, so the file to create is never the answer -- the name is what is
80 // wrong, or the tests have outlived their subject.
81 fn offence(&self, path: &str, expected: &str) -> Offence {
82 Offence::new(
83 path,
84 1,
85 self.name(),
86 format!("{path} is named for {expected}, which does not exist"),
87 "rename it after the source file it exercises, or delete it if that file is gone"
88 .to_string(),
89 )
90 .with_subject(path)
91 .with_expected(expected)
92 }
93}
94
95impl Default for PairedTestFileRule {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl Rule for PairedTestFileRule {
102 fn name(&self) -> &'static str {
103 "paired-test-file"
104 }
105
106 fn check(&self, _file: &SourceFile) -> Vec<Offence> {
107 Vec::new()
108 }
109
110 // A fact about the tree: the file that proves the offence is the source file
111 // that is not there, so there is nothing for check() to be handed.
112 fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
113 let present = Self::present(files);
114 files
115 .iter()
116 .map(SourceFile::relative_path)
117 .filter(|path| Self::is_test_file(path))
118 .filter_map(|path| {
119 let expected = Self::source_of(path);
120 (!present.contains(expected.as_str())).then(|| self.offence(path, &expected))
121 })
122 .collect()
123 }
124
125 fn requirement(&self) -> Option<&'static str> {
126 None
127 }
128
129 fn is_configured(&self) -> bool {
130 true
131 }
132}