Skip to main content

alint_rules/
file_exists.rs

1//! `file_exists` — require that at least one file matching any of the given
2//! globs exists in the repository.
3
4use 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    /// When `true`, only consider walked entries that are also
30    /// in git's index. Outside a git repo this becomes a silent
31    /// no-op — no entries qualify, so the rule reports the
32    /// "missing" violation as if no file existed.
33    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        // Existence is an aggregate verdict over the whole tree —
60        // "is at least one matching file present?". In `--changed`
61        // mode, evaluate against the full index (so an unchanged
62        // LICENSE still counts) but let the engine skip the rule
63        // entirely when its scope doesn't intersect the diff.
64        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
166/// Best-effort: return the first entry in `patterns` that has no glob
167/// metacharacters (so it's a usable file path). Returns `None` if every
168/// pattern is a glob — in that case the caller must require an
169/// explicit `fix.file_create.path`.
170fn 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        // `root_only: true` is the supported option; building
205        // it should succeed and produce a configured rule.
206        // (Unknown options are tolerated by file_exists' build
207        // path via `.unwrap_or(default)`; the JSON Schema and
208        // DSL loader catch typos at config-load time before
209        // we get here, which is the right layer for that
210        // check.)
211        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        // file_exists supports `file_create` only; `file_remove`
224        // (or any other op) must surface a clear config error so
225        // a typo doesn't silently disable the fix path.
226        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        // When every entry in `paths:` is a glob, the fixer
241        // can't pick a literal target; the user must supply
242        // `fix.file_create.path` explicitly.
243        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        // `root_only: true` only counts entries whose path has
287        // no parent component — `LICENSE` qualifies,
288        // `pkg/LICENSE` does not.
289        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}