stern4rust/rules/layout/
tests_layout_rule.rs1use 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
14pub 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 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 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 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}