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::registry_parser::RegistryParser;
8use crate::finding::registry_policy::RegistryPolicy;
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 is_registry(file: &SourceFile) -> bool {
135        let path = file.relative_path();
136        path == "tests/all_tests.rs" || path.ends_with("/mod.rs")
137    }
138
139    // Each stray reported at its own line, named. "Something in this file is not
140    // a declaration" is true of the whole file and actionable nowhere in it.
141    fn declarations_only(&self, file: &SourceFile) -> Vec<Offence> {
142        RegistryParser::strays(file, RegistryPolicy::tests())
143            .unwrap_or_default()
144            .into_iter()
145            .map(|stray| {
146                Offence::new(
147                    file.relative_path(),
148                    stray.line,
149                    self.name(),
150                    format!(
151                        "{} does not belong in a registry, which holds the header \
152                         and pub mod declarations only",
153                        stray.label
154                    ),
155                    format!(
156                        "move {} out of the registry into the file that needs it",
157                        stray.label
158                    ),
159                )
160                .with_subject(&stray.label)
161            })
162            .collect()
163    }
164}
165
166impl Default for TestsLayoutRule {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl Rule for TestsLayoutRule {
173    fn name(&self) -> &'static str {
174        "tests-layout"
175    }
176
177    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
178        Vec::new()
179    }
180
181    fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
182        let present = Self::in_tests(files);
183        if present.is_empty() {
184            return Vec::new();
185        }
186        let mut offences = self.missing_door(&present);
187        offences.extend(self.stray_doors(&present));
188        offences.extend(self.missing_mod_files(&present));
189        offences.extend(self.registry_contents(&present));
190        offences
191    }
192
193    fn is_configured(&self) -> bool {
194        true
195    }
196}