alint_rules/
file_absent.rs1use 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 git_tracked_only: bool,
24 fixer: Option<FileRemoveFixer>,
25}
26
27impl Rule for FileAbsentRule {
28 fn id(&self) -> &str {
29 &self.id
30 }
31 fn level(&self) -> Level {
32 self.level
33 }
34 fn policy_url(&self) -> Option<&str> {
35 self.policy_url.as_deref()
36 }
37 fn wants_git_tracked(&self) -> bool {
38 self.git_tracked_only
39 }
40
41 fn requires_full_index(&self) -> bool {
42 true
48 }
49
50 fn path_scope(&self) -> Option<&Scope> {
51 Some(&self.scope)
52 }
53
54 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
55 let mut violations = Vec::new();
56 for entry in ctx.index.files() {
57 if !self.scope.matches(&entry.path) {
58 continue;
59 }
60 if self.git_tracked_only && !ctx.is_git_tracked(&entry.path) {
61 continue;
62 }
63 let msg = self.message.clone().unwrap_or_else(|| {
64 let tracked = if self.git_tracked_only {
65 " and tracked in git"
66 } else {
67 ""
68 };
69 format!(
70 "file is forbidden (matches [{}]{tracked}): {}",
71 self.patterns.join(", "),
72 entry.path.display()
73 )
74 });
75 violations.push(Violation::new(msg).with_path(entry.path.clone()));
76 }
77 Ok(violations)
78 }
79
80 fn fixer(&self) -> Option<&dyn Fixer> {
81 self.fixer.as_ref().map(|f| f as &dyn Fixer)
82 }
83}
84
85pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
86 alint_core::reject_scope_filter_on_cross_file(spec, "file_absent")?;
87 let Some(paths) = &spec.paths else {
88 return Err(Error::rule_config(
89 &spec.id,
90 "file_absent requires a `paths` field",
91 ));
92 };
93 let fixer = match &spec.fix {
94 Some(FixSpec::FileRemove { .. }) => Some(FileRemoveFixer),
95 Some(other) => {
96 return Err(Error::rule_config(
97 &spec.id,
98 format!("fix.{} is not compatible with file_absent", other.op_name()),
99 ));
100 }
101 None => None,
102 };
103 Ok(Box::new(FileAbsentRule {
104 id: spec.id.clone(),
105 level: spec.level,
106 policy_url: spec.policy_url.clone(),
107 message: spec.message.clone(),
108 scope: Scope::from_paths_spec(paths)?,
109 patterns: patterns_of(paths),
110 git_tracked_only: spec.git_tracked_only,
111 fixer,
112 }))
113}
114
115fn patterns_of(spec: &PathsSpec) -> Vec<String> {
116 match spec {
117 PathsSpec::Single(s) => vec![s.clone()],
118 PathsSpec::Many(v) => v.clone(),
119 PathsSpec::IncludeExclude { include, .. } => include.clone(),
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::test_support::{ctx, index, spec_yaml};
127 use std::path::Path;
128
129 #[test]
130 fn build_rejects_missing_paths_field() {
131 let spec = spec_yaml(
132 "id: t\n\
133 kind: file_absent\n\
134 level: error\n",
135 );
136 let err = build(&spec).unwrap_err().to_string();
137 assert!(err.contains("paths"), "unexpected: {err}");
138 }
139
140 #[test]
141 fn build_rejects_incompatible_fix_op() {
142 let spec = spec_yaml(
146 "id: t\n\
147 kind: file_absent\n\
148 paths: \"*.bak\"\n\
149 level: error\n\
150 fix:\n \
151 file_create:\n \
152 content: \"\"\n",
153 );
154 let err = build(&spec).unwrap_err().to_string();
155 assert!(err.contains("file_create"), "unexpected: {err}");
156 }
157
158 #[test]
159 fn build_accepts_file_remove_fix() {
160 let spec = spec_yaml(
161 "id: t\n\
162 kind: file_absent\n\
163 paths: \"*.bak\"\n\
164 level: error\n\
165 fix:\n \
166 file_remove: {}\n",
167 );
168 let rule = build(&spec).expect("valid file_remove fix");
169 assert!(rule.fixer().is_some(), "fixer should be present");
170 }
171
172 #[test]
173 fn evaluate_passes_when_no_match_present() {
174 let spec = spec_yaml(
175 "id: t\n\
176 kind: file_absent\n\
177 paths: \"*.bak\"\n\
178 level: error\n",
179 );
180 let rule = build(&spec).unwrap();
181 let idx = index(&["src/main.rs", "README.md"]);
182 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
183 assert!(v.is_empty(), "unexpected: {v:?}");
184 }
185
186 #[test]
187 fn evaluate_fires_one_violation_per_match() {
188 let spec = spec_yaml(
189 "id: t\n\
190 kind: file_absent\n\
191 paths: \"**/*.bak\"\n\
192 level: error\n",
193 );
194 let rule = build(&spec).unwrap();
195 let idx = index(&["a.bak", "src/b.bak", "ok.txt"]);
196 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
197 assert_eq!(v.len(), 2, "expected one violation per .bak: {v:?}");
198 }
199
200 #[test]
201 fn evaluate_silent_when_git_tracked_only_outside_repo() {
202 let spec = spec_yaml(
208 "id: t\n\
209 kind: file_absent\n\
210 paths: \"*.bak\"\n\
211 level: error\n\
212 git_tracked_only: true\n",
213 );
214 let rule = build(&spec).unwrap();
215 let idx = index(&["a.bak"]);
216 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
217 assert!(
218 v.is_empty(),
219 "git_tracked_only without ctx.git_tracked must no-op: {v:?}",
220 );
221 }
222
223 #[test]
224 fn rule_advertises_full_index_requirement() {
225 let spec = spec_yaml(
229 "id: t\n\
230 kind: file_absent\n\
231 paths: \".env\"\n\
232 level: error\n",
233 );
234 let rule = build(&spec).unwrap();
235 assert!(rule.requires_full_index());
236 }
237
238 #[test]
239 fn build_rejects_scope_filter_on_cross_file_rule() {
240 let yaml = r#"
245id: t
246kind: file_absent
247paths: "*.bak"
248level: error
249scope_filter:
250 has_ancestor: Cargo.toml
251"#;
252 let spec = spec_yaml(yaml);
253 let err = build(&spec).unwrap_err().to_string();
254 assert!(
255 err.contains("scope_filter is supported on per-file rules only"),
256 "expected per-file-only message, got: {err}",
257 );
258 assert!(
259 err.contains("file_absent"),
260 "expected message to name the cross-file kind, got: {err}",
261 );
262 }
263}