Skip to main content

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