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