stern4rust/rules/testing/
test_file_structure_rule.rs1use crate::finding::model::import_path::ImportPath;
6use crate::finding::model::section::Section;
7use crate::finding::model::test_file_item::TestFileItem;
8use crate::finding::parsing::test_file_parser::TestFileParser;
9use crate::reporting::offence::Offence;
10use crate::reporting::rule_explanation::RuleExplanation;
11use crate::rule::Rule;
12use crate::source_file::SourceFile;
13
14pub struct TestFileStructureRule;
24
25impl TestFileStructureRule {
26 pub fn new() -> Self {
27 Self
28 }
29
30 fn applies_to(file: &SourceFile) -> bool {
32 file.relative_path().starts_with("tests/") && !Self::is_registry(file)
33 }
34
35 fn is_registry(file: &SourceFile) -> bool {
40 matches!(
41 file.relative_path().rsplit('/').next(),
42 Some("all_tests.rs") | Some("mod.rs")
43 )
44 }
45
46 fn section_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
47 if item.section >= previous.section {
48 return None;
49 }
50 Some(self.offence(
51 item,
52 format!(
53 "a {} follows a {}, but every {} belongs above them",
54 item.section.label(),
55 previous.section.label(),
56 item.section.label()
57 ),
58 format!(
59 "move `{}` up above the {}s",
60 item.name,
61 previous.section.label()
62 ),
63 ))
64 }
65
66 fn alphabetic_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
67 if previous.section != item.section || item.sort_key() >= previous.sort_key() {
68 return None;
69 }
70 if Self::ordered_by_rustfmt(previous, item) {
71 return None;
72 }
73 Some(self.offence(
74 item,
75 format!(
76 "{} is out of alphabetic order; it follows {}",
77 item.name, previous.name
78 ),
79 format!("move `{}` above `{}`", item.name, previous.name),
80 ))
81 }
82
83 fn ordered_by_rustfmt(previous: &TestFileItem, item: &TestFileItem) -> bool {
87 item.section == Section::Imports && ImportPath::decides_order(&previous.name, &item.name)
88 }
89
90 fn spacing(
91 &self,
92 file: &SourceFile,
93 previous: &TestFileItem,
94 item: &TestFileItem,
95 ) -> Option<Offence> {
96 if previous.section != item.section {
97 return None;
98 }
99 let expected = item.section.blank_lines_between_entries();
100 let found = Self::blank_lines_between(file, previous.last_line, item.first_line);
101 if found == expected {
102 return None;
103 }
104 Some(self.offence(
105 item,
106 format!(
107 "expected {expected} blank line(s) before {} but found {found}",
108 item.name
109 ),
110 format!(
111 "leave exactly {expected} blank line(s) between `{}` and `{}`",
112 previous.name, item.name
113 ),
114 ))
115 }
116
117 fn blank_lines_between(file: &SourceFile, after: usize, before: usize) -> usize {
118 file.lines()
119 .iter()
120 .skip(after)
121 .take(before.saturating_sub(after).saturating_sub(1))
122 .filter(|line| line.trim().is_empty())
123 .count()
124 }
125
126 fn offence(&self, item: &TestFileItem, description: String, correction: String) -> Offence {
127 Offence::new("", item.first_line, self.name(), description, correction)
128 .with_subject(&item.name)
129 }
130}
131
132impl Default for TestFileStructureRule {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138impl Rule for TestFileStructureRule {
139 fn name(&self) -> &'static str {
140 "test-file-structure"
141 }
142
143 fn check(&self, file: &SourceFile) -> Vec<Offence> {
148 if !Self::applies_to(file) {
149 return Vec::new();
150 }
151 let Some(items) = TestFileParser::parse(file) else {
152 return Vec::new();
153 };
154 let mut offences = Vec::new();
155 for pair in items.windows(2) {
156 let (previous, item) = (&pair[0], &pair[1]);
157 offences.extend(self.section_order(previous, item));
158 offences.extend(self.alphabetic_order(previous, item));
159 offences.extend(self.spacing(file, previous, item));
160 }
161 offences
162 .into_iter()
163 .map(|offence| Offence {
164 file: file.relative_path().to_string(),
165 ..offence
166 })
167 .collect()
168 }
169
170 fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
171 Vec::new()
172 }
173
174 fn requirement(&self) -> Option<&'static str> {
175 None
176 }
177
178 fn is_configured(&self) -> bool {
179 true
180 }
181
182 fn explanation(&self) -> RuleExplanation {
183 RuleExplanation::new(
184 self.name(),
185 "A test file reads header, imports, constants, helpers, tests -- each group alphabetical.",
186 "#[test]\nfn zeta() {}\n\nuse std::fs;\n\n#[test]\nfn alpha() {}",
187 "use std::fs;\n\n#[test]\nfn alpha() {}\n\n#[test]\nfn zeta() {}",
188 )
189 }
190}