stern4rust/rules/layout/
test_file_name_postfix_rule.rs1use 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
15pub 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 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 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 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}