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 evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
71        let mut violations = Vec::new();
72        for dir in ctx.index.dirs() {
73            if !self.select_scope.matches(&dir.path) {
74                continue;
75            }
76            for (i, matcher) in self.require_matchers.iter().enumerate() {
77                let found = ctx.index.entries.iter().any(|e| {
78                    if e.path.parent() != Some(&dir.path) {
79                        return false;
80                    }
81                    e.path
82                        .file_name()
83                        .and_then(|s| s.to_str())
84                        .is_some_and(|basename| matcher.is_match(basename))
85                });
86                if !found {
87                    let glob = &self.require_globs[i];
88                    let msg = self.format_message(&dir.path, glob);
89                    violations.push(Violation::new(msg).with_path(&dir.path));
90                }
91            }
92        }
93        Ok(violations)
94    }
95}
96
97impl DirContainsRule {
98    fn format_message(&self, dir: &Path, glob: &str) -> String {
99        if let Some(user) = self.message.as_deref() {
100            let dir_str = dir.display().to_string();
101            let glob_str = glob.to_string();
102            return alint_core::template::render_message(user, |ns, key| match (ns, key) {
103                ("ctx", "dir") => Some(dir_str.clone()),
104                ("ctx", "require") => Some(glob_str.clone()),
105                _ => None,
106            });
107        }
108        format!("{} is missing a child matching {:?}", dir.display(), glob)
109    }
110}
111
112pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
113    let opts: Options = spec
114        .deserialize_options()
115        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
116    let require_globs = opts.require.into_vec();
117    if require_globs.is_empty() {
118        return Err(Error::rule_config(
119            &spec.id,
120            "dir_contains `require` must not be empty",
121        ));
122    }
123    let select_scope = Scope::from_patterns(&[opts.select])?;
124    let mut require_matchers = Vec::with_capacity(require_globs.len());
125    for pat in &require_globs {
126        let glob = Glob::new(pat).map_err(|source| Error::Glob {
127            pattern: pat.clone(),
128            source,
129        })?;
130        require_matchers.push(glob.compile_matcher());
131    }
132    Ok(Box::new(DirContainsRule {
133        id: spec.id.clone(),
134        level: spec.level,
135        policy_url: spec.policy_url.clone(),
136        message: spec.message.clone(),
137        select_scope,
138        require_globs,
139        require_matchers,
140    }))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use alint_core::{FileEntry, FileIndex};
147    use std::path::PathBuf;
148
149    fn index(entries: &[(&str, bool)]) -> FileIndex {
150        FileIndex {
151            entries: entries
152                .iter()
153                .map(|(p, is_dir)| FileEntry {
154                    path: PathBuf::from(p),
155                    is_dir: *is_dir,
156                    size: 1,
157                })
158                .collect(),
159        }
160    }
161
162    fn rule(select: &str, require: &[&str]) -> DirContainsRule {
163        let globs: Vec<String> = require.iter().map(|s| (*s).to_string()).collect();
164        let matchers: Vec<GlobMatcher> = globs
165            .iter()
166            .map(|p| Glob::new(p).unwrap().compile_matcher())
167            .collect();
168        DirContainsRule {
169            id: "t".into(),
170            level: Level::Error,
171            policy_url: None,
172            message: None,
173            select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
174            require_globs: globs,
175            require_matchers: matchers,
176        }
177    }
178
179    fn eval(rule: &DirContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
180        let idx = index(files);
181        let ctx = Context {
182            root: Path::new("/"),
183            index: &idx,
184            registry: None,
185            facts: None,
186            vars: None,
187        };
188        rule.evaluate(&ctx).unwrap()
189    }
190
191    #[test]
192    fn passes_when_every_require_satisfied() {
193        let r = rule("packages/*", &["README.md", "LICENSE*"]);
194        let v = eval(
195            &r,
196            &[
197                ("packages", true),
198                ("packages/a", true),
199                ("packages/a/README.md", false),
200                ("packages/a/LICENSE-APACHE", false),
201                ("packages/b", true),
202                ("packages/b/README.md", false),
203                ("packages/b/LICENSE", false),
204            ],
205        );
206        assert!(v.is_empty(), "unexpected: {v:?}");
207    }
208
209    #[test]
210    fn violates_once_per_missing_require_per_dir() {
211        let r = rule("packages/*", &["README.md", "LICENSE*"]);
212        let v = eval(
213            &r,
214            &[
215                ("packages", true),
216                ("packages/a", true),
217                ("packages/a/README.md", false),
218                // missing LICENSE
219            ],
220        );
221        assert_eq!(v.len(), 1);
222        assert!(v[0].message.contains("LICENSE"));
223    }
224
225    #[test]
226    fn multiple_missing_across_multiple_dirs() {
227        let r = rule("packages/*", &["README.md", "LICENSE*"]);
228        let v = eval(
229            &r,
230            &[
231                ("packages", true),
232                ("packages/a", true),
233                // a: missing both
234                ("packages/b", true),
235                ("packages/b/README.md", false),
236                // b: missing LICENSE
237            ],
238        );
239        assert_eq!(v.len(), 3);
240    }
241
242    #[test]
243    fn directory_children_count_too() {
244        // `src` as a required name — matches a subdir named `src`.
245        let r = rule("packages/*", &["src"]);
246        let v = eval(
247            &r,
248            &[
249                ("packages", true),
250                ("packages/a", true),
251                ("packages/a/src", true),
252            ],
253        );
254        assert!(v.is_empty());
255    }
256
257    #[test]
258    fn require_can_be_single_string() {
259        let yaml = r"
260select: 'packages/*'
261require: 'README.md'
262";
263        let opts: Options = serde_yaml_ng::from_str(yaml).unwrap();
264        assert!(matches!(opts.require, RequireList::One(_)));
265    }
266
267    #[test]
268    fn no_matching_dirs_means_no_violations() {
269        let r = rule("packages/*", &["README.md"]);
270        let v = eval(&r, &[("src", true), ("src/foo", true)]);
271        assert!(v.is_empty());
272    }
273}