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, 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        // Group files by their immediate parent directory.
35        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
97// `Path::to_path_buf` is required by the grouping above.
98use 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        // Files in `a/` and files in `a/b/` count toward
175        // separate directory totals.
176        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        // `pkg/` and `other/` each hold 3 files; only `pkg/`
192        // has the `marker.lock` ancestor, so its files count
193        // toward the cap and `pkg/` fires; `other/` is silently
194        // excluded.
195        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}