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    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        // Group files by their immediate parent directory.
43        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
105// `Path::to_path_buf` is required by the grouping above.
106use 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        // Files in `a/` and files in `a/b/` count toward
183        // separate directory totals.
184        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        // `pkg/` and `other/` each hold 3 files; only `pkg/`
200        // has the `marker.lock` ancestor, so its files count
201        // toward the cap and `pkg/` fires; `other/` is silently
202        // excluded.
203        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}