Skip to main content

alint_rules/
dir_absent.rs

1//! `dir_absent` — no directory matching `paths` may exist.
2
3use alint_core::{Context, Error, Level, PathsSpec, Result, Rule, RuleSpec, Scope, Violation};
4
5#[derive(Debug)]
6pub struct DirAbsentRule {
7    id: String,
8    level: Level,
9    policy_url: Option<String>,
10    message: Option<String>,
11    scope: Scope,
12    patterns: Vec<String>,
13    /// When `true`, only fire on directories that contain at
14    /// least one git-tracked file. The canonical use case is
15    /// "don't let `target/` be committed" — with this flag set,
16    /// a developer's locally-built `target/` (gitignored, no
17    /// tracked content) doesn't trigger; a `target/` whose
18    /// contents made it into git's index does.
19    git_tracked_only: bool,
20}
21
22impl Rule for DirAbsentRule {
23    fn id(&self) -> &str {
24        &self.id
25    }
26    fn level(&self) -> Level {
27        self.level
28    }
29    fn policy_url(&self) -> Option<&str> {
30        self.policy_url.as_deref()
31    }
32    fn git_tracked_mode(&self) -> alint_core::GitTrackedMode {
33        if self.git_tracked_only {
34            alint_core::GitTrackedMode::DirAware
35        } else {
36            alint_core::GitTrackedMode::Off
37        }
38    }
39
40    fn requires_full_index(&self) -> bool {
41        // See `dir_exists::requires_full_index` — directory
42        // scopes don't intersect a file-path-based changed-set
43        // cleanly, so we always evaluate this rule on the full
44        // tree in `--changed` mode. One O(N) scan per rule.
45        true
46    }
47
48    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
49        let mut violations = Vec::new();
50        // v0.9.11: when `git_tracked_only` is set the engine
51        // hands us a pre-filtered `ctx.index` (dir_aware mode);
52        // the per-entry `dir_has_tracked_files` check that lived
53        // here is now subsumed by the engine narrowing.
54        for entry in ctx.index.dirs() {
55            if !self.scope.matches(&entry.path, ctx.index) {
56                continue;
57            }
58            let msg = self.message.clone().unwrap_or_else(|| {
59                let tracked = if self.git_tracked_only {
60                    " and has tracked content"
61                } else {
62                    ""
63                };
64                format!(
65                    "directory is forbidden (matches [{}]{tracked}): {}",
66                    self.patterns.join(", "),
67                    entry.path.display()
68                )
69            });
70            violations.push(Violation::new(msg).with_path(entry.path.clone()));
71        }
72        Ok(violations)
73    }
74}
75
76pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
77    alint_core::reject_scope_filter_on_cross_file(spec, "dir_absent")?;
78    let Some(paths) = &spec.paths else {
79        return Err(Error::rule_config(
80            &spec.id,
81            "dir_absent requires a `paths` field",
82        ));
83    };
84    Ok(Box::new(DirAbsentRule {
85        id: spec.id.clone(),
86        level: spec.level,
87        policy_url: spec.policy_url.clone(),
88        message: spec.message.clone(),
89        scope: Scope::from_paths_spec(paths)?,
90        patterns: patterns_of(paths),
91        git_tracked_only: spec.git_tracked_only,
92    }))
93}
94
95fn patterns_of(spec: &PathsSpec) -> Vec<String> {
96    match spec {
97        PathsSpec::Single(s) => vec![s.clone()],
98        PathsSpec::Many(v) => v.clone(),
99        PathsSpec::IncludeExclude { include, .. } => include.clone(),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::test_support::{ctx, index_with_dirs, spec_yaml};
107    use std::path::Path;
108
109    #[test]
110    fn build_rejects_missing_paths_field() {
111        let spec = spec_yaml(
112            "id: t\n\
113             kind: dir_absent\n\
114             level: error\n",
115        );
116        let err = build(&spec).unwrap_err().to_string();
117        assert!(err.contains("paths"), "unexpected: {err}");
118    }
119
120    #[test]
121    fn evaluate_passes_when_no_matching_dir_present() {
122        let spec = spec_yaml(
123            "id: t\n\
124             kind: dir_absent\n\
125             paths: \"target\"\n\
126             level: error\n",
127        );
128        let rule = build(&spec).unwrap();
129        let idx = index_with_dirs(&[("src", true), ("docs", true)]);
130        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
131        assert!(v.is_empty(), "unexpected: {v:?}");
132    }
133
134    #[test]
135    fn evaluate_fires_one_violation_per_forbidden_dir() {
136        let spec = spec_yaml(
137            "id: t\n\
138             kind: dir_absent\n\
139             paths: \"**/target\"\n\
140             level: error\n",
141        );
142        let rule = build(&spec).unwrap();
143        let idx = index_with_dirs(&[("target", true), ("crates/foo/target", true), ("src", true)]);
144        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
145        assert_eq!(v.len(), 2, "expected one violation per target dir: {v:?}");
146    }
147
148    #[test]
149    fn evaluate_ignores_files_with_matching_name() {
150        let spec = spec_yaml(
151            "id: t\n\
152             kind: dir_absent\n\
153             paths: \"target\"\n\
154             level: error\n",
155        );
156        let rule = build(&spec).unwrap();
157        // A file named "target" should NOT fire `dir_absent`.
158        let idx = index_with_dirs(&[("target", false)]);
159        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
160        assert!(v.is_empty(), "file named 'target' shouldn't fire");
161    }
162
163    #[test]
164    fn git_tracked_only_advertises_dir_aware_mode() {
165        // v0.9.11: the silent-no-op-outside-git-repo guarantee
166        // moved from a per-rule runtime check to an engine-side
167        // pre-filtered FileIndex. Calling `evaluate` directly
168        // bypasses the engine's filtering, so this unit test
169        // can no longer assert the no-op behaviour at the rule
170        // level — instead it asserts the rule advertises the
171        // correct `GitTrackedMode`, which is what tells the
172        // engine to substitute an empty index when the
173        // tracked-set is `None`. The end-to-end no-op behaviour
174        // is asserted by
175        // `crates/alint-e2e/scenarios/check/git/git_tracked_only_outside_git_silently_passes_absent.yml`.
176        let spec = spec_yaml(
177            "id: t\n\
178             kind: dir_absent\n\
179             paths: \"target\"\n\
180             level: error\n\
181             git_tracked_only: true\n",
182        );
183        let rule = build(&spec).unwrap();
184        assert_eq!(
185            rule.git_tracked_mode(),
186            alint_core::GitTrackedMode::DirAware,
187            "git_tracked_only on dir_absent must advertise DirAware mode",
188        );
189    }
190
191    #[test]
192    fn rule_advertises_full_index_requirement() {
193        let spec = spec_yaml(
194            "id: t\n\
195             kind: dir_absent\n\
196             paths: \"target\"\n\
197             level: error\n",
198        );
199        let rule = build(&spec).unwrap();
200        assert!(rule.requires_full_index());
201    }
202
203    #[test]
204    fn build_rejects_scope_filter_on_cross_file_rule() {
205        // dir_absent is a cross-file rule (requires_full_index =
206        // true); scope_filter is per-file-rules-only. The build
207        // path must reject it with a clear message pointing at
208        // the for_each_dir + when_iter: alternative.
209        let yaml = r#"
210id: t
211kind: dir_absent
212paths: "target"
213level: error
214scope_filter:
215  has_ancestor: Cargo.toml
216"#;
217        let spec = spec_yaml(yaml);
218        let err = build(&spec).unwrap_err().to_string();
219        assert!(
220            err.contains("scope_filter is supported on per-file rules only"),
221            "expected per-file-only message, got: {err}",
222        );
223        assert!(
224            err.contains("dir_absent"),
225            "expected message to name the cross-file kind, got: {err}",
226        );
227    }
228}