1use std::path::PathBuf;
5
6use alint_core::{
7 Context, Error, FixSpec, Fixer, Level, PathsSpec, Result, Rule, RuleSpec, Scope, Violation,
8};
9use serde::Deserialize;
10
11use crate::fixers::FileCreateFixer;
12
13#[derive(Debug, Deserialize)]
14#[serde(deny_unknown_fields)]
15struct Options {
16 #[serde(default)]
17 root_only: bool,
18}
19
20#[derive(Debug)]
21pub struct FileExistsRule {
22 id: String,
23 level: Level,
24 policy_url: Option<String>,
25 message: Option<String>,
26 scope: Scope,
27 patterns: Vec<String>,
28 root_only: bool,
29 git_tracked_only: bool,
34 fixer: Option<FileCreateFixer>,
35}
36
37impl FileExistsRule {
38 fn describe_patterns(&self) -> String {
39 self.patterns.join(", ")
40 }
41}
42
43impl Rule for FileExistsRule {
44 fn id(&self) -> &str {
45 &self.id
46 }
47 fn level(&self) -> Level {
48 self.level
49 }
50 fn policy_url(&self) -> Option<&str> {
51 self.policy_url.as_deref()
52 }
53
54 fn wants_git_tracked(&self) -> bool {
55 self.git_tracked_only
56 }
57
58 fn requires_full_index(&self) -> bool {
59 true
65 }
66
67 fn path_scope(&self) -> Option<&Scope> {
68 Some(&self.scope)
69 }
70
71 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
72 let found = ctx.index.files().any(|entry| {
73 if self.root_only && entry.path.components().count() != 1 {
74 return false;
75 }
76 if !self.scope.matches(&entry.path) {
77 return false;
78 }
79 if self.git_tracked_only && !ctx.is_git_tracked(&entry.path) {
80 return false;
81 }
82 true
83 });
84 if found {
85 Ok(Vec::new())
86 } else {
87 let message = self.message.clone().unwrap_or_else(|| {
88 let scope = if self.root_only {
89 " at the repo root"
90 } else {
91 ""
92 };
93 let tracked = if self.git_tracked_only {
94 " (tracked in git)"
95 } else {
96 ""
97 };
98 format!(
99 "expected a file matching [{}]{scope}{tracked}",
100 self.describe_patterns()
101 )
102 });
103 Ok(vec![Violation::new(message)])
104 }
105 }
106
107 fn fixer(&self) -> Option<&dyn Fixer> {
108 self.fixer.as_ref().map(|f| f as &dyn Fixer)
109 }
110}
111
112pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
113 let Some(paths) = &spec.paths else {
114 return Err(Error::rule_config(
115 &spec.id,
116 "file_exists requires a `paths` field",
117 ));
118 };
119 let patterns = patterns_of(paths);
120 let scope = Scope::from_paths_spec(paths)?;
121 let opts: Options = spec
122 .deserialize_options()
123 .unwrap_or(Options { root_only: false });
124 let fixer = match &spec.fix {
125 Some(FixSpec::FileCreate { file_create: cfg }) => {
126 let target = cfg
127 .path
128 .clone()
129 .or_else(|| first_literal_path(&patterns))
130 .ok_or_else(|| {
131 Error::rule_config(
132 &spec.id,
133 "fix.file_create needs a `path` — none of the rule's `paths:` \
134 entries is a literal filename",
135 )
136 })?;
137 let source = alint_core::resolve_content_source(
138 &spec.id,
139 "file_create",
140 &cfg.content,
141 &cfg.content_from,
142 )?;
143 Some(FileCreateFixer::new(target, source, cfg.create_parents))
144 }
145 Some(other) => {
146 return Err(Error::rule_config(
147 &spec.id,
148 format!("fix.{} is not compatible with file_exists", other.op_name()),
149 ));
150 }
151 None => None,
152 };
153 Ok(Box::new(FileExistsRule {
154 id: spec.id.clone(),
155 level: spec.level,
156 policy_url: spec.policy_url.clone(),
157 message: spec.message.clone(),
158 scope,
159 patterns,
160 root_only: opts.root_only,
161 git_tracked_only: spec.git_tracked_only,
162 fixer,
163 }))
164}
165
166fn first_literal_path(patterns: &[String]) -> Option<PathBuf> {
171 patterns
172 .iter()
173 .find(|p| !p.chars().any(|c| matches!(c, '*' | '?' | '[' | '{')))
174 .map(PathBuf::from)
175}
176
177fn patterns_of(spec: &PathsSpec) -> Vec<String> {
178 match spec {
179 PathsSpec::Single(s) => vec![s.clone()],
180 PathsSpec::Many(v) => v.clone(),
181 PathsSpec::IncludeExclude { include, .. } => include.clone(),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::test_support::{ctx, index, spec_yaml};
189 use std::path::Path;
190
191 #[test]
192 fn build_rejects_missing_paths_field() {
193 let spec = spec_yaml(
194 "id: t\n\
195 kind: file_exists\n\
196 level: error\n",
197 );
198 let err = build(&spec).unwrap_err().to_string();
199 assert!(err.contains("paths"), "unexpected: {err}");
200 }
201
202 #[test]
203 fn build_accepts_root_only_option() {
204 let spec = spec_yaml(
212 "id: t\n\
213 kind: file_exists\n\
214 paths: \"LICENSE\"\n\
215 level: error\n\
216 root_only: true\n",
217 );
218 assert!(build(&spec).is_ok());
219 }
220
221 #[test]
222 fn build_rejects_incompatible_fix_op() {
223 let spec = spec_yaml(
227 "id: t\n\
228 kind: file_exists\n\
229 paths: \"LICENSE\"\n\
230 level: error\n\
231 fix:\n \
232 file_remove: {}\n",
233 );
234 let err = build(&spec).unwrap_err().to_string();
235 assert!(err.contains("file_remove"), "unexpected: {err}");
236 }
237
238 #[test]
239 fn build_file_create_needs_explicit_path_for_glob_only_paths() {
240 let spec = spec_yaml(
244 "id: t\n\
245 kind: file_exists\n\
246 paths: \"docs/**/*.md\"\n\
247 level: error\n\
248 fix:\n \
249 file_create:\n \
250 content: \"# title\\n\"\n",
251 );
252 let err = build(&spec).unwrap_err().to_string();
253 assert!(err.contains("path"), "unexpected: {err}");
254 }
255
256 #[test]
257 fn evaluate_passes_when_matching_file_present() {
258 let spec = spec_yaml(
259 "id: t\n\
260 kind: file_exists\n\
261 paths: \"README.md\"\n\
262 level: error\n",
263 );
264 let rule = build(&spec).unwrap();
265 let idx = index(&["README.md", "Cargo.toml"]);
266 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
267 assert!(v.is_empty(), "unexpected violations: {v:?}");
268 }
269
270 #[test]
271 fn evaluate_fires_when_no_matching_file_present() {
272 let spec = spec_yaml(
273 "id: t\n\
274 kind: file_exists\n\
275 paths: \"LICENSE\"\n\
276 level: error\n",
277 );
278 let rule = build(&spec).unwrap();
279 let idx = index(&["README.md"]);
280 let v = rule.evaluate(&ctx(Path::new("/fake"), &idx)).unwrap();
281 assert_eq!(v.len(), 1, "expected one violation; got: {v:?}");
282 }
283
284 #[test]
285 fn evaluate_root_only_excludes_nested_matches() {
286 let spec = spec_yaml(
290 "id: t\n\
291 kind: file_exists\n\
292 paths: \"LICENSE\"\n\
293 level: error\n\
294 root_only: true\n",
295 );
296 let rule = build(&spec).unwrap();
297 let idx_only_nested = index(&["pkg/LICENSE"]);
298 let v = rule
299 .evaluate(&ctx(Path::new("/fake"), &idx_only_nested))
300 .unwrap();
301 assert_eq!(v.len(), 1, "nested match shouldn't satisfy root_only");
302 }
303
304 #[test]
305 fn first_literal_path_picks_first_non_glob() {
306 let patterns = vec!["docs/**/*.md".into(), "LICENSE".into(), "README.md".into()];
307 assert_eq!(
308 first_literal_path(&patterns).as_deref(),
309 Some(Path::new("LICENSE")),
310 );
311 }
312
313 #[test]
314 fn first_literal_path_returns_none_when_all_glob() {
315 let patterns = vec!["docs/**/*.md".into(), "src/[a-z]*.rs".into()];
316 assert!(first_literal_path(&patterns).is_none());
317 }
318
319 #[test]
320 fn patterns_of_handles_every_paths_spec_shape() {
321 assert_eq!(patterns_of(&PathsSpec::Single("a".into())), vec!["a"]);
322 assert_eq!(
323 patterns_of(&PathsSpec::Many(vec!["a".into(), "b".into()])),
324 vec!["a", "b"],
325 );
326 assert_eq!(
327 patterns_of(&PathsSpec::IncludeExclude {
328 include: vec!["a".into()],
329 exclude: vec!["b".into()],
330 }),
331 vec!["a"],
332 );
333 }
334}