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::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}