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