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));
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 use std::path::PathBuf;
155
156 fn index(entries: &[(&str, bool)]) -> FileIndex {
157 FileIndex {
158 entries: entries
159 .iter()
160 .map(|(p, is_dir)| FileEntry {
161 path: PathBuf::from(p),
162 is_dir: *is_dir,
163 size: 1,
164 })
165 .collect(),
166 }
167 }
168
169 fn rule(select: &str, require: &[&str]) -> DirContainsRule {
170 let globs: Vec<String> = require.iter().map(|s| (*s).to_string()).collect();
171 let matchers: Vec<GlobMatcher> = globs
172 .iter()
173 .map(|p| Glob::new(p).unwrap().compile_matcher())
174 .collect();
175 DirContainsRule {
176 id: "t".into(),
177 level: Level::Error,
178 policy_url: None,
179 message: None,
180 select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
181 require_globs: globs,
182 require_matchers: matchers,
183 }
184 }
185
186 fn eval(rule: &DirContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
187 let idx = index(files);
188 let ctx = Context {
189 root: Path::new("/"),
190 index: &idx,
191 registry: None,
192 facts: None,
193 vars: None,
194 git_tracked: None,
195 git_blame: None,
196 };
197 rule.evaluate(&ctx).unwrap()
198 }
199
200 #[test]
201 fn passes_when_every_require_satisfied() {
202 let r = rule("packages/*", &["README.md", "LICENSE*"]);
203 let v = eval(
204 &r,
205 &[
206 ("packages", true),
207 ("packages/a", true),
208 ("packages/a/README.md", false),
209 ("packages/a/LICENSE-APACHE", false),
210 ("packages/b", true),
211 ("packages/b/README.md", false),
212 ("packages/b/LICENSE", false),
213 ],
214 );
215 assert!(v.is_empty(), "unexpected: {v:?}");
216 }
217
218 #[test]
219 fn violates_once_per_missing_require_per_dir() {
220 let r = rule("packages/*", &["README.md", "LICENSE*"]);
221 let v = eval(
222 &r,
223 &[
224 ("packages", true),
225 ("packages/a", true),
226 ("packages/a/README.md", false),
227 ],
229 );
230 assert_eq!(v.len(), 1);
231 assert!(v[0].message.contains("LICENSE"));
232 }
233
234 #[test]
235 fn multiple_missing_across_multiple_dirs() {
236 let r = rule("packages/*", &["README.md", "LICENSE*"]);
237 let v = eval(
238 &r,
239 &[
240 ("packages", true),
241 ("packages/a", true),
242 ("packages/b", true),
244 ("packages/b/README.md", false),
245 ],
247 );
248 assert_eq!(v.len(), 3);
249 }
250
251 #[test]
252 fn directory_children_count_too() {
253 let r = rule("packages/*", &["src"]);
255 let v = eval(
256 &r,
257 &[
258 ("packages", true),
259 ("packages/a", true),
260 ("packages/a/src", true),
261 ],
262 );
263 assert!(v.is_empty());
264 }
265
266 #[test]
267 fn require_can_be_single_string() {
268 let yaml = r"
269select: 'packages/*'
270require: 'README.md'
271";
272 let opts: Options = serde_yaml_ng::from_str(yaml).unwrap();
273 assert!(matches!(opts.require, RequireList::One(_)));
274 }
275
276 #[test]
277 fn no_matching_dirs_means_no_violations() {
278 let r = rule("packages/*", &["README.md"]);
279 let v = eval(&r, &[("src", true), ("src/foo", true)]);
280 assert!(v.is_empty());
281 }
282}