stern4rust/rules/testing/
test_naming_rule.rs1use syn::Attribute;
6use syn::Item;
7use syn::ItemFn;
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 TestNamingRule;
33
34impl TestNamingRule {
35 pub const MINIMUM_PARTS: usize = 3;
36 pub const REGISTRIES: [&'static str; 2] = ["all_tests.rs", "mod.rs"];
37 pub const TESTS_ROOT: &'static str = "tests/";
38
39 pub fn new() -> Self {
40 Self
41 }
42
43 fn applies_to(file: &SourceFile) -> bool {
44 let path = file.relative_path();
45 path.starts_with(Self::TESTS_ROOT)
46 && !path
47 .rsplit('/')
48 .next()
49 .is_some_and(|name| Self::REGISTRIES.contains(&name))
50 }
51
52 fn is_test(attrs: &[Attribute]) -> bool {
53 attrs.iter().any(|attr| {
54 attr.path()
55 .segments
56 .last()
57 .is_some_and(|segment| segment.ident == "test")
58 })
59 }
60
61 fn offence(&self, file: &SourceFile, function: &ItemFn) -> Option<Offence> {
62 let name = function.sig.ident.to_string();
63 if name.split('_').count() >= Self::MINIMUM_PARTS {
64 return None;
65 }
66 Some(self.shape_offence(file, &name, function.sig.ident.span().start().line))
67 }
68
69 fn shape_offence(&self, file: &SourceFile, name: &str, line: usize) -> Offence {
70 Offence::new(
71 file.relative_path(),
72 line,
73 self.name(),
74 format!(
75 "`{name}` has fewer than {} parts, so it cannot say what it calls, under what \
76 conditions, and with what result",
77 Self::MINIMUM_PARTS
78 ),
79 format!(
80 "rename it `<method>_<conditions>_<result>`, starting with the method {name} calls"
81 ),
82 )
83 .with_subject(name)
84 }
85}
86
87impl Default for TestNamingRule {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl Rule for TestNamingRule {
94 fn name(&self) -> &'static str {
95 "test-naming"
96 }
97
98 fn check(&self, file: &SourceFile) -> Vec<Offence> {
99 if !Self::applies_to(file) {
100 return Vec::new();
101 }
102 let Ok(syntax) = parse_file(&file.contents()) else {
103 return Vec::new();
104 };
105 syntax
106 .items
107 .iter()
108 .filter_map(|item| match item {
109 Item::Fn(function) if Self::is_test(&function.attrs) => Some(function),
110 _ => None,
111 })
112 .filter_map(|function| self.offence(file, function))
113 .collect()
114 }
115
116 fn check_workspace(&self, _files: &[SourceFile]) -> Vec<Offence> {
117 Vec::new()
118 }
119
120 fn requirement(&self) -> Option<&'static str> {
121 None
122 }
123
124 fn is_configured(&self) -> bool {
125 true
126 }
127
128 fn explanation(&self) -> RuleExplanation {
129 RuleExplanation::new(
130 self.name(),
131 "A test is named <method>_<conditions>_<result>.",
132 "#[test]\nfn test_widget() {}",
133 "#[test]\nfn commit_without_a_quorum_returns_none() {}",
134 )
135 }
136}