Skip to main content

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