Skip to main content

alint_rules/
max_files_per_directory.rs

1//! `max_files_per_directory` — cap how many files may live
2//! directly under any directory in scope (non-recursive).
3//!
4//! Useful for monorepos that want to force sharding — "no more
5//! than 200 files in `packages/`", etc. Reports one violation
6//! per overlong directory, with the overflow count.
7
8use 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        // Group files by their immediate parent directory.
44        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
116// `Path::to_path_buf` is required by the grouping above.
117use 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        // Files in `a/` and files in `a/b/` count toward
194        // separate directory totals.
195        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        // `pkg/` and `other/` each hold 3 files; only `pkg/`
211        // has the `marker.lock` ancestor, so its files count
212        // toward the cap and `pkg/` fires; `other/` is silently
213        // excluded.
214        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}