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