Skip to main content

alint_rules/
dir_contains.rs

1//! `dir_contains` — every directory matching `select` must have at least
2//! one direct child matching each glob in `require`. Sugar over
3//! `for_each_dir` + `file_exists` for the common shape "this dir must
4//! have X, Y, and Z."
5//!
6//! Canonical shape — every `packages/*` must have both a README and a
7//! license file:
8//!
9//! ```yaml
10//! - id: packages-have-readme-and-license
11//!   kind: dir_contains
12//!   select: "packages/*"
13//!   require: ["README.md", "LICENSE*"]
14//!   level: error
15//! ```
16//!
17//! `require` patterns match direct-child **basenames**. Use
18//! `for_each_dir` with nested rules if you need deeper semantics.
19
20use 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        // Cross-file: every selected dir's verdict depends on
72        // its current child set, not just the diff. Per roadmap,
73        // opts out of `--changed` filtering.
74        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                // missing LICENSE
227            ],
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                // a: missing both
242                ("packages/b", true),
243                ("packages/b/README.md", false),
244                // b: missing LICENSE
245            ],
246        );
247        assert_eq!(v.len(), 3);
248    }
249
250    #[test]
251    fn directory_children_count_too() {
252        // `src` as a required name — matches a subdir named `src`.
253        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}