Skip to main content

stern4rust/rules/layout/
test_file_name_postfix_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use syn::Attribute;
6use syn::Item;
7use syn::ItemMod;
8use syn::parse_file;
9
10use crate::reporting::offence::Offence;
11use crate::reporting::rule_explanation::RuleExplanation;
12use crate::rule::Rule;
13use crate::source_file::SourceFile;
14
15// A file that holds tests is named for it: `<X>_tests.rs`.
16//
17// The name is what pairs a test file with the source file it exercises. That
18// pairing is the whole basis of the mirrored layout -- `src/foo.rs` answering to
19// `tests/foo_tests.rs` -- and a file holding tests under any other name is
20// outside it: nothing points at it from the source side, and no tool checking
21// the pair can see that it is the test file somebody wrote.
22//
23// One direction only. Holding a test obliges the name; a `_tests.rs` file
24// holding none is a different question, and a separate rule if it ever earns
25// one.
26//
27// The two exemptions do real work rather than softening the rule.
28//
29// `src/` is exempt because a `#[test]` there is already `test-free-source`'s
30// offence, and the correction here would be **wrong**: renaming `src/foo.rs` to
31// `src/foo_tests.rs` leaves the test exactly where it does not belong. That file
32// has to move, not be renamed.
33//
34// Registries are exempt for the same reason from the other side. A `#[test]` in
35// an `all_tests.rs` or a `mod.rs` is already `tests-layout`'s offence, and
36// `mod.rs` cannot be renamed at all -- the correction would be impossible to
37// follow.
38pub struct TestFileNamePostfixRule;
39
40impl TestFileNamePostfixRule {
41    pub const POSTFIX: &'static str = "_tests.rs";
42    pub const REGISTRIES: [&'static str; 2] = ["all_tests.rs", "mod.rs"];
43    pub const TESTS_ROOT: &'static str = "tests/";
44
45    pub fn new() -> Self {
46        Self
47    }
48
49    fn applies_to(file: &SourceFile) -> bool {
50        let path = file.relative_path();
51        path.starts_with(Self::TESTS_ROOT)
52            && !path
53                .rsplit('/')
54                .next()
55                .is_some_and(|name| Self::REGISTRIES.contains(&name))
56    }
57
58    // Descends into inline modules: a test does not stop being a test for
59    // sitting one level down, and the file still holds it.
60    fn tests_in(items: &[Item]) -> usize {
61        items
62            .iter()
63            .map(|item| match item {
64                Item::Fn(function) if Self::is_test(&function.attrs) => 1,
65                Item::Mod(module) => Self::inside(module).map(Self::tests_in).unwrap_or_default(),
66                _ => 0,
67            })
68            .sum()
69    }
70
71    fn inside(module: &ItemMod) -> Option<&[Item]> {
72        module.content.as_ref().map(|(_, items)| items.as_slice())
73    }
74
75    // The last segment is what says so, which is what lets `#[tokio::test]`
76    // count without naming any runtime here.
77    fn is_test(attrs: &[Attribute]) -> bool {
78        attrs.iter().any(|attr| {
79            attr.path()
80                .segments
81                .last()
82                .is_some_and(|segment| segment.ident == "test")
83        })
84    }
85
86    fn suggested_name(relative_path: &str) -> String {
87        let stem = relative_path
88            .strip_suffix(".rs")
89            .unwrap_or(relative_path)
90            .to_string();
91        format!("{stem}{}", Self::POSTFIX)
92    }
93
94    // Reported at line 1: the offence is the file's name, not any one test in
95    // it. The count is named as the evidence for calling it a test file.
96    fn offence(&self, file: &SourceFile, found: usize) -> Offence {
97        let path = file.relative_path();
98        let suggested = Self::suggested_name(path);
99        Offence::new(
100            path,
101            1,
102            self.name(),
103            format!(
104                "{path} holds {found} test(s) but its name does not end in `{}`, so nothing \
105                 pairs it with the source file it exercises",
106                Self::POSTFIX
107            ),
108            format!("rename it `{suggested}`"),
109        )
110        .with_subject(path)
111        .with_expected(&suggested)
112    }
113}
114
115impl Default for TestFileNamePostfixRule {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl Rule for TestFileNamePostfixRule {
122    fn name(&self) -> &'static str {
123        "test-file-name-postfix"
124    }
125
126    fn check(&self, file: &SourceFile) -> Vec<Offence> {
127        if !Self::applies_to(file) || file.relative_path().ends_with(Self::POSTFIX) {
128            return Vec::new();
129        }
130        let Ok(syntax) = parse_file(&file.contents()) else {
131            return Vec::new();
132        };
133        match Self::tests_in(&syntax.items) {
134            0 => Vec::new(),
135            found => vec![self.offence(file, found)],
136        }
137    }
138
139    fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
140        Vec::new()
141    }
142
143    fn requirement(&self) -> Option<&'static str> {
144        None
145    }
146
147    fn is_configured(&self) -> bool {
148        true
149    }
150
151    fn explanation(&self) -> RuleExplanation {
152        RuleExplanation::new(
153            self.name(),
154            "A file that holds tests is named for it: <X>_tests.rs.",
155            "tests/widget_spec.rs",
156            "tests/widget_tests.rs",
157        )
158    }
159}