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, 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    max_depth: usize,
27}
28
29impl Rule for MaxDirectoryDepthRule {
30    alint_core::rule_common_impl!();
31
32    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
33        let mut violations = Vec::new();
34        for entry in ctx.index.files() {
35            if !self.scope.matches(&entry.path, ctx.index) {
36                continue;
37            }
38            let depth = entry.path.components().count();
39            if depth > self.max_depth {
40                let msg = self.message.clone().unwrap_or_else(|| {
41                    format!(
42                        "{} is at depth {depth}; max is {}",
43                        entry.path.display(),
44                        self.max_depth
45                    )
46                });
47                violations.push(Violation::new(msg).with_path(entry.path.clone()));
48            }
49        }
50        Ok(violations)
51    }
52}
53
54pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
55    let _paths = spec.paths.as_ref().ok_or_else(|| {
56        Error::rule_config(
57            &spec.id,
58            "max_directory_depth requires a `paths` field (often `\"**\"`)",
59        )
60    })?;
61    let opts: Options = spec
62        .deserialize_options()
63        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
64    if opts.max_depth == 0 {
65        return Err(Error::rule_config(
66            &spec.id,
67            "max_directory_depth `max_depth` must be > 0",
68        ));
69    }
70    if spec.fix.is_some() {
71        return Err(Error::rule_config(
72            &spec.id,
73            "max_directory_depth has no fix op — moving files is a human decision",
74        ));
75    }
76    Ok(Box::new(MaxDirectoryDepthRule {
77        id: spec.id.clone(),
78        level: spec.level,
79        policy_url: spec.policy_url.clone(),
80        message: spec.message.clone(),
81        scope: Scope::from_spec(spec)?,
82        max_depth: opts.max_depth,
83    }))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::test_support::{ctx, index, spec_yaml};
90    use std::path::{Path, PathBuf};
91
92    fn depth_of(s: &str) -> usize {
93        PathBuf::from(s).components().count()
94    }
95
96    #[test]
97    fn depth_counts_components() {
98        assert_eq!(depth_of("README.md"), 1);
99        assert_eq!(depth_of("src/lib.rs"), 2);
100        assert_eq!(depth_of("a/b/c/d.rs"), 4);
101    }
102
103    #[test]
104    fn scope_filter_narrows() {
105        // Two too-deep files; only the one inside a directory
106        // with `marker.lock` as ancestor should fire.
107        let spec = spec_yaml(
108            "id: t\n\
109             kind: max_directory_depth\n\
110             paths: \"**\"\n\
111             max_depth: 2\n\
112             scope_filter:\n  \
113               has_ancestor: marker.lock\n\
114             level: warning\n",
115        );
116        let rule = build(&spec).unwrap();
117        let idx = index(&[
118            "pkg/marker.lock",
119            "pkg/sub/deep/file.rs",
120            "other/sub/deep/file.rs",
121        ]);
122        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
123        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
124        assert_eq!(
125            v[0].path.as_deref(),
126            Some(Path::new("pkg/sub/deep/file.rs"))
127        );
128    }
129}