stern4rust/rules/
test_file_structure_rule.rs1use crate::offence::Offence;
6use crate::rule::Rule;
7use crate::source_file::SourceFile;
8use crate::test_file_item::TestFileItem;
9use crate::test_file_parser::TestFileParser;
10
11pub struct TestFileStructureRule;
21
22impl TestFileStructureRule {
23 pub fn new() -> Self {
24 Self
25 }
26
27 fn applies_to(file: &SourceFile) -> bool {
29 file.relative_path().starts_with("tests/") && !Self::is_registry(file)
30 }
31
32 fn is_registry(file: &SourceFile) -> bool {
37 matches!(
38 file.relative_path().rsplit('/').next(),
39 Some("all_tests.rs") | Some("mod.rs")
40 )
41 }
42
43 fn section_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
44 if item.section >= previous.section {
45 return None;
46 }
47 Some(self.offence(
48 item,
49 format!(
50 "a {} follows a {}, but every {} belongs above them",
51 item.section.label(),
52 previous.section.label(),
53 item.section.label()
54 ),
55 format!(
56 "move `{}` up above the {}s",
57 item.name,
58 previous.section.label()
59 ),
60 ))
61 }
62
63 fn alphabetic_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
64 if previous.section != item.section || item.sort_key() >= previous.sort_key() {
65 return None;
66 }
67 Some(self.offence(
68 item,
69 format!(
70 "{} is out of alphabetic order; it follows {}",
71 item.name, previous.name
72 ),
73 format!("move `{}` above `{}`", item.name, previous.name),
74 ))
75 }
76
77 fn spacing(
78 &self,
79 file: &SourceFile,
80 previous: &TestFileItem,
81 item: &TestFileItem,
82 ) -> Option<Offence> {
83 if previous.section != item.section {
84 return None;
85 }
86 let expected = item.section.blank_lines_between_entries();
87 let found = Self::blank_lines_between(file, previous.last_line, item.first_line);
88 if found == expected {
89 return None;
90 }
91 Some(self.offence(
92 item,
93 format!(
94 "expected {expected} blank line(s) before {} but found {found}",
95 item.name
96 ),
97 format!(
98 "leave exactly {expected} blank line(s) between `{}` and `{}`",
99 previous.name, item.name
100 ),
101 ))
102 }
103
104 fn blank_lines_between(file: &SourceFile, after: usize, before: usize) -> usize {
105 file.lines()
106 .iter()
107 .skip(after)
108 .take(before.saturating_sub(after).saturating_sub(1))
109 .filter(|line| line.trim().is_empty())
110 .count()
111 }
112
113 fn offence(&self, item: &TestFileItem, description: String, correction: String) -> Offence {
114 Offence::new("", item.first_line, self.name(), description, correction)
115 .with_subject(&item.name)
116 }
117}
118
119impl Default for TestFileStructureRule {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125impl Rule for TestFileStructureRule {
126 fn name(&self) -> &'static str {
127 "test-file-structure"
128 }
129
130 fn check(&self, file: &SourceFile) -> Vec<Offence> {
135 if !Self::applies_to(file) {
136 return Vec::new();
137 }
138 let Some(items) = TestFileParser::parse(file) else {
139 return Vec::new();
140 };
141 let mut offences = Vec::new();
142 for pair in items.windows(2) {
143 let (previous, item) = (&pair[0], &pair[1]);
144 offences.extend(self.section_order(previous, item));
145 offences.extend(self.alphabetic_order(previous, item));
146 offences.extend(self.spacing(file, previous, item));
147 }
148 offences
149 .into_iter()
150 .map(|offence| Offence {
151 file: file.relative_path().to_string(),
152 ..offence
153 })
154 .collect()
155 }
156}