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) {
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));
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_paths_spec(paths)?,
101        max_files: opts.max_files,
102    }))
103}
104
105// `Path::to_path_buf` is required by the grouping above.
106use std::path::Path;