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            Some(FileCreateFixer::new(
138                target,
139                cfg.content.clone(),
140                cfg.create_parents,
141            ))
142        }
143        Some(other) => {
144            return Err(Error::rule_config(
145                &spec.id,
146                format!("fix.{} is not compatible with file_exists", other.op_name()),
147            ));
148        }
149        None => None,
150    };
151    Ok(Box::new(FileExistsRule {
152        id: spec.id.clone(),
153        level: spec.level,
154        policy_url: spec.policy_url.clone(),
155        message: spec.message.clone(),
156        scope,
157        patterns,
158        root_only: opts.root_only,
159        git_tracked_only: spec.git_tracked_only,
160        fixer,
161    }))
162}
163
164/// Best-effort: return the first entry in `patterns` that has no glob
165/// metacharacters (so it's a usable file path). Returns `None` if every
166/// pattern is a glob — in that case the caller must require an
167/// explicit `fix.file_create.path`.
168fn first_literal_path(patterns: &[String]) -> Option<PathBuf> {
169    patterns
170        .iter()
171        .find(|p| !p.chars().any(|c| matches!(c, '*' | '?' | '[' | '{')))
172        .map(PathBuf::from)
173}
174
175fn patterns_of(spec: &PathsSpec) -> Vec<String> {
176    match spec {
177        PathsSpec::Single(s) => vec![s.clone()],
178        PathsSpec::Many(v) => v.clone(),
179        PathsSpec::IncludeExclude { include, .. } => include.clone(),
180    }
181}