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 evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
59        let found = ctx.index.files().any(|entry| {
60            if self.root_only && entry.path.components().count() != 1 {
61                return false;
62            }
63            if !self.scope.matches(&entry.path) {
64                return false;
65            }
66            if self.git_tracked_only && !ctx.is_git_tracked(&entry.path) {
67                return false;
68            }
69            true
70        });
71        if found {
72            Ok(Vec::new())
73        } else {
74            let message = self.message.clone().unwrap_or_else(|| {
75                let scope = if self.root_only {
76                    " at the repo root"
77                } else {
78                    ""
79                };
80                let tracked = if self.git_tracked_only {
81                    " (tracked in git)"
82                } else {
83                    ""
84                };
85                format!(
86                    "expected a file matching [{}]{scope}{tracked}",
87                    self.describe_patterns()
88                )
89            });
90            Ok(vec![Violation::new(message)])
91        }
92    }
93
94    fn fixer(&self) -> Option<&dyn Fixer> {
95        self.fixer.as_ref().map(|f| f as &dyn Fixer)
96    }
97}
98
99pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
100    let Some(paths) = &spec.paths else {
101        return Err(Error::rule_config(
102            &spec.id,
103            "file_exists requires a `paths` field",
104        ));
105    };
106    let patterns = patterns_of(paths);
107    let scope = Scope::from_paths_spec(paths)?;
108    let opts: Options = spec
109        .deserialize_options()
110        .unwrap_or(Options { root_only: false });
111    let fixer = match &spec.fix {
112        Some(FixSpec::FileCreate { file_create: cfg }) => {
113            let target = cfg
114                .path
115                .clone()
116                .or_else(|| first_literal_path(&patterns))
117                .ok_or_else(|| {
118                    Error::rule_config(
119                        &spec.id,
120                        "fix.file_create needs a `path` — none of the rule's `paths:` \
121                         entries is a literal filename",
122                    )
123                })?;
124            Some(FileCreateFixer::new(
125                target,
126                cfg.content.clone(),
127                cfg.create_parents,
128            ))
129        }
130        Some(other) => {
131            return Err(Error::rule_config(
132                &spec.id,
133                format!("fix.{} is not compatible with file_exists", other.op_name()),
134            ));
135        }
136        None => None,
137    };
138    Ok(Box::new(FileExistsRule {
139        id: spec.id.clone(),
140        level: spec.level,
141        policy_url: spec.policy_url.clone(),
142        message: spec.message.clone(),
143        scope,
144        patterns,
145        root_only: opts.root_only,
146        git_tracked_only: spec.git_tracked_only,
147        fixer,
148    }))
149}
150
151/// Best-effort: return the first entry in `patterns` that has no glob
152/// metacharacters (so it's a usable file path). Returns `None` if every
153/// pattern is a glob — in that case the caller must require an
154/// explicit `fix.file_create.path`.
155fn first_literal_path(patterns: &[String]) -> Option<PathBuf> {
156    patterns
157        .iter()
158        .find(|p| !p.chars().any(|c| matches!(c, '*' | '?' | '[' | '{')))
159        .map(PathBuf::from)
160}
161
162fn patterns_of(spec: &PathsSpec) -> Vec<String> {
163    match spec {
164        PathsSpec::Single(s) => vec![s.clone()],
165        PathsSpec::Many(v) => v.clone(),
166        PathsSpec::IncludeExclude { include, .. } => include.clone(),
167    }
168}