Skip to main content

alint_rules/
max_directory_depth.rs

1//! `max_directory_depth` — cap the depth of any path in scope.
2//!
3//! Depth is the number of `/`-separated components in the path.
4//! `README.md` is depth 1; `src/lib.rs` is depth 2; `a/b/c/d.rs`
5//! is depth 4. Flags one violation per path that exceeds the cap.
6//!
7//! Check-only: moving files around to flatten the tree isn't a
8//! decision alint can make automatically.
9
10use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, ScopeFilter, Violation};
11use serde::Deserialize;
12
13#[derive(Debug, Deserialize)]
14#[serde(deny_unknown_fields)]
15struct Options {
16    max_depth: usize,
17}
18
19#[derive(Debug)]
20pub struct MaxDirectoryDepthRule {
21    id: String,
22    level: Level,
23    policy_url: Option<String>,
24    message: Option<String>,
25    scope: Scope,
26    scope_filter: Option<ScopeFilter>,
27    max_depth: usize,
28}
29
30impl Rule for MaxDirectoryDepthRule {
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        let mut violations = Vec::new();
43        for entry in ctx.index.files() {
44            if !self.scope.matches(&entry.path) {
45                continue;
46            }
47            if let Some(filter) = &self.scope_filter
48                && !filter.matches(&entry.path, ctx.index)
49            {
50                continue;
51            }
52            let depth = entry.path.components().count();
53            if depth > self.max_depth {
54                let msg = self.message.clone().unwrap_or_else(|| {
55                    format!(
56                        "{} is at depth {depth}; max is {}",
57                        entry.path.display(),
58                        self.max_depth
59                    )
60                });
61                violations.push(Violation::new(msg).with_path(entry.path.clone()));
62            }
63        }
64        Ok(violations)
65    }
66
67    fn scope_filter(&self) -> Option<&ScopeFilter> {
68        self.scope_filter.as_ref()
69    }
70}
71
72pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
73    let paths = spec.paths.as_ref().ok_or_else(|| {
74        Error::rule_config(
75            &spec.id,
76            "max_directory_depth requires a `paths` field (often `\"**\"`)",
77        )
78    })?;
79    let opts: Options = spec
80        .deserialize_options()
81        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
82    if opts.max_depth == 0 {
83        return Err(Error::rule_config(
84            &spec.id,
85            "max_directory_depth `max_depth` must be > 0",
86        ));
87    }
88    if spec.fix.is_some() {
89        return Err(Error::rule_config(
90            &spec.id,
91            "max_directory_depth has no fix op — moving files is a human decision",
92        ));
93    }
94    Ok(Box::new(MaxDirectoryDepthRule {
95        id: spec.id.clone(),
96        level: spec.level,
97        policy_url: spec.policy_url.clone(),
98        message: spec.message.clone(),
99        scope: Scope::from_paths_spec(paths)?,
100        scope_filter: spec.parse_scope_filter()?,
101        max_depth: opts.max_depth,
102    }))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::test_support::{ctx, index, spec_yaml};
109    use std::path::{Path, PathBuf};
110
111    fn depth_of(s: &str) -> usize {
112        PathBuf::from(s).components().count()
113    }
114
115    #[test]
116    fn depth_counts_components() {
117        assert_eq!(depth_of("README.md"), 1);
118        assert_eq!(depth_of("src/lib.rs"), 2);
119        assert_eq!(depth_of("a/b/c/d.rs"), 4);
120    }
121
122    #[test]
123    fn scope_filter_narrows() {
124        // Two too-deep files; only the one inside a directory
125        // with `marker.lock` as ancestor should fire.
126        let spec = spec_yaml(
127            "id: t\n\
128             kind: max_directory_depth\n\
129             paths: \"**\"\n\
130             max_depth: 2\n\
131             scope_filter:\n  \
132               has_ancestor: marker.lock\n\
133             level: warning\n",
134        );
135        let rule = build(&spec).unwrap();
136        let idx = index(&[
137            "pkg/marker.lock",
138            "pkg/sub/deep/file.rs",
139            "other/sub/deep/file.rs",
140        ]);
141        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
142        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
143        assert_eq!(
144            v[0].path.as_deref(),
145            Some(Path::new("pkg/sub/deep/file.rs"))
146        );
147    }
148}