Skip to main content

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