Skip to main content

alint_core/
rule.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::error::Result;
5use crate::facts::FactValues;
6use crate::level::Level;
7use crate::registry::RuleRegistry;
8use crate::walker::FileIndex;
9
10/// A single linting violation produced by a rule.
11#[derive(Debug, Clone)]
12pub struct Violation {
13    pub path: Option<PathBuf>,
14    pub message: String,
15    pub line: Option<usize>,
16    pub column: Option<usize>,
17}
18
19impl Violation {
20    pub fn new(message: impl Into<String>) -> Self {
21        Self {
22            path: None,
23            message: message.into(),
24            line: None,
25            column: None,
26        }
27    }
28
29    #[must_use]
30    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
31        self.path = Some(path.into());
32        self
33    }
34
35    #[must_use]
36    pub fn with_location(mut self, line: usize, column: usize) -> Self {
37        self.line = Some(line);
38        self.column = Some(column);
39        self
40    }
41}
42
43/// The collected outcome of evaluating a single rule.
44#[derive(Debug, Clone)]
45pub struct RuleResult {
46    pub rule_id: String,
47    pub level: Level,
48    pub policy_url: Option<String>,
49    pub violations: Vec<Violation>,
50    /// Whether the rule declares a [`Fixer`] — surfaced here so
51    /// the human formatter can tag violations as `fixable`
52    /// without threading the rule registry into the renderer.
53    pub is_fixable: bool,
54}
55
56impl RuleResult {
57    pub fn passed(&self) -> bool {
58        self.violations.is_empty()
59    }
60}
61
62/// Execution context handed to each rule during evaluation.
63///
64/// - `registry` — available for rules that need to build and evaluate nested
65///   rules at runtime (e.g. `for_each_dir`). Tests that don't exercise
66///   nested evaluation can set this to `None`.
67/// - `facts` — resolved fact values, computed once per `Engine::run`.
68/// - `vars` — user-supplied string variables from the config's `vars:` section.
69/// - `git_tracked` — set of repo paths reported by `git ls-files`,
70///   computed once per run when at least one rule has
71///   `git_tracked_only: true`. `None` outside a git repo or when
72///   no rule asked for it. Rules that opt in consult it via
73///   [`Context::is_git_tracked`].
74#[derive(Debug)]
75pub struct Context<'a> {
76    pub root: &'a Path,
77    pub index: &'a FileIndex,
78    pub registry: Option<&'a RuleRegistry>,
79    pub facts: Option<&'a FactValues>,
80    pub vars: Option<&'a HashMap<String, String>>,
81    pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
82}
83
84impl Context<'_> {
85    /// True if `rel_path` is in git's index. Returns `false` when
86    /// no tracked-set was computed (no git repo, or no rule asked
87    /// for it). Rules that opt into `git_tracked_only` therefore
88    /// silently skip every entry outside a git repo, which is the
89    /// right behaviour for the canonical "don't let X be
90    /// committed" use case.
91    pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
92        match self.git_tracked {
93            Some(set) => set.contains(rel_path),
94            None => false,
95        }
96    }
97
98    /// True if the directory at `rel_path` contains at least one
99    /// git-tracked file. Used by `dir_*` rules opting into
100    /// `git_tracked_only`. Same `None`-means-untracked semantics
101    /// as [`Context::is_git_tracked`].
102    pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
103        match self.git_tracked {
104            Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
105            None => false,
106        }
107    }
108}
109
110/// Trait every built-in and plugin rule implements.
111pub trait Rule: Send + Sync + std::fmt::Debug {
112    fn id(&self) -> &str;
113    fn level(&self) -> Level;
114    fn policy_url(&self) -> Option<&str> {
115        None
116    }
117    /// Whether this rule needs the git-tracked-paths set on
118    /// [`Context`]. Default `false`; rule kinds that support
119    /// `git_tracked_only` override to return `true` only when
120    /// the user actually opted in. The engine collects the set
121    /// (via `git ls-files`) once per run when ANY rule returns
122    /// `true`, so the cost is paid at most once even if many
123    /// rules opt in.
124    fn wants_git_tracked(&self) -> bool {
125        false
126    }
127
128    /// In `--changed` mode, return `true` to evaluate this rule
129    /// against the **full** [`FileIndex`] rather than the
130    /// changed-only filtered subset. Default `false` (per-file
131    /// semantics — the rule sees only changed files in scope).
132    ///
133    /// Cross-file rules (`pair`, `for_each_dir`,
134    /// `every_matching_has`, `unique_by`, `dir_contains`,
135    /// `dir_only_contains`) override to `true` because their
136    /// inputs span the whole tree by definition — a verdict on
137    /// the changed file depends on what's still in the rest of
138    /// the tree. Existence rules (`file_exists`, `file_absent`,
139    /// `dir_exists`, `dir_absent`) likewise consult the whole
140    /// tree to answer "is X present?" correctly.
141    fn requires_full_index(&self) -> bool {
142        false
143    }
144
145    /// In `--changed` mode, return the [`Scope`](crate::Scope)
146    /// this rule is scoped to (typically the rule's `paths:`
147    /// field). The engine intersects the scope with the
148    /// changed-set; rules whose scope doesn't intersect are
149    /// skipped, which is the optimisation `--changed` exists
150    /// for.
151    ///
152    /// Default `None` ("no scope information") means the rule is
153    /// always evaluated. Cross-file rules deliberately leave this
154    /// as `None` (they always evaluate per the roadmap contract).
155    /// Per-file rules with a single `Scope` field should override
156    /// to return `Some(&self.scope)`.
157    fn path_scope(&self) -> Option<&crate::scope::Scope> {
158        None
159    }
160
161    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
162
163    /// Optional automatic-fix strategy. Rules whose violations can be
164    /// mechanically corrected (e.g. creating a missing file, removing a
165    /// forbidden one, renaming to the correct case) return a
166    /// [`Fixer`] here; the default implementation reports the rule as
167    /// unfixable.
168    fn fixer(&self) -> Option<&dyn Fixer> {
169        None
170    }
171}
172
173/// Runtime context for applying a fix.
174#[derive(Debug)]
175pub struct FixContext<'a> {
176    pub root: &'a Path,
177    /// When true, fixers must describe what they would do without
178    /// touching the filesystem.
179    pub dry_run: bool,
180    /// Max bytes a content-editing fix will read + rewrite.
181    /// `None` means no cap. Honored by the `read_for_fix` helper
182    /// (and any custom fixer that opts in).
183    pub fix_size_limit: Option<u64>,
184}
185
186/// The result of applying (or simulating) one fix against one violation.
187#[derive(Debug, Clone)]
188pub enum FixOutcome {
189    /// The fix was applied (or would be, under `dry_run`). The string
190    /// is a human-readable one-liner — e.g. `"created LICENSE"`,
191    /// `"would remove target/debug.log"`.
192    Applied(String),
193    /// The fixer intentionally did nothing; the string explains why
194    /// (e.g. `"already exists"`, `"no path on violation"`). This is
195    /// distinct from a hard error returned via `Result::Err`.
196    Skipped(String),
197}
198
199/// A mechanical corrector for a specific rule's violations.
200pub trait Fixer: Send + Sync + std::fmt::Debug {
201    /// Short human-readable summary of what this fixer does,
202    /// independent of any specific violation.
203    fn describe(&self) -> String;
204
205    /// Apply the fix against a single violation.
206    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
207}
208
209/// Result of [`read_for_fix`] — either the bytes of the file,
210/// or a [`FixOutcome::Skipped`] the caller should return.
211///
212/// Content-editing fixers (`file_prepend`, `file_append`,
213/// `file_trim_trailing_whitespace`, …) funnel their initial read
214/// through this helper so the `fix_size_limit` guard is enforced
215/// uniformly: over-limit files are reported as `Skipped` with a
216/// clear reason, and a one-line warning is printed to stderr so
217/// scripted runs notice.
218#[derive(Debug)]
219pub enum ReadForFix {
220    Bytes(Vec<u8>),
221    Skipped(FixOutcome),
222}
223
224/// Check whether `abs` is within the `fix_size_limit` on `ctx`.
225/// Returns `Some(outcome)` when the file is over-limit (the
226/// caller returns this directly); returns `None` when the fix
227/// can proceed. Emits a one-line stderr warning on over-limit.
228///
229/// Use this in fixers that modify the file without reading the
230/// full body (e.g. streaming append). For read-modify-write
231/// flows, prefer [`read_for_fix`] which folds the check in.
232pub fn check_fix_size(
233    abs: &Path,
234    display_path: &std::path::Path,
235    ctx: &FixContext<'_>,
236) -> Result<Option<FixOutcome>> {
237    let Some(limit) = ctx.fix_size_limit else {
238        return Ok(None);
239    };
240    let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
241        path: abs.to_path_buf(),
242        source,
243    })?;
244    if metadata.len() > limit {
245        let reason = format!(
246            "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
247             `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
248             to fix files this large.",
249            display_path.display(),
250            metadata.len(),
251            limit,
252        );
253        eprintln!("alint: warning: {reason}");
254        return Ok(Some(FixOutcome::Skipped(reason)));
255    }
256    Ok(None)
257}
258
259/// Read `abs` subject to the size limit on `ctx`. Over-limit
260/// files return `ReadForFix::Skipped(Outcome::Skipped(_))` and
261/// emit a one-line stderr warning; in-limit files return
262/// `ReadForFix::Bytes(...)`. Pass-through I/O errors propagate.
263pub fn read_for_fix(
264    abs: &Path,
265    display_path: &std::path::Path,
266    ctx: &FixContext<'_>,
267) -> Result<ReadForFix> {
268    if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
269        return Ok(ReadForFix::Skipped(outcome));
270    }
271    let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
272        path: abs.to_path_buf(),
273        source,
274    })?;
275    Ok(ReadForFix::Bytes(bytes))
276}