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::rule::Rule;
12use crate::source_file::SourceFile;
13
14pub 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 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 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 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}