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::reporting::rule_explanation::RuleExplanation;
11use crate::rule::Rule;
12use crate::source_file::SourceFile;
13
14// A test file reads top to bottom in one order: header, imports, constants,
15// helpers, tests. Each group is alphabetical, and the spacing is part of the
16// shape -- imports run together, everything else is separated by exactly one
17// blank line.
18//
19// The order is what makes a test file skimmable without reading it. Once a
20// constant sits below a helper, or a test lands between two others out of order,
21// the file has no shape and every later addition goes wherever the last one
22// happened to end.
23pub struct TestFileStructureRule;
24
25impl TestFileStructureRule {
26    pub fn new() -> Self {
27        Self
28    }
29
30    // Source files have a different shape and are not this rule's business.
31    fn applies_to(file: &SourceFile) -> bool {
32        file.relative_path().starts_with("tests/") && !Self::is_registry(file)
33    }
34
35    // all_tests.rs and mod.rs are registries, not test files. They hold nothing
36    // but `pub mod` lines, and a registry reads as a list -- demanding a blank
37    // line between each entry would make the one file whose whole job is to be
38    // scannable the hardest one to scan.
39    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    // A pair whose order rustfmt decides is not this rule's to judge. Demanding
84    // the alphabet there would make the file unsatisfiable rather than merely
85    // wrong: the formatter runs first and writes the other order back.
86    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    // Every offence in the file, not only the first. Unlike a missing header --
144    // where one report per line would bury the rest of the workspace -- these
145    // are independent facts about different items, and a reader fixing the file
146    // wants all of them.
147    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}