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