Skip to main content

stern4rust/rules/layout/
tests_layout_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::finding::model::registry_policy::RegistryPolicy;
8use crate::finding::parsing::registry_parser::RegistryParser;
9use crate::reporting::offence::Offence;
10use crate::reporting::rule_explanation::RuleExplanation;
11use crate::rule::Rule;
12use crate::source_file::SourceFile;
13
14// A tests folder is reached through exactly one door -- `tests/all_tests.rs` --
15// and a `mod.rs` in every subfolder below it.
16//
17// Miss one and the files beneath it are not compiled at all. They still exist,
18// still look like tests, and are still counted by anyone reading the directory,
19// but nothing runs them. That is the failure this rule exists for, and it is
20// silent by construction: a test that is never compiled cannot fail.
21//
22// Both registry files hold nothing but the header and `pub mod` declarations.
23// Anything else in them is logic living in the one file a reader scans expecting
24// a list.
25pub struct TestsLayoutRule;
26
27impl TestsLayoutRule {
28    pub const ROOT: &'static str = "tests/";
29
30    pub fn new() -> Self {
31        Self
32    }
33
34    fn in_tests(files: &[SourceFile]) -> Vec<&SourceFile> {
35        files
36            .iter()
37            .filter(|file| file.relative_path().starts_with(Self::ROOT))
38            .collect()
39    }
40
41    fn missing_door(&self, present: &[&SourceFile]) -> Vec<Offence> {
42        if present
43            .iter()
44            .any(|file| file.relative_path() == "tests/all_tests.rs")
45        {
46            return Vec::new();
47        }
48        vec![
49            Offence::new(
50                "tests/all_tests.rs",
51                1,
52                self.name(),
53                "a tests folder is present but has no all_tests.rs, so nothing in it \
54             is compiled"
55                    .to_string(),
56                "create tests/all_tests.rs with the header and one `pub mod` line per \
57                 file in tests/"
58                    .to_string(),
59            )
60            .with_subject("tests/all_tests.rs"),
61        ]
62    }
63
64    // A second one below the top is not a door; it is a file with a misleading
65    // name that no `pub mod` will ever point at.
66    fn stray_doors(&self, present: &[&SourceFile]) -> Vec<Offence> {
67        present
68            .iter()
69            .filter(|file| {
70                file.relative_path().ends_with("/all_tests.rs")
71                    && file.relative_path() != "tests/all_tests.rs"
72            })
73            .map(|file| {
74                Offence::new(
75                    file.relative_path(),
76                    1,
77                    self.name(),
78                    "only tests/all_tests.rs is a registry; this one is never \
79                     reached"
80                        .to_string(),
81                    "rename it to mod.rs, or delete it and declare its contents from \
82                     tests/all_tests.rs"
83                        .to_string(),
84                )
85                .with_subject(file.relative_path())
86            })
87            .collect()
88    }
89
90    fn missing_mod_files(&self, present: &[&SourceFile]) -> Vec<Offence> {
91        let existing: BTreeSet<&str> = present.iter().map(|file| file.relative_path()).collect();
92        Self::subfolders(present)
93            .into_iter()
94            .map(|folder| format!("{folder}/mod.rs"))
95            .filter(|expected| !existing.contains(expected.as_str()))
96            .map(|expected| {
97                Offence::new(
98                    &expected,
99                    1,
100                    self.name(),
101                    "a tests subfolder has no mod.rs, so nothing in it is compiled".to_string(),
102                    format!(
103                        "create {expected} with the header and one `pub mod` line per \
104                         file in that folder"
105                    ),
106                )
107                .with_subject(&expected)
108            })
109            .collect()
110    }
111
112    // Every folder on the way down, not only the ones holding a file. An
113    // intermediate folder is a folder too, and a missing mod.rs there hides
114    // everything beneath it just as completely.
115    fn subfolders(present: &[&SourceFile]) -> BTreeSet<String> {
116        let mut folders = BTreeSet::new();
117        for file in present {
118            let mut parts: Vec<&str> = file.relative_path().split('/').collect();
119            parts.pop();
120            for depth in 2..=parts.len() {
121                folders.insert(parts[..depth].join("/"));
122            }
123        }
124        folders
125    }
126
127    fn registry_contents(&self, present: &[&SourceFile]) -> Vec<Offence> {
128        present
129            .iter()
130            .filter(|file| Self::is_registry(file))
131            .flat_map(|file| self.declarations_only(file))
132            .collect()
133    }
134
135    fn unchecked(&self, file: &SourceFile) -> Offence {
136        Offence::new(
137            file.relative_path(),
138            1,
139            self.name(),
140            format!(
141                "{} could not be parsed, so its contents were not checked",
142                file.relative_path()
143            ),
144            "correct the syntax error readable-source reports, so this registry can be checked"
145                .to_string(),
146        )
147        .with_subject(file.relative_path())
148    }
149
150    fn is_registry(file: &SourceFile) -> bool {
151        let path = file.relative_path();
152        path == "tests/all_tests.rs" || path.ends_with("/mod.rs")
153    }
154
155    // Each stray reported at its own line, named. "Something in this file is not
156    // a declaration" is true of the whole file and actionable nowhere in it.
157    // A registry that does not parse is reported as unchecked rather than
158    // passed over. `readable-source` names the file, but this rule's own answer
159    // would otherwise be simply absent -- indistinguishable from a registry it
160    // had read and found clean, which is the silence this tool refuses.
161    //
162    // One offence, not one per sibling: treating an unparseable registry as
163    // declaring nothing is the page of wrong answers R009 rejected.
164    fn declarations_only(&self, file: &SourceFile) -> Vec<Offence> {
165        let Some(strays) = RegistryParser::strays(file, RegistryPolicy::tests()) else {
166            return vec![self.unchecked(file)];
167        };
168        strays
169            .into_iter()
170            .map(|stray| {
171                Offence::new(
172                    file.relative_path(),
173                    stray.line,
174                    self.name(),
175                    format!(
176                        "{} does not belong in a registry, which holds the header \
177                         and pub mod declarations only",
178                        stray.label
179                    ),
180                    format!(
181                        "move {} out of the registry into the file that needs it",
182                        stray.label
183                    ),
184                )
185                .with_subject(&stray.label)
186            })
187            .collect()
188    }
189}
190
191impl Default for TestsLayoutRule {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197impl Rule for TestsLayoutRule {
198    fn name(&self) -> &'static str {
199        "tests-layout"
200    }
201
202    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
203        Vec::new()
204    }
205
206    fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
207        let present = Self::in_tests(files);
208        if present.is_empty() {
209            return Vec::new();
210        }
211        let mut offences = self.missing_door(&present);
212        offences.extend(self.stray_doors(&present));
213        offences.extend(self.missing_mod_files(&present));
214        offences.extend(self.registry_contents(&present));
215        offences
216    }
217
218    fn requirement(&self) -> Option<&'static str> {
219        None
220    }
221
222    fn is_configured(&self) -> bool {
223        true
224    }
225
226    fn explanation(&self) -> RuleExplanation {
227        RuleExplanation::new(
228            self.name(),
229            "A tests folder is reached through one door -- tests/all_tests.rs -- and a mod.rs below it.",
230            "tests/all_tests.rs\nextern crate alloc;\npub mod widget_tests;",
231            "tests/all_tests.rs\npub mod widget_tests;",
232        )
233    }
234}