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