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/// - `git_blame` — per-file `git blame` cache, computed lazily
75///   when at least one rule reports `wants_git_blame()`. `None`
76///   when no rule asked for it. Rules consult it via
77///   [`crate::git::BlameCache::get`]; both "outside a git repo"
78///   and "blame failed for this file" surface as a `None`
79///   lookup, which the rule treats as "silent no-op."
80#[derive(Debug)]
81pub struct Context<'a> {
82    pub root: &'a Path,
83    pub index: &'a FileIndex,
84    pub registry: Option<&'a RuleRegistry>,
85    pub facts: Option<&'a FactValues>,
86    pub vars: Option<&'a HashMap<String, String>>,
87    pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
88    pub git_blame: Option<&'a crate::git::BlameCache>,
89}
90
91impl Context<'_> {
92    /// True if `rel_path` is in git's index. Returns `false` when
93    /// no tracked-set was computed (no git repo, or no rule asked
94    /// for it). Rules that opt into `git_tracked_only` therefore
95    /// silently skip every entry outside a git repo, which is the
96    /// right behaviour for the canonical "don't let X be
97    /// committed" use case.
98    pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
99        match self.git_tracked {
100            Some(set) => set.contains(rel_path),
101            None => false,
102        }
103    }
104
105    /// True if the directory at `rel_path` contains at least one
106    /// git-tracked file. Used by `dir_*` rules opting into
107    /// `git_tracked_only`. Same `None`-means-untracked semantics
108    /// as [`Context::is_git_tracked`].
109    pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
110        match self.git_tracked {
111            Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
112            None => false,
113        }
114    }
115}
116
117/// Trait every built-in and plugin rule implements.
118pub trait Rule: Send + Sync + std::fmt::Debug {
119    fn id(&self) -> &str;
120    fn level(&self) -> Level;
121    fn policy_url(&self) -> Option<&str> {
122        None
123    }
124    /// Whether this rule needs the git-tracked-paths set on
125    /// [`Context`]. Default `false`; rule kinds that support
126    /// `git_tracked_only` override to return `true` only when
127    /// the user actually opted in. The engine collects the set
128    /// (via `git ls-files`) once per run when ANY rule returns
129    /// `true`, so the cost is paid at most once even if many
130    /// rules opt in.
131    fn wants_git_tracked(&self) -> bool {
132        false
133    }
134
135    /// Whether this rule needs `git blame` output on
136    /// [`Context`]. Default `false`; the `git_blame_age` rule
137    /// kind overrides to return `true`. The engine builds the
138    /// shared [`crate::git::BlameCache`] once per run when any
139    /// rule opts in, so multiple blame-aware rules over
140    /// overlapping `paths:` re-use the parsed result.
141    fn wants_git_blame(&self) -> bool {
142        false
143    }
144
145    /// In `--changed` mode, return `true` to evaluate this rule
146    /// against the **full** [`FileIndex`] rather than the
147    /// changed-only filtered subset. Default `false` (per-file
148    /// semantics — the rule sees only changed files in scope).
149    ///
150    /// Cross-file rules (`pair`, `for_each_dir`,
151    /// `every_matching_has`, `unique_by`, `dir_contains`,
152    /// `dir_only_contains`) override to `true` because their
153    /// inputs span the whole tree by definition — a verdict on
154    /// the changed file depends on what's still in the rest of
155    /// the tree. Existence rules (`file_exists`, `file_absent`,
156    /// `dir_exists`, `dir_absent`) likewise consult the whole
157    /// tree to answer "is X present?" correctly.
158    fn requires_full_index(&self) -> bool {
159        false
160    }
161
162    /// In `--changed` mode, return the [`Scope`](crate::Scope)
163    /// this rule is scoped to (typically the rule's `paths:`
164    /// field). The engine intersects the scope with the
165    /// changed-set; rules whose scope doesn't intersect are
166    /// skipped, which is the optimisation `--changed` exists
167    /// for.
168    ///
169    /// Default `None` ("no scope information") means the rule is
170    /// always evaluated. Cross-file rules deliberately leave this
171    /// as `None` (they always evaluate per the roadmap contract).
172    /// Per-file rules with a single `Scope` field should override
173    /// to return `Some(&self.scope)`.
174    fn path_scope(&self) -> Option<&crate::scope::Scope> {
175        None
176    }
177
178    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
179
180    /// Optional automatic-fix strategy. Rules whose violations can be
181    /// mechanically corrected (e.g. creating a missing file, removing a
182    /// forbidden one, renaming to the correct case) return a
183    /// [`Fixer`] here; the default implementation reports the rule as
184    /// unfixable.
185    fn fixer(&self) -> Option<&dyn Fixer> {
186        None
187    }
188}
189
190/// Runtime context for applying a fix.
191#[derive(Debug)]
192pub struct FixContext<'a> {
193    pub root: &'a Path,
194    /// When true, fixers must describe what they would do without
195    /// touching the filesystem.
196    pub dry_run: bool,
197    /// Max bytes a content-editing fix will read + rewrite.
198    /// `None` means no cap. Honored by the `read_for_fix` helper
199    /// (and any custom fixer that opts in).
200    pub fix_size_limit: Option<u64>,
201}
202
203/// The result of applying (or simulating) one fix against one violation.
204#[derive(Debug, Clone)]
205pub enum FixOutcome {
206    /// The fix was applied (or would be, under `dry_run`). The string
207    /// is a human-readable one-liner — e.g. `"created LICENSE"`,
208    /// `"would remove target/debug.log"`.
209    Applied(String),
210    /// The fixer intentionally did nothing; the string explains why
211    /// (e.g. `"already exists"`, `"no path on violation"`). This is
212    /// distinct from a hard error returned via `Result::Err`.
213    Skipped(String),
214}
215
216/// A mechanical corrector for a specific rule's violations.
217pub trait Fixer: Send + Sync + std::fmt::Debug {
218    /// Short human-readable summary of what this fixer does,
219    /// independent of any specific violation.
220    fn describe(&self) -> String;
221
222    /// Apply the fix against a single violation.
223    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
224}
225
226/// Result of [`read_for_fix`] — either the bytes of the file,
227/// or a [`FixOutcome::Skipped`] the caller should return.
228///
229/// Content-editing fixers (`file_prepend`, `file_append`,
230/// `file_trim_trailing_whitespace`, …) funnel their initial read
231/// through this helper so the `fix_size_limit` guard is enforced
232/// uniformly: over-limit files are reported as `Skipped` with a
233/// clear reason, and a one-line warning is printed to stderr so
234/// scripted runs notice.
235#[derive(Debug)]
236pub enum ReadForFix {
237    Bytes(Vec<u8>),
238    Skipped(FixOutcome),
239}
240
241/// Check whether `abs` is within the `fix_size_limit` on `ctx`.
242/// Returns `Some(outcome)` when the file is over-limit (the
243/// caller returns this directly); returns `None` when the fix
244/// can proceed. Emits a one-line stderr warning on over-limit.
245///
246/// Use this in fixers that modify the file without reading the
247/// full body (e.g. streaming append). For read-modify-write
248/// flows, prefer [`read_for_fix`] which folds the check in.
249pub fn check_fix_size(
250    abs: &Path,
251    display_path: &std::path::Path,
252    ctx: &FixContext<'_>,
253) -> Result<Option<FixOutcome>> {
254    let Some(limit) = ctx.fix_size_limit else {
255        return Ok(None);
256    };
257    let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
258        path: abs.to_path_buf(),
259        source,
260    })?;
261    if metadata.len() > limit {
262        let reason = format!(
263            "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
264             `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
265             to fix files this large.",
266            display_path.display(),
267            metadata.len(),
268            limit,
269        );
270        eprintln!("alint: warning: {reason}");
271        return Ok(Some(FixOutcome::Skipped(reason)));
272    }
273    Ok(None)
274}
275
276/// Read `abs` subject to the size limit on `ctx`. Over-limit
277/// files return `ReadForFix::Skipped(Outcome::Skipped(_))` and
278/// emit a one-line stderr warning; in-limit files return
279/// `ReadForFix::Bytes(...)`. Pass-through I/O errors propagate.
280pub fn read_for_fix(
281    abs: &Path,
282    display_path: &std::path::Path,
283    ctx: &FixContext<'_>,
284) -> Result<ReadForFix> {
285    if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
286        return Ok(ReadForFix::Skipped(outcome));
287    }
288    let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
289        path: abs.to_path_buf(),
290        source,
291    })?;
292    Ok(ReadForFix::Bytes(bytes))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn empty_index() -> FileIndex {
300        FileIndex::default()
301    }
302
303    #[test]
304    fn violation_builder_sets_fields_via_chain() {
305        let v = Violation::new("trailing whitespace")
306            .with_path("src/main.rs")
307            .with_location(12, 4);
308        assert_eq!(v.message, "trailing whitespace");
309        assert_eq!(v.path.as_deref(), Some(Path::new("src/main.rs")));
310        assert_eq!(v.line, Some(12));
311        assert_eq!(v.column, Some(4));
312    }
313
314    #[test]
315    fn violation_new_starts_with_no_path_or_location() {
316        let v = Violation::new("global note");
317        assert!(v.path.is_none());
318        assert!(v.line.is_none());
319        assert!(v.column.is_none());
320    }
321
322    #[test]
323    fn rule_result_passed_iff_violations_empty() {
324        let mut r = RuleResult {
325            rule_id: "x".into(),
326            level: Level::Error,
327            policy_url: None,
328            violations: Vec::new(),
329            is_fixable: false,
330        };
331        assert!(r.passed());
332        r.violations.push(Violation::new("oops"));
333        assert!(!r.passed());
334    }
335
336    #[test]
337    fn context_is_git_tracked_returns_false_outside_repo() {
338        let idx = empty_index();
339        let ctx = Context {
340            root: Path::new("/tmp"),
341            index: &idx,
342            registry: None,
343            facts: None,
344            vars: None,
345            git_tracked: None, // outside-a-repo / no rule opted in
346            git_blame: None,
347        };
348        assert!(!ctx.is_git_tracked(Path::new("anything.rs")));
349        assert!(!ctx.dir_has_tracked_files(Path::new("src")));
350    }
351
352    #[test]
353    fn context_is_git_tracked_consults_set_when_present() {
354        let mut tracked: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
355        tracked.insert(PathBuf::from("src/main.rs"));
356        let idx = empty_index();
357        let ctx = Context {
358            root: Path::new("/tmp"),
359            index: &idx,
360            registry: None,
361            facts: None,
362            vars: None,
363            git_tracked: Some(&tracked),
364            git_blame: None,
365        };
366        assert!(ctx.is_git_tracked(Path::new("src/main.rs")));
367        assert!(!ctx.is_git_tracked(Path::new("README.md")));
368    }
369
370    /// Stand-in `Rule` impl that returns the trait defaults.
371    /// Lets us assert the documented defaults without dragging
372    /// in a real registered rule.
373    #[derive(Debug)]
374    struct DefaultRule;
375
376    impl Rule for DefaultRule {
377        fn id(&self) -> &'static str {
378            "default"
379        }
380        fn level(&self) -> Level {
381            Level::Warning
382        }
383        fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
384            Ok(Vec::new())
385        }
386    }
387
388    #[test]
389    fn rule_trait_defaults_are_safe_no_ops() {
390        let r = DefaultRule;
391        assert_eq!(r.policy_url(), None);
392        assert!(!r.wants_git_tracked());
393        assert!(!r.wants_git_blame());
394        assert!(!r.requires_full_index());
395        assert!(r.path_scope().is_none());
396        assert!(r.fixer().is_none());
397    }
398
399    #[test]
400    fn check_fix_size_returns_none_when_limit_disabled() {
401        let dir = tempfile::tempdir().unwrap();
402        let f = dir.path().join("a.txt");
403        std::fs::write(&f, b"hello").unwrap();
404        let ctx = FixContext {
405            root: dir.path(),
406            dry_run: false,
407            fix_size_limit: None,
408        };
409        let outcome = check_fix_size(&f, Path::new("a.txt"), &ctx).unwrap();
410        assert!(outcome.is_none());
411    }
412
413    #[test]
414    fn check_fix_size_skips_over_limit_files() {
415        let dir = tempfile::tempdir().unwrap();
416        let f = dir.path().join("big.txt");
417        std::fs::write(&f, vec![b'x'; 1024]).unwrap();
418        let ctx = FixContext {
419            root: dir.path(),
420            dry_run: false,
421            fix_size_limit: Some(64),
422        };
423        let outcome = check_fix_size(&f, Path::new("big.txt"), &ctx).unwrap();
424        match outcome {
425            Some(FixOutcome::Skipped(reason)) => {
426                assert!(reason.contains("exceeds fix_size_limit"));
427                assert!(reason.contains("big.txt"));
428            }
429            other => panic!("expected Skipped, got {other:?}"),
430        }
431    }
432
433    #[test]
434    fn read_for_fix_returns_bytes_when_in_limit() {
435        let dir = tempfile::tempdir().unwrap();
436        let f = dir.path().join("a.txt");
437        std::fs::write(&f, b"hello").unwrap();
438        let ctx = FixContext {
439            root: dir.path(),
440            dry_run: false,
441            fix_size_limit: Some(1 << 20),
442        };
443        match read_for_fix(&f, Path::new("a.txt"), &ctx).unwrap() {
444            ReadForFix::Bytes(b) => assert_eq!(b, b"hello"),
445            ReadForFix::Skipped(_) => panic!("expected Bytes, got Skipped"),
446        }
447    }
448
449    #[test]
450    fn read_for_fix_returns_skipped_when_over_limit() {
451        let dir = tempfile::tempdir().unwrap();
452        let f = dir.path().join("big.txt");
453        std::fs::write(&f, vec![b'x'; 1024]).unwrap();
454        let ctx = FixContext {
455            root: dir.path(),
456            dry_run: false,
457            fix_size_limit: Some(64),
458        };
459        match read_for_fix(&f, Path::new("big.txt"), &ctx).unwrap() {
460            ReadForFix::Skipped(FixOutcome::Skipped(_)) => {}
461            ReadForFix::Skipped(FixOutcome::Applied(_)) => {
462                panic!("expected Skipped, got Skipped(Applied)")
463            }
464            ReadForFix::Bytes(_) => panic!("expected Skipped, got Bytes"),
465        }
466    }
467
468    #[test]
469    fn fix_outcome_variants_are_constructible() {
470        // Sanity: documented variant shapes haven't drifted.
471        let _applied = FixOutcome::Applied("created LICENSE".into());
472        let _skipped = FixOutcome::Skipped("already exists".into());
473    }
474}