Skip to main content

alint_core/
rule.rs

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