1use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
21use globset::{Glob, GlobMatcher};
22use serde::Deserialize;
23use std::path::Path;
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Options {
28 select: String,
29 require: RequireList,
30}
31
32#[derive(Debug, Deserialize)]
33#[serde(untagged)]
34enum RequireList {
35 One(String),
36 Many(Vec<String>),
37}
38
39impl RequireList {
40 fn into_vec(self) -> Vec<String> {
41 match self {
42 Self::One(s) => vec![s],
43 Self::Many(v) => v,
44 }
45 }
46}
47
48#[derive(Debug)]
49pub struct DirContainsRule {
50 id: String,
51 level: Level,
52 policy_url: Option<String>,
53 message: Option<String>,
54 select_scope: Scope,
55 require_globs: Vec<String>,
56 require_matchers: Vec<GlobMatcher>,
57}
58
59impl Rule for DirContainsRule {
60 fn id(&self) -> &str {
61 &self.id
62 }
63 fn level(&self) -> Level {
64 self.level
65 }
66 fn policy_url(&self) -> Option<&str> {
67 self.policy_url.as_deref()
68 }
69
70 fn requires_full_index(&self) -> bool {
71 true
75 }
76
77 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
78 let mut violations = Vec::new();
79 for dir in ctx.index.dirs() {
80 if !self.select_scope.matches(&dir.path, ctx.index) {
81 continue;
82 }
83 let basenames: Vec<&str> = ctx
95 .index
96 .children_of(&dir.path)
97 .iter()
98 .filter_map(|&i| {
99 ctx.index.entries[i]
100 .path
101 .file_name()
102 .and_then(|s| s.to_str())
103 })
104 .collect();
105 for (i, matcher) in self.require_matchers.iter().enumerate() {
106 let found = basenames.iter().any(|b| matcher.is_match(b));
107 if !found {
108 let glob = &self.require_globs[i];
109 let msg = self.format_message(&dir.path, glob);
110 violations.push(Violation::new(msg).with_path(dir.path.clone()));
111 }
112 }
113 }
114 Ok(violations)
115 }
116}
117
118impl DirContainsRule {
119 fn format_message(&self, dir: &Path, glob: &str) -> String {
120 if let Some(user) = self.message.as_deref() {
121 let dir_str = dir.display().to_string();
122 let glob_str = glob.to_string();
123 return alint_core::template::render_message(user, |ns, key| match (ns, key) {
124 ("ctx", "dir") => Some(dir_str.clone()),
125 ("ctx", "require") => Some(glob_str.clone()),
126 _ => None,
127 });
128 }
129 format!("{} is missing a child matching {:?}", dir.display(), glob)
130 }
131}
132
133pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
134 alint_core::reject_scope_filter_on_cross_file(spec, "dir_contains")?;
135 let opts: Options = spec
136 .deserialize_options()
137 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
138 let require_globs = opts.require.into_vec();
139 if require_globs.is_empty() {
140 return Err(Error::rule_config(
141 &spec.id,
142 "dir_contains `require` must not be empty",
143 ));
144 }
145 let select_scope = Scope::from_patterns(&[opts.select])?;
146 let mut require_matchers = Vec::with_capacity(require_globs.len());
147 for pat in &require_globs {
148 let glob = Glob::new(pat).map_err(|source| Error::Glob {
149 pattern: pat.clone(),
150 source,
151 })?;
152 require_matchers.push(glob.compile_matcher());
153 }
154 Ok(Box::new(DirContainsRule {
155 id: spec.id.clone(),
156 level: spec.level,
157 policy_url: spec.policy_url.clone(),
158 message: spec.message.clone(),
159 select_scope,
160 require_globs,
161 require_matchers,
162 }))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use alint_core::{FileEntry, FileIndex};
169
170 fn index(entries: &[(&str, bool)]) -> FileIndex {
171 FileIndex::from_entries(
172 entries
173 .iter()
174 .map(|(p, is_dir)| FileEntry {
175 path: std::path::Path::new(p).into(),
176 is_dir: *is_dir,
177 size: 1,
178 })
179 .collect(),
180 )
181 }
182
183 fn rule(select: &str, require: &[&str]) -> DirContainsRule {
184 let globs: Vec<String> = require.iter().map(|s| (*s).to_string()).collect();
185 let matchers: Vec<GlobMatcher> = globs
186 .iter()
187 .map(|p| Glob::new(p).unwrap().compile_matcher())
188 .collect();
189 DirContainsRule {
190 id: "t".into(),
191 level: Level::Error,
192 policy_url: None,
193 message: None,
194 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
195 require_globs: globs,
196 require_matchers: matchers,
197 }
198 }
199
200 fn eval(rule: &DirContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
201 let idx = index(files);
202 let ctx = Context {
203 root: Path::new("/"),
204 index: &idx,
205 registry: None,
206 facts: None,
207 vars: None,
208 git_tracked: None,
209 git_blame: None,
210 };
211 rule.evaluate(&ctx).unwrap()
212 }
213
214 #[test]
215 fn passes_when_every_require_satisfied() {
216 let r = rule("packages/*", &["README.md", "LICENSE*"]);
217 let v = eval(
218 &r,
219 &[
220 ("packages", true),
221 ("packages/a", true),
222 ("packages/a/README.md", false),
223 ("packages/a/LICENSE-APACHE", false),
224 ("packages/b", true),
225 ("packages/b/README.md", false),
226 ("packages/b/LICENSE", false),
227 ],
228 );
229 assert!(v.is_empty(), "unexpected: {v:?}");
230 }
231
232 #[test]
233 fn violates_once_per_missing_require_per_dir() {
234 let r = rule("packages/*", &["README.md", "LICENSE*"]);
235 let v = eval(
236 &r,
237 &[
238 ("packages", true),
239 ("packages/a", true),
240 ("packages/a/README.md", false),
241 ],
243 );
244 assert_eq!(v.len(), 1);
245 assert!(v[0].message.contains("LICENSE"));
246 }
247
248 #[test]
249 fn multiple_missing_across_multiple_dirs() {
250 let r = rule("packages/*", &["README.md", "LICENSE*"]);
251 let v = eval(
252 &r,
253 &[
254 ("packages", true),
255 ("packages/a", true),
256 ("packages/b", true),
258 ("packages/b/README.md", false),
259 ],
261 );
262 assert_eq!(v.len(), 3);
263 }
264
265 #[test]
266 fn directory_children_count_too() {
267 let r = rule("packages/*", &["src"]);
269 let v = eval(
270 &r,
271 &[
272 ("packages", true),
273 ("packages/a", true),
274 ("packages/a/src", true),
275 ],
276 );
277 assert!(v.is_empty());
278 }
279
280 #[test]
281 fn require_can_be_single_string() {
282 let yaml = r"
283select: 'packages/*'
284require: 'README.md'
285";
286 let opts: Options = serde_yaml_ng::from_str(yaml).unwrap();
287 assert!(matches!(opts.require, RequireList::One(_)));
288 }
289
290 #[test]
291 fn no_matching_dirs_means_no_violations() {
292 let r = rule("packages/*", &["README.md"]);
293 let v = eval(&r, &[("src", true), ("src/foo", true)]);
294 assert!(v.is_empty());
295 }
296
297 #[test]
298 fn build_rejects_scope_filter_on_cross_file_rule() {
299 let yaml = r#"
304id: t
305kind: dir_contains
306select: "packages/*"
307require: ["README.md"]
308level: error
309scope_filter:
310 has_ancestor: Cargo.toml
311"#;
312 let spec = crate::test_support::spec_yaml(yaml);
313 let err = build(&spec).unwrap_err().to_string();
314 assert!(
315 err.contains("scope_filter is supported on per-file rules only"),
316 "expected per-file-only message, got: {err}",
317 );
318 assert!(
319 err.contains("dir_contains"),
320 "expected message to name the cross-file kind, got: {err}",
321 );
322 }
323}