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::rule::Rule;
11use crate::source_file::SourceFile;
12
13pub 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 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 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 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}