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