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    alint_core::rule_common_impl!();
61
62    fn requires_full_index(&self) -> bool {
63        // Cross-file: every selected dir's verdict depends on
64        // its current child set, not just the diff. Per roadmap,
65        // opts out of `--changed` filtering.
66        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            // v0.9.8: collect direct child basenames once per dir
76            // (cheap; iterator borrows into the Arc<Path>), then
77            // run each matcher over that small slice rather than
78            // scanning all entries per (dir, matcher) pair.
79            // O(D × children) replaces the prior O(D × R × N).
80            //
81            // dir_contains accepts BOTH file and subdir basenames
82            // (a require of `src` matches a `src/` subdir as well
83            // as a `src` file), so we use `children_of` directly
84            // instead of `file_basenames_of` which would filter
85            // out subdirs.
86            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                // missing LICENSE
234            ],
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                // a: missing both
249                ("packages/b", true),
250                ("packages/b/README.md", false),
251                // b: missing LICENSE
252            ],
253        );
254        assert_eq!(v.len(), 3);
255    }
256
257    #[test]
258    fn directory_children_count_too() {
259        // `src` as a required name — matches a subdir named `src`.
260        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        // dir_contains is a cross-file rule (requires_full_index =
292        // true); scope_filter is per-file-rules-only. The build
293        // path must reject it with a clear message pointing at
294        // the for_each_dir + when_iter: alternative.
295        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}