alint_rules/
max_directory_depth.rs1use 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 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}