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}