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