alint_rules/
filename_case.rs1use alint_core::{Context, Error, FixSpec, Fixer, Level, Result, Rule, RuleSpec, Scope, Violation};
10use serde::Deserialize;
11
12use crate::case::CaseConvention;
13use crate::fixers::FileRenameFixer;
14
15#[derive(Debug, Deserialize)]
16#[serde(deny_unknown_fields)]
17struct Options {
18 case: CaseConvention,
19}
20
21#[derive(Debug)]
22pub struct FilenameCaseRule {
23 id: String,
24 level: Level,
25 policy_url: Option<String>,
26 message: Option<String>,
27 scope: Scope,
28 case: CaseConvention,
29 fixer: Option<FileRenameFixer>,
30}
31
32impl Rule for FilenameCaseRule {
33 alint_core::rule_common_impl!();
34
35 fn fixer(&self) -> Option<&dyn Fixer> {
36 self.fixer.as_ref().map(|f| f as &dyn Fixer)
37 }
38
39 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
40 let mut violations = Vec::new();
41 for entry in ctx.index.files() {
42 if !self.scope.matches(&entry.path, ctx.index) {
43 continue;
44 }
45 let Some(stem) = entry.path.file_stem().and_then(|s| s.to_str()) else {
46 continue;
47 };
48 if !self.case.check(stem) {
49 let msg = self.message.clone().unwrap_or_else(|| {
50 format!(
51 "filename stem {:?} is not {}",
52 stem,
53 self.case.display_name()
54 )
55 });
56 violations.push(Violation::new(msg).with_path(entry.path.clone()));
57 }
58 }
59 Ok(violations)
60 }
61}
62
63pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
64 let Some(_paths) = &spec.paths else {
65 return Err(Error::rule_config(
66 &spec.id,
67 "filename_case requires a `paths` field",
68 ));
69 };
70 let opts: Options = spec
71 .deserialize_options()
72 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
73 let fixer = match &spec.fix {
74 Some(FixSpec::FileRename { .. }) => Some(FileRenameFixer::new(opts.case)),
75 Some(other) => {
76 return Err(Error::rule_config(
77 &spec.id,
78 format!(
79 "fix.{} is not compatible with filename_case",
80 other.op_name()
81 ),
82 ));
83 }
84 None => None,
85 };
86 Ok(Box::new(FilenameCaseRule {
87 id: spec.id.clone(),
88 level: spec.level,
89 policy_url: spec.policy_url.clone(),
90 message: spec.message.clone(),
91 scope: Scope::from_spec(spec)?,
92 case: opts.case,
93 fixer,
94 }))
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::test_support::{ctx, index, spec_yaml};
101 use std::path::Path;
102
103 #[test]
104 fn build_rejects_missing_paths_field() {
105 let spec = spec_yaml(
106 "id: t\n\
107 kind: filename_case\n\
108 case: snake_case\n\
109 level: error\n",
110 );
111 let err = build(&spec).unwrap_err().to_string();
112 assert!(err.contains("paths"), "unexpected: {err}");
113 }
114
115 #[test]
116 fn build_rejects_missing_case_option() {
117 let spec = spec_yaml(
118 "id: t\n\
119 kind: filename_case\n\
120 paths: \"src/**/*.rs\"\n\
121 level: error\n",
122 );
123 assert!(build(&spec).is_err(), "missing `case:` should error");
124 }
125
126 #[test]
127 fn build_rejects_incompatible_fix_op() {
128 let spec = spec_yaml(
129 "id: t\n\
130 kind: filename_case\n\
131 paths: \"src/**/*.rs\"\n\
132 case: snake_case\n\
133 level: error\n\
134 fix:\n \
135 file_remove: {}\n",
136 );
137 let err = build(&spec).unwrap_err().to_string();
138 assert!(err.contains("file_remove"), "unexpected: {err}");
139 }
140
141 #[test]
142 fn evaluate_passes_on_canonical_snake_case() {
143 let spec = spec_yaml(
144 "id: t\n\
145 kind: filename_case\n\
146 paths: \"src/**/*.rs\"\n\
147 case: snake_case\n\
148 level: error\n",
149 );
150 let rule = build(&spec).unwrap();
151 let idx = index(&["src/main.rs", "src/lib.rs", "src/sub/mod.rs"]);
152 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
153 assert!(v.is_empty(), "unexpected: {v:?}");
154 }
155
156 #[test]
157 fn evaluate_fires_on_pascal_case_when_snake_required() {
158 let spec = spec_yaml(
159 "id: t\n\
160 kind: filename_case\n\
161 paths: \"src/**/*.rs\"\n\
162 case: snake_case\n\
163 level: error\n",
164 );
165 let rule = build(&spec).unwrap();
166 let idx = index(&["src/MainModule.rs"]);
167 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
168 assert_eq!(v.len(), 1);
169 }
170
171 #[test]
172 fn evaluate_skips_files_outside_scope() {
173 let spec = spec_yaml(
174 "id: t\n\
175 kind: filename_case\n\
176 paths: \"src/**/*.rs\"\n\
177 case: snake_case\n\
178 level: error\n",
179 );
180 let rule = build(&spec).unwrap();
181 let idx = index(&["docs/MainDoc.md"]);
183 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
184 assert!(v.is_empty(), "out-of-scope shouldn't fire: {v:?}");
185 }
186
187 #[test]
188 fn pascal_case_matches_canonical_components() {
189 let spec = spec_yaml(
190 "id: t\n\
191 kind: filename_case\n\
192 paths: \"components/**/*.tsx\"\n\
193 case: PascalCase\n\
194 level: error\n",
195 );
196 let rule = build(&spec).unwrap();
197 let idx = index(&[
198 "components/Button.tsx",
199 "components/UserCard.tsx",
200 "components/bad_name.tsx",
201 ]);
202 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
203 assert_eq!(v.len(), 1, "only `bad_name` should fire");
204 }
205
206 #[test]
207 fn scope_filter_narrows() {
208 let spec = spec_yaml(
212 "id: t\n\
213 kind: filename_case\n\
214 paths: \"**/*.rs\"\n\
215 case: snake_case\n\
216 scope_filter:\n \
217 has_ancestor: marker.lock\n\
218 level: error\n",
219 );
220 let rule = build(&spec).unwrap();
221 let idx = index(&["pkg/marker.lock", "pkg/BadName.rs", "other/AlsoBad.rs"]);
222 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
223 assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
224 assert_eq!(v[0].path.as_deref(), Some(Path::new("pkg/BadName.rs")));
225 }
226}