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