alint_rules/
dir_exists.rs1use alint_core::{Context, Error, Level, PathsSpec, Result, Rule, RuleSpec, Scope, Violation};
4
5#[derive(Debug)]
6pub struct DirExistsRule {
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,
18}
19
20impl Rule for DirExistsRule {
21 fn id(&self) -> &str {
22 &self.id
23 }
24 fn level(&self) -> Level {
25 self.level
26 }
27 fn policy_url(&self) -> Option<&str> {
28 self.policy_url.as_deref()
29 }
30 fn git_tracked_mode(&self) -> alint_core::GitTrackedMode {
31 if self.git_tracked_only {
32 alint_core::GitTrackedMode::DirAware
33 } else {
34 alint_core::GitTrackedMode::Off
35 }
36 }
37
38 fn requires_full_index(&self) -> bool {
39 true
46 }
47
48 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
49 let found = ctx.index.dirs().any(|entry| {
54 if !self.scope.matches(&entry.path, ctx.index) {
55 return false;
56 }
57 true
58 });
59 if found {
60 Ok(Vec::new())
61 } else {
62 let msg = self.message.clone().unwrap_or_else(|| {
63 let tracked = if self.git_tracked_only {
64 " (with tracked content)"
65 } else {
66 ""
67 };
68 format!(
69 "expected a directory matching [{}]{tracked}",
70 self.patterns.join(", ")
71 )
72 });
73 Ok(vec![Violation::new(msg)])
74 }
75 }
76}
77
78pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
79 alint_core::reject_scope_filter_on_cross_file(spec, "dir_exists")?;
80 let Some(paths) = &spec.paths else {
81 return Err(Error::rule_config(
82 &spec.id,
83 "dir_exists requires a `paths` field",
84 ));
85 };
86 Ok(Box::new(DirExistsRule {
87 id: spec.id.clone(),
88 level: spec.level,
89 policy_url: spec.policy_url.clone(),
90 message: spec.message.clone(),
91 scope: Scope::from_paths_spec(paths)?,
92 patterns: patterns_of(paths),
93 git_tracked_only: spec.git_tracked_only,
94 }))
95}
96
97fn patterns_of(spec: &PathsSpec) -> Vec<String> {
98 match spec {
99 PathsSpec::Single(s) => vec![s.clone()],
100 PathsSpec::Many(v) => v.clone(),
101 PathsSpec::IncludeExclude { include, .. } => include.clone(),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::test_support::{ctx, index_with_dirs, spec_yaml};
109 use std::path::Path;
110
111 #[test]
112 fn build_rejects_missing_paths_field() {
113 let spec = spec_yaml(
114 "id: t\n\
115 kind: dir_exists\n\
116 level: error\n",
117 );
118 let err = build(&spec).unwrap_err().to_string();
119 assert!(err.contains("paths"), "unexpected: {err}");
120 }
121
122 #[test]
123 fn evaluate_passes_when_matching_dir_present() {
124 let spec = spec_yaml(
125 "id: t\n\
126 kind: dir_exists\n\
127 paths: \"docs\"\n\
128 level: error\n",
129 );
130 let rule = build(&spec).unwrap();
131 let idx = index_with_dirs(&[("docs", true), ("docs/README.md", false)]);
132 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
133 assert!(v.is_empty(), "unexpected: {v:?}");
134 }
135
136 #[test]
137 fn evaluate_fires_when_directory_missing() {
138 let spec = spec_yaml(
139 "id: t\n\
140 kind: dir_exists\n\
141 paths: \"docs\"\n\
142 level: error\n",
143 );
144 let rule = build(&spec).unwrap();
145 let idx = index_with_dirs(&[("README.md", false), ("src", true)]);
146 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
147 assert_eq!(v.len(), 1, "missing dir should fire one violation");
148 }
149
150 #[test]
151 fn evaluate_skips_files_when_dir_glob_only_matches_dirs() {
152 let spec = spec_yaml(
155 "id: t\n\
156 kind: dir_exists\n\
157 paths: \"docs\"\n\
158 level: error\n",
159 );
160 let rule = build(&spec).unwrap();
161 let idx = index_with_dirs(&[("docs", false)]); let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
163 assert_eq!(v.len(), 1);
164 }
165
166 #[test]
167 fn rule_advertises_full_index_requirement() {
168 let spec = spec_yaml(
169 "id: t\n\
170 kind: dir_exists\n\
171 paths: \"docs\"\n\
172 level: error\n",
173 );
174 let rule = build(&spec).unwrap();
175 assert!(rule.requires_full_index());
176 }
177
178 #[test]
179 fn git_tracked_only_advertises_dir_aware_mode() {
180 let spec = spec_yaml(
181 "id: t\n\
182 kind: dir_exists\n\
183 paths: \"src\"\n\
184 level: error\n\
185 git_tracked_only: true\n",
186 );
187 let rule = build(&spec).unwrap();
188 assert_eq!(
189 rule.git_tracked_mode(),
190 alint_core::GitTrackedMode::DirAware,
191 );
192 }
193
194 #[test]
195 fn build_rejects_scope_filter_on_cross_file_rule() {
196 let yaml = r#"
201id: t
202kind: dir_exists
203paths: "docs"
204level: error
205scope_filter:
206 has_ancestor: Cargo.toml
207"#;
208 let spec = spec_yaml(yaml);
209 let err = build(&spec).unwrap_err().to_string();
210 assert!(
211 err.contains("scope_filter is supported on per-file rules only"),
212 "expected per-file-only message, got: {err}",
213 );
214 assert!(
215 err.contains("dir_exists"),
216 "expected message to name the cross-file kind, got: {err}",
217 );
218 }
219}