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 alint_core::rule_common_impl!();
24 fn git_tracked_mode(&self) -> alint_core::GitTrackedMode {
25 if self.git_tracked_only {
26 alint_core::GitTrackedMode::DirAware
27 } else {
28 alint_core::GitTrackedMode::Off
29 }
30 }
31
32 fn requires_full_index(&self) -> bool {
33 true
38 }
39
40 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
41 let mut violations = Vec::new();
42 for entry in ctx.index.dirs() {
47 if !self.scope.matches(&entry.path, ctx.index) {
48 continue;
49 }
50 let msg = self.message.clone().unwrap_or_else(|| {
51 let tracked = if self.git_tracked_only {
52 " and has tracked content"
53 } else {
54 ""
55 };
56 format!(
57 "directory is forbidden (matches [{}]{tracked}): {}",
58 self.patterns.join(", "),
59 entry.path.display()
60 )
61 });
62 violations.push(Violation::new(msg).with_path(entry.path.clone()));
63 }
64 Ok(violations)
65 }
66}
67
68pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
69 let Some(paths) = &spec.paths else {
70 return Err(Error::rule_config(
71 &spec.id,
72 "dir_absent requires a `paths` field",
73 ));
74 };
75 Ok(Box::new(DirAbsentRule {
82 id: spec.id.clone(),
83 level: spec.level,
84 policy_url: spec.policy_url.clone(),
85 message: spec.message.clone(),
86 scope: Scope::from_spec(spec)?,
87 patterns: patterns_of(paths),
88 git_tracked_only: spec.git_tracked_only,
89 }))
90}
91
92fn patterns_of(spec: &PathsSpec) -> Vec<String> {
93 match spec {
94 PathsSpec::Single(s) => vec![s.clone()],
95 PathsSpec::Many(v) => v.clone(),
96 PathsSpec::IncludeExclude { include, .. } => include.clone(),
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::test_support::{ctx, index_with_dirs, spec_yaml};
104 use std::path::Path;
105
106 #[test]
107 fn build_rejects_missing_paths_field() {
108 let spec = spec_yaml(
109 "id: t\n\
110 kind: dir_absent\n\
111 level: error\n",
112 );
113 let err = build(&spec).unwrap_err().to_string();
114 assert!(err.contains("paths"), "unexpected: {err}");
115 }
116
117 #[test]
118 fn evaluate_passes_when_no_matching_dir_present() {
119 let spec = spec_yaml(
120 "id: t\n\
121 kind: dir_absent\n\
122 paths: \"target\"\n\
123 level: error\n",
124 );
125 let rule = build(&spec).unwrap();
126 let idx = index_with_dirs(&[("src", true), ("docs", true)]);
127 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
128 assert!(v.is_empty(), "unexpected: {v:?}");
129 }
130
131 #[test]
132 fn evaluate_fires_one_violation_per_forbidden_dir() {
133 let spec = spec_yaml(
134 "id: t\n\
135 kind: dir_absent\n\
136 paths: \"**/target\"\n\
137 level: error\n",
138 );
139 let rule = build(&spec).unwrap();
140 let idx = index_with_dirs(&[("target", true), ("crates/foo/target", true), ("src", true)]);
141 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
142 assert_eq!(v.len(), 2, "expected one violation per target dir: {v:?}");
143 }
144
145 #[test]
146 fn evaluate_ignores_files_with_matching_name() {
147 let spec = spec_yaml(
148 "id: t\n\
149 kind: dir_absent\n\
150 paths: \"target\"\n\
151 level: error\n",
152 );
153 let rule = build(&spec).unwrap();
154 let idx = index_with_dirs(&[("target", false)]);
156 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
157 assert!(v.is_empty(), "file named 'target' shouldn't fire");
158 }
159
160 #[test]
161 fn git_tracked_only_advertises_dir_aware_mode() {
162 let spec = spec_yaml(
174 "id: t\n\
175 kind: dir_absent\n\
176 paths: \"target\"\n\
177 level: error\n\
178 git_tracked_only: true\n",
179 );
180 let rule = build(&spec).unwrap();
181 assert_eq!(
182 rule.git_tracked_mode(),
183 alint_core::GitTrackedMode::DirAware,
184 "git_tracked_only on dir_absent must advertise DirAware mode",
185 );
186 }
187
188 #[test]
189 fn rule_advertises_full_index_requirement() {
190 let spec = spec_yaml(
191 "id: t\n\
192 kind: dir_absent\n\
193 paths: \"target\"\n\
194 level: error\n",
195 );
196 let rule = build(&spec).unwrap();
197 assert!(rule.requires_full_index());
198 }
199
200 #[test]
201 fn build_accepts_scope_filter() {
202 let yaml = r#"
207id: t
208kind: dir_absent
209paths: "**/dist"
210level: warning
211scope_filter:
212 has_ancestor: package.json
213"#;
214 let spec = spec_yaml(yaml);
215 let rule = build(&spec).expect("scope_filter must be accepted on dir_absent");
216 assert_eq!(rule.id(), "t");
217 }
218}