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 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 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 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 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 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 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 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}