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));
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                // missing LICENSE
228            ],
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                // a: missing both
243                ("packages/b", true),
244                ("packages/b/README.md", false),
245                // b: missing LICENSE
246            ],
247        );
248        assert_eq!(v.len(), 3);
249    }
250
251    #[test]
252    fn directory_children_count_too() {
253        // `src` as a required name — matches a subdir named `src`.
254        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}