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) {
81 continue;
82 }
83 for (i, matcher) in self.require_matchers.iter().enumerate() {
84 let found = ctx.index.entries.iter().any(|e| {
85 if e.path.parent() != Some(&dir.path) {
86 return false;
87 }
88 e.path
89 .file_name()
90 .and_then(|s| s.to_str())
91 .is_some_and(|basename| matcher.is_match(basename))
92 });
93 if !found {
94 let glob = &self.require_globs[i];
95 let msg = self.format_message(&dir.path, glob);
96 violations.push(Violation::new(msg).with_path(dir.path.clone()));
97 }
98 }
99 }
100 Ok(violations)
101 }
102}
103
104impl DirContainsRule {
105 fn format_message(&self, dir: &Path, glob: &str) -> String {
106 if let Some(user) = self.message.as_deref() {
107 let dir_str = dir.display().to_string();
108 let glob_str = glob.to_string();
109 return alint_core::template::render_message(user, |ns, key| match (ns, key) {
110 ("ctx", "dir") => Some(dir_str.clone()),
111 ("ctx", "require") => Some(glob_str.clone()),
112 _ => None,
113 });
114 }
115 format!("{} is missing a child matching {:?}", dir.display(), glob)
116 }
117}
118
119pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
120 let opts: Options = spec
121 .deserialize_options()
122 .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
123 let require_globs = opts.require.into_vec();
124 if require_globs.is_empty() {
125 return Err(Error::rule_config(
126 &spec.id,
127 "dir_contains `require` must not be empty",
128 ));
129 }
130 let select_scope = Scope::from_patterns(&[opts.select])?;
131 let mut require_matchers = Vec::with_capacity(require_globs.len());
132 for pat in &require_globs {
133 let glob = Glob::new(pat).map_err(|source| Error::Glob {
134 pattern: pat.clone(),
135 source,
136 })?;
137 require_matchers.push(glob.compile_matcher());
138 }
139 Ok(Box::new(DirContainsRule {
140 id: spec.id.clone(),
141 level: spec.level,
142 policy_url: spec.policy_url.clone(),
143 message: spec.message.clone(),
144 select_scope,
145 require_globs,
146 require_matchers,
147 }))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use alint_core::{FileEntry, FileIndex};
154
155 fn index(entries: &[(&str, bool)]) -> FileIndex {
156 FileIndex {
157 entries: entries
158 .iter()
159 .map(|(p, is_dir)| FileEntry {
160 path: std::path::Path::new(p).into(),
161 is_dir: *is_dir,
162 size: 1,
163 })
164 .collect(),
165 }
166 }
167
168 fn rule(select: &str, require: &[&str]) -> DirContainsRule {
169 let globs: Vec<String> = require.iter().map(|s| (*s).to_string()).collect();
170 let matchers: Vec<GlobMatcher> = globs
171 .iter()
172 .map(|p| Glob::new(p).unwrap().compile_matcher())
173 .collect();
174 DirContainsRule {
175 id: "t".into(),
176 level: Level::Error,
177 policy_url: None,
178 message: None,
179 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
180 require_globs: globs,
181 require_matchers: matchers,
182 }
183 }
184
185 fn eval(rule: &DirContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
186 let idx = index(files);
187 let ctx = Context {
188 root: Path::new("/"),
189 index: &idx,
190 registry: None,
191 facts: None,
192 vars: None,
193 git_tracked: None,
194 git_blame: None,
195 };
196 rule.evaluate(&ctx).unwrap()
197 }
198
199 #[test]
200 fn passes_when_every_require_satisfied() {
201 let r = rule("packages/*", &["README.md", "LICENSE*"]);
202 let v = eval(
203 &r,
204 &[
205 ("packages", true),
206 ("packages/a", true),
207 ("packages/a/README.md", false),
208 ("packages/a/LICENSE-APACHE", false),
209 ("packages/b", true),
210 ("packages/b/README.md", false),
211 ("packages/b/LICENSE", false),
212 ],
213 );
214 assert!(v.is_empty(), "unexpected: {v:?}");
215 }
216
217 #[test]
218 fn violates_once_per_missing_require_per_dir() {
219 let r = rule("packages/*", &["README.md", "LICENSE*"]);
220 let v = eval(
221 &r,
222 &[
223 ("packages", true),
224 ("packages/a", true),
225 ("packages/a/README.md", false),
226 ],
228 );
229 assert_eq!(v.len(), 1);
230 assert!(v[0].message.contains("LICENSE"));
231 }
232
233 #[test]
234 fn multiple_missing_across_multiple_dirs() {
235 let r = rule("packages/*", &["README.md", "LICENSE*"]);
236 let v = eval(
237 &r,
238 &[
239 ("packages", true),
240 ("packages/a", true),
241 ("packages/b", true),
243 ("packages/b/README.md", false),
244 ],
246 );
247 assert_eq!(v.len(), 3);
248 }
249
250 #[test]
251 fn directory_children_count_too() {
252 let r = rule("packages/*", &["src"]);
254 let v = eval(
255 &r,
256 &[
257 ("packages", true),
258 ("packages/a", true),
259 ("packages/a/src", true),
260 ],
261 );
262 assert!(v.is_empty());
263 }
264
265 #[test]
266 fn require_can_be_single_string() {
267 let yaml = r"
268select: 'packages/*'
269require: 'README.md'
270";
271 let opts: Options = serde_yaml_ng::from_str(yaml).unwrap();
272 assert!(matches!(opts.require, RequireList::One(_)));
273 }
274
275 #[test]
276 fn no_matching_dirs_means_no_violations() {
277 let r = rule("packages/*", &["README.md"]);
278 let v = eval(&r, &[("src", true), ("src/foo", true)]);
279 assert!(v.is_empty());
280 }
281}