alint_rules/
max_files_per_directory.rs1use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, ScopeFilter, Violation};
12use serde::Deserialize;
13
14#[derive(Debug, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct Options {
17 max_files: usize,
18}
19
20#[derive(Debug)]
21pub struct MaxFilesPerDirectoryRule {
22 id: String,
23 level: Level,
24 policy_url: Option<String>,
25 message: Option<String>,
26 scope: Scope,
27 scope_filter: Option<ScopeFilter>,
28 max_files: usize,
29}
30
31impl Rule for MaxFilesPerDirectoryRule {
32 fn id(&self) -> &str {
33 &self.id
34 }
35 fn level(&self) -> Level {
36 self.level
37 }
38 fn policy_url(&self) -> Option<&str> {
39 self.policy_url.as_deref()
40 }
41
42 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
43 let mut counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
45 for entry in ctx.index.files() {
46 if !self.scope.matches(&entry.path) {
47 continue;
48 }
49 if let Some(filter) = &self.scope_filter
50 && !filter.matches(&entry.path, ctx.index)
51 {
52 continue;
53 }
54 let parent = entry
55 .path
56 .parent()
57 .map(Path::to_path_buf)
58 .unwrap_or_default();
59 *counts.entry(parent).or_insert(0) += 1;
60 }
61 let mut violations = Vec::new();
62 for (dir, count) in counts {
63 if count > self.max_files {
64 let pretty = if dir.as_os_str().is_empty() {
65 "<repo root>".to_string()
66 } else {
67 dir.display().to_string()
68 };
69 let msg = self.message.clone().unwrap_or_else(|| {
70 format!("{pretty} has {count} files; max is {}", self.max_files)
71 });
72 violations.push(Violation::new(msg).with_path(dir.clone()));
73 }
74 }
75 Ok(violations)
76 }
77
78 fn scope_filter(&self) -> Option<&ScopeFilter> {
79 self.scope_filter.as_ref()
80 }
81}
82
83pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
84 let paths = spec.paths.as_ref().ok_or_else(|| {
85 Error::rule_config(
86 &spec.id,
87 "max_files_per_directory requires a `paths` field (often `\"**\"`)",
88 )
89 })?;
90 let opts: Options = spec
91 .deserialize_options()
92 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
93 if opts.max_files == 0 {
94 return Err(Error::rule_config(
95 &spec.id,
96 "max_files_per_directory `max_files` must be > 0",
97 ));
98 }
99 if spec.fix.is_some() {
100 return Err(Error::rule_config(
101 &spec.id,
102 "max_files_per_directory has no fix op — file relocation is a human decision",
103 ));
104 }
105 Ok(Box::new(MaxFilesPerDirectoryRule {
106 id: spec.id.clone(),
107 level: spec.level,
108 policy_url: spec.policy_url.clone(),
109 message: spec.message.clone(),
110 scope: Scope::from_paths_spec(paths)?,
111 scope_filter: spec.parse_scope_filter()?,
112 max_files: opts.max_files,
113 }))
114}
115
116use std::path::Path;
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::test_support::{ctx, index, spec_yaml};
123
124 #[test]
125 fn build_rejects_missing_paths_field() {
126 let spec = spec_yaml(
127 "id: t\n\
128 kind: max_files_per_directory\n\
129 max_files: 100\n\
130 level: warning\n",
131 );
132 assert!(build(&spec).is_err());
133 }
134
135 #[test]
136 fn build_rejects_zero_max() {
137 let spec = spec_yaml(
138 "id: t\n\
139 kind: max_files_per_directory\n\
140 paths: \"**\"\n\
141 max_files: 0\n\
142 level: warning\n",
143 );
144 assert!(build(&spec).is_err());
145 }
146
147 #[test]
148 fn build_rejects_fix_block() {
149 let spec = spec_yaml(
150 "id: t\n\
151 kind: max_files_per_directory\n\
152 paths: \"**\"\n\
153 max_files: 100\n\
154 level: warning\n\
155 fix:\n \
156 file_remove: {}\n",
157 );
158 assert!(build(&spec).is_err());
159 }
160
161 #[test]
162 fn evaluate_passes_under_limit() {
163 let spec = spec_yaml(
164 "id: t\n\
165 kind: max_files_per_directory\n\
166 paths: \"**\"\n\
167 max_files: 5\n\
168 level: warning\n",
169 );
170 let rule = build(&spec).unwrap();
171 let idx = index(&["a/1.rs", "a/2.rs", "a/3.rs"]);
172 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
173 assert!(v.is_empty());
174 }
175
176 #[test]
177 fn evaluate_fires_on_over_limit_directory() {
178 let spec = spec_yaml(
179 "id: t\n\
180 kind: max_files_per_directory\n\
181 paths: \"**\"\n\
182 max_files: 2\n\
183 level: warning\n",
184 );
185 let rule = build(&spec).unwrap();
186 let idx = index(&["a/1.rs", "a/2.rs", "a/3.rs", "b/1.rs"]);
187 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
188 assert_eq!(v.len(), 1, "only `a/` exceeds: {v:?}");
189 }
190
191 #[test]
192 fn evaluate_groups_by_immediate_parent() {
193 let spec = spec_yaml(
196 "id: t\n\
197 kind: max_files_per_directory\n\
198 paths: \"**\"\n\
199 max_files: 2\n\
200 level: warning\n",
201 );
202 let rule = build(&spec).unwrap();
203 let idx = index(&["a/1.rs", "a/2.rs", "a/b/1.rs", "a/b/2.rs"]);
204 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
205 assert!(v.is_empty(), "neither dir exceeds: {v:?}");
206 }
207
208 #[test]
209 fn scope_filter_narrows() {
210 let spec = spec_yaml(
215 "id: t\n\
216 kind: max_files_per_directory\n\
217 paths: \"**/*.rs\"\n\
218 max_files: 2\n\
219 scope_filter:\n \
220 has_ancestor: marker.lock\n\
221 level: warning\n",
222 );
223 let rule = build(&spec).unwrap();
224 let idx = index(&[
225 "pkg/marker.lock",
226 "pkg/1.rs",
227 "pkg/2.rs",
228 "pkg/3.rs",
229 "other/1.rs",
230 "other/2.rs",
231 "other/3.rs",
232 ]);
233 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
234 assert_eq!(v.len(), 1, "only `pkg/` should fire: {v:?}");
235 assert!(
236 v[0].path.as_deref().is_some_and(|p| p == Path::new("pkg")),
237 "unexpected path: {:?}",
238 v[0].path
239 );
240 }
241}