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