alint_rules/
dir_absent.rs1use alint_core::{Context, Error, Level, PathsSpec, Result, Rule, RuleSpec, Scope, Violation};
4
5#[derive(Debug)]
6pub struct DirAbsentRule {
7 id: String,
8 level: Level,
9 policy_url: Option<String>,
10 message: Option<String>,
11 scope: Scope,
12 patterns: Vec<String>,
13 git_tracked_only: bool,
20}
21
22impl Rule for DirAbsentRule {
23 fn id(&self) -> &str {
24 &self.id
25 }
26 fn level(&self) -> Level {
27 self.level
28 }
29 fn policy_url(&self) -> Option<&str> {
30 self.policy_url.as_deref()
31 }
32 fn git_tracked_mode(&self) -> alint_core::GitTrackedMode {
33 if self.git_tracked_only {
34 alint_core::GitTrackedMode::DirAware
35 } else {
36 alint_core::GitTrackedMode::Off
37 }
38 }
39
40 fn requires_full_index(&self) -> bool {
41 true
46 }
47
48 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
49 let mut violations = Vec::new();
50 for entry in ctx.index.dirs() {
55 if !self.scope.matches(&entry.path, ctx.index) {
56 continue;
57 }
58 let msg = self.message.clone().unwrap_or_else(|| {
59 let tracked = if self.git_tracked_only {
60 " and has tracked content"
61 } else {
62 ""
63 };
64 format!(
65 "directory is forbidden (matches [{}]{tracked}): {}",
66 self.patterns.join(", "),
67 entry.path.display()
68 )
69 });
70 violations.push(Violation::new(msg).with_path(entry.path.clone()));
71 }
72 Ok(violations)
73 }
74}
75
76pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
77 alint_core::reject_scope_filter_on_cross_file(spec, "dir_absent")?;
78 let Some(paths) = &spec.paths else {
79 return Err(Error::rule_config(
80 &spec.id,
81 "dir_absent requires a `paths` field",
82 ));
83 };
84 Ok(Box::new(DirAbsentRule {
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_paths_spec(paths)?,
90 patterns: patterns_of(paths),
91 git_tracked_only: spec.git_tracked_only,
92 }))
93}
94
95fn patterns_of(spec: &PathsSpec) -> Vec<String> {
96 match spec {
97 PathsSpec::Single(s) => vec![s.clone()],
98 PathsSpec::Many(v) => v.clone(),
99 PathsSpec::IncludeExclude { include, .. } => include.clone(),
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::test_support::{ctx, index_with_dirs, spec_yaml};
107 use std::path::Path;
108
109 #[test]
110 fn build_rejects_missing_paths_field() {
111 let spec = spec_yaml(
112 "id: t\n\
113 kind: dir_absent\n\
114 level: error\n",
115 );
116 let err = build(&spec).unwrap_err().to_string();
117 assert!(err.contains("paths"), "unexpected: {err}");
118 }
119
120 #[test]
121 fn evaluate_passes_when_no_matching_dir_present() {
122 let spec = spec_yaml(
123 "id: t\n\
124 kind: dir_absent\n\
125 paths: \"target\"\n\
126 level: error\n",
127 );
128 let rule = build(&spec).unwrap();
129 let idx = index_with_dirs(&[("src", true), ("docs", true)]);
130 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
131 assert!(v.is_empty(), "unexpected: {v:?}");
132 }
133
134 #[test]
135 fn evaluate_fires_one_violation_per_forbidden_dir() {
136 let spec = spec_yaml(
137 "id: t\n\
138 kind: dir_absent\n\
139 paths: \"**/target\"\n\
140 level: error\n",
141 );
142 let rule = build(&spec).unwrap();
143 let idx = index_with_dirs(&[("target", true), ("crates/foo/target", true), ("src", true)]);
144 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
145 assert_eq!(v.len(), 2, "expected one violation per target dir: {v:?}");
146 }
147
148 #[test]
149 fn evaluate_ignores_files_with_matching_name() {
150 let spec = spec_yaml(
151 "id: t\n\
152 kind: dir_absent\n\
153 paths: \"target\"\n\
154 level: error\n",
155 );
156 let rule = build(&spec).unwrap();
157 let idx = index_with_dirs(&[("target", false)]);
159 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
160 assert!(v.is_empty(), "file named 'target' shouldn't fire");
161 }
162
163 #[test]
164 fn git_tracked_only_advertises_dir_aware_mode() {
165 let spec = spec_yaml(
177 "id: t\n\
178 kind: dir_absent\n\
179 paths: \"target\"\n\
180 level: error\n\
181 git_tracked_only: true\n",
182 );
183 let rule = build(&spec).unwrap();
184 assert_eq!(
185 rule.git_tracked_mode(),
186 alint_core::GitTrackedMode::DirAware,
187 "git_tracked_only on dir_absent must advertise DirAware mode",
188 );
189 }
190
191 #[test]
192 fn rule_advertises_full_index_requirement() {
193 let spec = spec_yaml(
194 "id: t\n\
195 kind: dir_absent\n\
196 paths: \"target\"\n\
197 level: error\n",
198 );
199 let rule = build(&spec).unwrap();
200 assert!(rule.requires_full_index());
201 }
202
203 #[test]
204 fn build_rejects_scope_filter_on_cross_file_rule() {
205 let yaml = r#"
210id: t
211kind: dir_absent
212paths: "target"
213level: error
214scope_filter:
215 has_ancestor: Cargo.toml
216"#;
217 let spec = spec_yaml(yaml);
218 let err = build(&spec).unwrap_err().to_string();
219 assert!(
220 err.contains("scope_filter is supported on per-file rules only"),
221 "expected per-file-only message, got: {err}",
222 );
223 assert!(
224 err.contains("dir_absent"),
225 "expected message to name the cross-file kind, got: {err}",
226 );
227 }
228}