Skip to main content

alint_rules/
dir_only_contains.rs

1//! `dir_only_contains` — every direct child file of a directory matching
2//! `select:` must match at least one glob in `allow:`. Subdirectories are
3//! not checked (use `dir_absent` if you need to forbid nested directories).
4//!
5//! Canonical shape — `src/` subdirectories may only contain Rust sources:
6//!
7//! ```yaml
8//! - id: src-only-rs
9//!   kind: dir_only_contains
10//!   select: "src/*"
11//!   allow: ["*.rs", "README.md"]
12//!   level: error
13//! ```
14//!
15//! `allow` patterns match the CHILD's basename — not the full path — so
16//! `"*.rs"` matches any `.rs` file regardless of its directory.
17
18use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
19use globset::{Glob, GlobSet, GlobSetBuilder};
20use serde::Deserialize;
21use std::path::Path;
22
23#[derive(Debug, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct Options {
26    select: String,
27    allow: AllowList,
28}
29
30#[derive(Debug, Deserialize)]
31#[serde(untagged)]
32enum AllowList {
33    One(String),
34    Many(Vec<String>),
35}
36
37impl AllowList {
38    fn into_vec(self) -> Vec<String> {
39        match self {
40            Self::One(s) => vec![s],
41            Self::Many(v) => v,
42        }
43    }
44}
45
46#[derive(Debug)]
47pub struct DirOnlyContainsRule {
48    id: String,
49    level: Level,
50    policy_url: Option<String>,
51    message: Option<String>,
52    select_scope: Scope,
53    allow_globs: Vec<String>,
54    allow_matcher: GlobSet,
55}
56
57impl Rule for DirOnlyContainsRule {
58    alint_core::rule_common_impl!();
59
60    fn requires_full_index(&self) -> bool {
61        // Cross-file: every selected dir's verdict depends on
62        // its full child set, including unchanged children. Per
63        // roadmap, opts out of `--changed` filtering.
64        true
65    }
66
67    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
68        let mut violations = Vec::new();
69        for dir in ctx.index.dirs() {
70            if !self.select_scope.matches(&dir.path, ctx.index) {
71                continue;
72            }
73            // v0.9.8: O(D × children) instead of O(D × N). At 1M
74            // files / 5K matched dirs / ~200 files-per-dir, this
75            // is 1M ops total instead of the previous 5B
76            // entries.iter() comparisons per matched dir.
77            for &child_idx in ctx.index.children_of(&dir.path) {
78                let file = &ctx.index.entries[child_idx];
79                if file.is_dir {
80                    continue;
81                }
82                let Some(basename) = file.path.file_name().and_then(|s| s.to_str()) else {
83                    continue;
84                };
85                if self.allow_matcher.is_match(basename) {
86                    continue;
87                }
88                let msg = self.format_message(&dir.path, &file.path, basename);
89                violations.push(Violation::new(msg).with_path(file.path.clone()));
90            }
91        }
92        Ok(violations)
93    }
94}
95
96impl DirOnlyContainsRule {
97    fn format_message(&self, dir: &Path, file: &Path, basename: &str) -> String {
98        if let Some(user) = self.message.as_deref() {
99            let dir_str = dir.display().to_string();
100            let file_str = file.display().to_string();
101            let basename_str = basename.to_string();
102            return alint_core::template::render_message(user, |ns, key| match (ns, key) {
103                ("ctx", "dir") => Some(dir_str.clone()),
104                ("ctx", "file") => Some(file_str.clone()),
105                ("ctx", "basename") => Some(basename_str.clone()),
106                _ => None,
107            });
108        }
109        format!(
110            "{} is not allowed in {} (allow: [{}])",
111            file.display(),
112            dir.display(),
113            self.allow_globs.join(", "),
114        )
115    }
116}
117
118pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
119    alint_core::reject_scope_filter_on_cross_file(spec, "dir_only_contains")?;
120    let opts: Options = spec
121        .deserialize_options()
122        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
123    let allow_globs = opts.allow.into_vec();
124    if allow_globs.is_empty() {
125        return Err(Error::rule_config(
126            &spec.id,
127            "dir_only_contains `allow` must not be empty",
128        ));
129    }
130    let select_scope = Scope::from_patterns(&[opts.select])?;
131    let mut builder = GlobSetBuilder::new();
132    for pat in &allow_globs {
133        let glob = Glob::new(pat).map_err(|source| Error::Glob {
134            pattern: pat.clone(),
135            source,
136        })?;
137        builder.add(glob);
138    }
139    let allow_matcher = builder.build().map_err(|source| Error::Glob {
140        pattern: allow_globs.join(","),
141        source,
142    })?;
143    Ok(Box::new(DirOnlyContainsRule {
144        id: spec.id.clone(),
145        level: spec.level,
146        policy_url: spec.policy_url.clone(),
147        message: spec.message.clone(),
148        select_scope,
149        allow_globs,
150        allow_matcher,
151    }))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use alint_core::{FileEntry, FileIndex};
158
159    fn index(entries: &[(&str, bool)]) -> FileIndex {
160        FileIndex::from_entries(
161            entries
162                .iter()
163                .map(|(p, is_dir)| FileEntry {
164                    path: std::path::Path::new(p).into(),
165                    is_dir: *is_dir,
166                    size: 1,
167                })
168                .collect(),
169        )
170    }
171
172    fn rule(select: &str, allow: &[&str]) -> DirOnlyContainsRule {
173        let allow_globs: Vec<String> = allow.iter().map(|s| (*s).to_string()).collect();
174        let mut builder = GlobSetBuilder::new();
175        for p in &allow_globs {
176            builder.add(Glob::new(p).unwrap());
177        }
178        DirOnlyContainsRule {
179            id: "t".into(),
180            level: Level::Error,
181            policy_url: None,
182            message: None,
183            select_scope: Scope::from_patterns(&[select.to_string()]).unwrap(),
184            allow_globs,
185            allow_matcher: builder.build().unwrap(),
186        }
187    }
188
189    fn eval(rule: &DirOnlyContainsRule, files: &[(&str, bool)]) -> Vec<Violation> {
190        let idx = index(files);
191        let ctx = Context {
192            root: Path::new("/"),
193            index: &idx,
194            registry: None,
195            facts: None,
196            vars: None,
197            git_tracked: None,
198            git_blame: None,
199        };
200        rule.evaluate(&ctx).unwrap()
201    }
202
203    #[test]
204    fn passes_when_every_child_allowed() {
205        let r = rule("src/*", &["*.rs", "mod.rs"]);
206        let v = eval(
207            &r,
208            &[
209                ("src", true),
210                ("src/foo", true),
211                ("src/foo/lib.rs", false),
212                ("src/foo/mod.rs", false),
213                ("src/bar", true),
214                ("src/bar/main.rs", false),
215            ],
216        );
217        assert!(v.is_empty(), "unexpected: {v:?}");
218    }
219
220    #[test]
221    fn flags_disallowed_child() {
222        let r = rule("src/*", &["*.rs"]);
223        let v = eval(
224            &r,
225            &[
226                ("src", true),
227                ("src/foo", true),
228                ("src/foo/lib.rs", false),
229                ("src/foo/README.md", false), // disallowed
230            ],
231        );
232        assert_eq!(v.len(), 1);
233        assert_eq!(v[0].path.as_deref(), Some(Path::new("src/foo/README.md")));
234    }
235
236    #[test]
237    fn multiple_disallowed_children_emit_multiple_violations() {
238        let r = rule("src/*", &["*.rs"]);
239        let v = eval(
240            &r,
241            &[
242                ("src", true),
243                ("src/foo", true),
244                ("src/foo/a.rs", false),
245                ("src/foo/a.md", false),   // disallowed
246                ("src/foo/a.json", false), // disallowed
247            ],
248        );
249        assert_eq!(v.len(), 2);
250    }
251
252    #[test]
253    fn subdirectories_are_not_flagged() {
254        // `src/foo` is an iterated dir. Its child `src/foo/inner` is a
255        // subdirectory — we only check files, so it passes.
256        let r = rule("src/*", &["*.rs"]);
257        let v = eval(
258            &r,
259            &[
260                ("src", true),
261                ("src/foo", true),
262                ("src/foo/a.rs", false),
263                ("src/foo/inner", true), // subdirectory — skipped
264            ],
265        );
266        assert!(v.is_empty());
267    }
268
269    #[test]
270    fn deeper_files_are_not_direct_children() {
271        // A file two levels below the iterated dir is not a direct child, so
272        // it is not subject to this rule.
273        let r = rule("src/*", &["*.rs"]);
274        let v = eval(
275            &r,
276            &[
277                ("src", true),
278                ("src/foo", true),
279                ("src/foo/a.rs", false),
280                ("src/foo/inner", true),
281                ("src/foo/inner/weird.bin", false), // not a direct child of src/foo
282            ],
283        );
284        assert!(v.is_empty());
285    }
286
287    #[test]
288    fn no_matched_dirs_means_no_violations() {
289        let r = rule("components/*", &["*.tsx"]);
290        let v = eval(&r, &[("src", true), ("src/foo", true)]);
291        assert!(v.is_empty());
292    }
293
294    #[test]
295    fn allow_can_be_single_string() {
296        let yaml = r"
297select: src/*
298allow: '*.rs'
299";
300        let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
301        assert!(matches!(opts.allow, super::AllowList::One(_)));
302    }
303
304    #[test]
305    fn allow_can_be_list() {
306        let yaml = r#"
307select: src/*
308allow: ["*.rs", "*.toml"]
309"#;
310        let opts: super::Options = serde_yaml_ng::from_str(yaml).unwrap();
311        assert!(matches!(opts.allow, super::AllowList::Many(_)));
312    }
313
314    #[test]
315    fn build_rejects_scope_filter_on_cross_file_rule() {
316        // dir_only_contains is a cross-file rule
317        // (requires_full_index = true); scope_filter is
318        // per-file-rules-only. The build path must reject it with
319        // a clear message pointing at the for_each_dir +
320        // when_iter: alternative.
321        let yaml = r#"
322id: t
323kind: dir_only_contains
324select: "src/*"
325allow: ["*.rs"]
326level: error
327scope_filter:
328  has_ancestor: Cargo.toml
329"#;
330        let spec = crate::test_support::spec_yaml(yaml);
331        let err = build(&spec).unwrap_err().to_string();
332        assert!(
333            err.contains("scope_filter is supported on per-file rules only"),
334            "expected per-file-only message, got: {err}",
335        );
336        assert!(
337            err.contains("dir_only_contains"),
338            "expected message to name the cross-file kind, got: {err}",
339        );
340    }
341}