Skip to main content

alint_rules/
file_absent.rs

1//! `file_absent` — emit a violation for every file matching `paths`.
2
3use alint_core::{
4    Context, Error, FixSpec, Fixer, Level, PathsSpec, Result, Rule, RuleSpec, Scope, Violation,
5};
6
7use crate::fixers::FileRemoveFixer;
8
9#[derive(Debug)]
10pub struct FileAbsentRule {
11    id: String,
12    level: Level,
13    policy_url: Option<String>,
14    message: Option<String>,
15    scope: Scope,
16    patterns: Vec<String>,
17    /// When `true`, only fire on entries that are also tracked
18    /// in git's index. Outside a git repo or with no rules
19    /// opting in, the tracked-set is `None` and every entry
20    /// reads as "untracked," so the rule becomes a no-op —
21    /// which is the right default for "don't let X be
22    /// committed" semantics.
23    git_tracked_only: bool,
24    fixer: Option<FileRemoveFixer>,
25}
26
27impl Rule for FileAbsentRule {
28    alint_core::rule_common_impl!();
29    fn git_tracked_mode(&self) -> alint_core::GitTrackedMode {
30        if self.git_tracked_only {
31            alint_core::GitTrackedMode::FileOnly
32        } else {
33            alint_core::GitTrackedMode::Off
34        }
35    }
36
37    fn requires_full_index(&self) -> bool {
38        // The verdict on "is X forbidden?" is over the whole tree —
39        // an unchanged-but-already-committed `.env` should still
40        // be visible. The engine skips this rule entirely when its
41        // scope doesn't intersect the diff, which is the usual
42        // user expectation in `--changed` mode.
43        true
44    }
45
46    fn path_scope(&self) -> Option<&Scope> {
47        Some(&self.scope)
48    }
49
50    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
51        let mut violations = Vec::new();
52        // v0.9.11: when `git_tracked_only` is set the engine
53        // hands us a pre-filtered `ctx.index` (file_only mode);
54        // the per-entry `is_git_tracked` check that lived here
55        // is now subsumed by the engine-side narrowing.
56        for entry in ctx.index.files() {
57            if !self.scope.matches(&entry.path, ctx.index) {
58                continue;
59            }
60            let msg = self.message.clone().unwrap_or_else(|| {
61                let tracked = if self.git_tracked_only {
62                    " and tracked in git"
63                } else {
64                    ""
65                };
66                format!(
67                    "file is forbidden (matches [{}]{tracked}): {}",
68                    self.patterns.join(", "),
69                    entry.path.display()
70                )
71            });
72            violations.push(Violation::new(msg).with_path(entry.path.clone()));
73        }
74        Ok(violations)
75    }
76
77    fn fixer(&self) -> Option<&dyn Fixer> {
78        self.fixer.as_ref().map(|f| f as &dyn Fixer)
79    }
80}
81
82pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
83    alint_core::reject_scope_filter_on_cross_file(spec, "file_absent")?;
84    let Some(paths) = &spec.paths else {
85        return Err(Error::rule_config(
86            &spec.id,
87            "file_absent requires a `paths` field",
88        ));
89    };
90    let fixer = match &spec.fix {
91        Some(FixSpec::FileRemove { .. }) => Some(FileRemoveFixer),
92        Some(other) => {
93            return Err(Error::rule_config(
94                &spec.id,
95                format!("fix.{} is not compatible with file_absent", other.op_name()),
96            ));
97        }
98        None => None,
99    };
100    Ok(Box::new(FileAbsentRule {
101        id: spec.id.clone(),
102        level: spec.level,
103        policy_url: spec.policy_url.clone(),
104        message: spec.message.clone(),
105        scope: Scope::from_paths_spec(paths)?,
106        patterns: patterns_of(paths),
107        git_tracked_only: spec.git_tracked_only,
108        fixer,
109    }))
110}
111
112fn patterns_of(spec: &PathsSpec) -> Vec<String> {
113    match spec {
114        PathsSpec::Single(s) => vec![s.clone()],
115        PathsSpec::Many(v) => v.clone(),
116        PathsSpec::IncludeExclude { include, .. } => include.clone(),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::test_support::{ctx, index, spec_yaml};
124    use std::path::Path;
125
126    #[test]
127    fn build_rejects_missing_paths_field() {
128        let spec = spec_yaml(
129            "id: t\n\
130             kind: file_absent\n\
131             level: error\n",
132        );
133        let err = build(&spec).unwrap_err().to_string();
134        assert!(err.contains("paths"), "unexpected: {err}");
135    }
136
137    #[test]
138    fn build_rejects_incompatible_fix_op() {
139        // file_absent supports `file_remove` only; any other
140        // op surfaces a config error so a typo doesn't silently
141        // disable the fix path.
142        let spec = spec_yaml(
143            "id: t\n\
144             kind: file_absent\n\
145             paths: \"*.bak\"\n\
146             level: error\n\
147             fix:\n  \
148               file_create:\n    \
149                 content: \"\"\n",
150        );
151        let err = build(&spec).unwrap_err().to_string();
152        assert!(err.contains("file_create"), "unexpected: {err}");
153    }
154
155    #[test]
156    fn build_accepts_file_remove_fix() {
157        let spec = spec_yaml(
158            "id: t\n\
159             kind: file_absent\n\
160             paths: \"*.bak\"\n\
161             level: error\n\
162             fix:\n  \
163               file_remove: {}\n",
164        );
165        let rule = build(&spec).expect("valid file_remove fix");
166        assert!(rule.fixer().is_some(), "fixer should be present");
167    }
168
169    #[test]
170    fn evaluate_passes_when_no_match_present() {
171        let spec = spec_yaml(
172            "id: t\n\
173             kind: file_absent\n\
174             paths: \"*.bak\"\n\
175             level: error\n",
176        );
177        let rule = build(&spec).unwrap();
178        let idx = index(&["src/main.rs", "README.md"]);
179        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
180        assert!(v.is_empty(), "unexpected: {v:?}");
181    }
182
183    #[test]
184    fn evaluate_fires_one_violation_per_match() {
185        let spec = spec_yaml(
186            "id: t\n\
187             kind: file_absent\n\
188             paths: \"**/*.bak\"\n\
189             level: error\n",
190        );
191        let rule = build(&spec).unwrap();
192        let idx = index(&["a.bak", "src/b.bak", "ok.txt"]);
193        let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
194        assert_eq!(v.len(), 2, "expected one violation per .bak: {v:?}");
195    }
196
197    #[test]
198    fn git_tracked_only_advertises_file_only_mode() {
199        // v0.9.11: the silent-no-op-outside-git-repo guarantee
200        // moved from a per-rule runtime check to an engine-side
201        // pre-filtered FileIndex. Calling `evaluate` directly
202        // bypasses the engine's filtering, so this unit test
203        // can no longer assert the no-op behaviour at the rule
204        // level — instead it asserts the rule advertises the
205        // correct `GitTrackedMode`, which is what tells the
206        // engine to substitute an empty index when the
207        // tracked-set is `None`. The end-to-end no-op behaviour
208        // is asserted by the e2e scenarios under
209        // `crates/alint-e2e/scenarios/check/git/`.
210        let spec = spec_yaml(
211            "id: t\n\
212             kind: file_absent\n\
213             paths: \"*.bak\"\n\
214             level: error\n\
215             git_tracked_only: true\n",
216        );
217        let rule = build(&spec).unwrap();
218        assert_eq!(
219            rule.git_tracked_mode(),
220            alint_core::GitTrackedMode::FileOnly,
221            "git_tracked_only on file_absent must advertise FileOnly mode",
222        );
223    }
224
225    #[test]
226    fn rule_advertises_full_index_requirement() {
227        // Existence-axis rules opt out of changed-mode
228        // filtering — an unchanged-but-already-committed `.env`
229        // should still fire.
230        let spec = spec_yaml(
231            "id: t\n\
232             kind: file_absent\n\
233             paths: \".env\"\n\
234             level: error\n",
235        );
236        let rule = build(&spec).unwrap();
237        assert!(rule.requires_full_index());
238    }
239
240    #[test]
241    fn build_rejects_scope_filter_on_cross_file_rule() {
242        // file_absent is a cross-file rule (requires_full_index =
243        // true); scope_filter is per-file-rules-only. The build
244        // path must reject it with a clear message pointing at
245        // the for_each_dir + when_iter: alternative.
246        let yaml = r#"
247id: t
248kind: file_absent
249paths: "*.bak"
250level: error
251scope_filter:
252  has_ancestor: Cargo.toml
253"#;
254        let spec = spec_yaml(yaml);
255        let err = build(&spec).unwrap_err().to_string();
256        assert!(
257            err.contains("scope_filter is supported on per-file rules only"),
258            "expected per-file-only message, got: {err}",
259        );
260        assert!(
261            err.contains("file_absent"),
262            "expected message to name the cross-file kind, got: {err}",
263        );
264    }
265}