Skip to main content

stern4rust/rules/testing/
test_file_structure_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use 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::rule::Rule;
11use crate::source_file::SourceFile;
12
13// A test file reads top to bottom in one order: header, imports, constants,
14// helpers, tests. Each group is alphabetical, and the spacing is part of the
15// shape -- imports run together, everything else is separated by exactly one
16// blank line.
17//
18// The order is what makes a test file skimmable without reading it. Once a
19// constant sits below a helper, or a test lands between two others out of order,
20// the file has no shape and every later addition goes wherever the last one
21// happened to end.
22pub struct TestFileStructureRule;
23
24impl TestFileStructureRule {
25    pub fn new() -> Self {
26        Self
27    }
28
29    // Source files have a different shape and are not this rule's business.
30    fn applies_to(file: &SourceFile) -> bool {
31        file.relative_path().starts_with("tests/") && !Self::is_registry(file)
32    }
33
34    // all_tests.rs and mod.rs are registries, not test files. They hold nothing
35    // but `pub mod` lines, and a registry reads as a list -- demanding a blank
36    // line between each entry would make the one file whose whole job is to be
37    // scannable the hardest one to scan.
38    fn is_registry(file: &SourceFile) -> bool {
39        matches!(
40            file.relative_path().rsplit('/').next(),
41            Some("all_tests.rs") | Some("mod.rs")
42        )
43    }
44
45    fn section_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
46        if item.section >= previous.section {
47            return None;
48        }
49        Some(self.offence(
50            item,
51            format!(
52                "a {} follows a {}, but every {} belongs above them",
53                item.section.label(),
54                previous.section.label(),
55                item.section.label()
56            ),
57            format!(
58                "move `{}` up above the {}s",
59                item.name,
60                previous.section.label()
61            ),
62        ))
63    }
64
65    fn alphabetic_order(&self, previous: &TestFileItem, item: &TestFileItem) -> Option<Offence> {
66        if previous.section != item.section || item.sort_key() >= previous.sort_key() {
67            return None;
68        }
69        if Self::ordered_by_rustfmt(previous, item) {
70            return None;
71        }
72        Some(self.offence(
73            item,
74            format!(
75                "{} is out of alphabetic order; it follows {}",
76                item.name, previous.name
77            ),
78            format!("move `{}` above `{}`", item.name, previous.name),
79        ))
80    }
81
82    // A pair whose order rustfmt decides is not this rule's to judge. Demanding
83    // the alphabet there would make the file unsatisfiable rather than merely
84    // wrong: the formatter runs first and writes the other order back.
85    fn ordered_by_rustfmt(previous: &TestFileItem, item: &TestFileItem) -> bool {
86        item.section == Section::Imports && ImportPath::decides_order(&previous.name, &item.name)
87    }
88
89    fn spacing(
90        &self,
91        file: &SourceFile,
92        previous: &TestFileItem,
93        item: &TestFileItem,
94    ) -> Option<Offence> {
95        if previous.section != item.section {
96            return None;
97        }
98        let expected = item.section.blank_lines_between_entries();
99        let found = Self::blank_lines_between(file, previous.last_line, item.first_line);
100        if found == expected {
101            return None;
102        }
103        Some(self.offence(
104            item,
105            format!(
106                "expected {expected} blank line(s) before {} but found {found}",
107                item.name
108            ),
109            format!(
110                "leave exactly {expected} blank line(s) between `{}` and `{}`",
111                previous.name, item.name
112            ),
113        ))
114    }
115
116    fn blank_lines_between(file: &SourceFile, after: usize, before: usize) -> usize {
117        file.lines()
118            .iter()
119            .skip(after)
120            .take(before.saturating_sub(after).saturating_sub(1))
121            .filter(|line| line.trim().is_empty())
122            .count()
123    }
124
125    fn offence(&self, item: &TestFileItem, description: String, correction: String) -> Offence {
126        Offence::new("", item.first_line, self.name(), description, correction)
127            .with_subject(&item.name)
128    }
129}
130
131impl Default for TestFileStructureRule {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl Rule for TestFileStructureRule {
138    fn name(&self) -> &'static str {
139        "test-file-structure"
140    }
141
142    // Every offence in the file, not only the first. Unlike a missing header --
143    // where one report per line would bury the rest of the workspace -- these
144    // are independent facts about different items, and a reader fixing the file
145    // wants all of them.
146    fn check(&self, file: &SourceFile) -> Vec<Offence> {
147        if !Self::applies_to(file) {
148            return Vec::new();
149        }
150        let Some(items) = TestFileParser::parse(file) else {
151            return Vec::new();
152        };
153        let mut offences = Vec::new();
154        for pair in items.windows(2) {
155            let (previous, item) = (&pair[0], &pair[1]);
156            offences.extend(self.section_order(previous, item));
157            offences.extend(self.alphabetic_order(previous, item));
158            offences.extend(self.spacing(file, previous, item));
159        }
160        offences
161            .into_iter()
162            .map(|offence| Offence {
163                file: file.relative_path().to_string(),
164                ..offence
165            })
166            .collect()
167    }
168
169    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
170        Vec::new()
171    }
172
173    fn requirement(&self) -> Option<&'static str> {
174        None
175    }
176
177    fn is_configured(&self) -> bool {
178        true
179    }
180}