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