alint_core/rule.rs
1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
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 /// Transient flag: when `true`, this is an informational *note*
32 /// (a non-violation finding — e.g. an entry a rule skipped rather
33 /// than failed on), not a real violation. Defaults to `false`.
34 /// The engine partitions notes out of [`RuleResult::violations`]
35 /// into [`RuleResult::notes`] at result-assembly time, so the flag
36 /// never reaches a formatter and pass/fail logic is unaffected.
37 pub is_note: bool,
38}
39
40impl Violation {
41 pub fn new(message: impl Into<Cow<'static, str>>) -> Self {
42 Self {
43 path: None,
44 message: message.into(),
45 line: None,
46 column: None,
47 is_note: false,
48 }
49 }
50
51 /// Mark this finding as an informational note rather than a
52 /// violation. See [`Violation::is_note`].
53 #[must_use]
54 pub fn as_note(mut self) -> Self {
55 self.is_note = true;
56 self
57 }
58
59 /// Attach a path to the violation. Accepts anything convertible
60 /// into `Arc<Path>` — the canonical caller is
61 /// `.with_path(entry.path.clone())` where `entry.path` is the
62 /// `Arc<Path>` already owned by the [`FileIndex`]; this clones
63 /// the [`Arc`] (atomic refcount bump) rather than the bytes.
64 /// `PathBuf`, `&Path`, and `Box<Path>` are also accepted via
65 /// std's `From` impls; for an ad-hoc `&str` use
66 /// `Path::new("a.rs")` to convert first.
67 #[must_use]
68 pub fn with_path(mut self, path: impl Into<Arc<Path>>) -> Self {
69 self.path = Some(path.into());
70 self
71 }
72
73 #[must_use]
74 pub fn with_location(mut self, line: usize, column: usize) -> Self {
75 self.line = Some(line);
76 self.column = Some(column);
77 self
78 }
79}
80
81/// The collected outcome of evaluating a single rule.
82///
83/// `rule_id` holds an [`Arc<str>`]: the engine builds it once
84/// per rule run and shares it across every violation that rule
85/// produces, saving N-1 allocations per rule. `policy_url`
86/// follows the same shape via [`Arc<str>`] — set once per rule,
87/// shared across violations.
88#[derive(Debug, Clone)]
89pub struct RuleResult {
90 pub rule_id: Arc<str>,
91 pub level: Level,
92 pub policy_url: Option<Arc<str>>,
93 pub violations: Vec<Violation>,
94 /// Informational notes (non-violation findings) the rule
95 /// produced — e.g. entries it skipped rather than failed on.
96 /// Partitioned out of the rule's raw output by
97 /// [`RuleResult::new`]; never counted in pass/fail.
98 pub notes: Vec<Violation>,
99 /// Whether the rule declares a [`Fixer`] — surfaced here so
100 /// the human formatter can tag violations as `fixable`
101 /// without threading the rule registry into the renderer.
102 pub is_fixable: bool,
103}
104
105impl RuleResult {
106 /// Build a result from a rule's raw output, partitioning
107 /// note-flagged [`Violation`]s (`is_note`) into [`notes`](Self::notes)
108 /// and the rest into [`violations`](Self::violations). Centralises
109 /// the note/violation split so pass/fail and formatters only ever
110 /// see real violations in `violations`.
111 #[must_use]
112 pub fn new(
113 rule_id: Arc<str>,
114 level: Level,
115 policy_url: Option<Arc<str>>,
116 raw: Vec<Violation>,
117 is_fixable: bool,
118 ) -> Self {
119 let (notes, violations): (Vec<_>, Vec<_>) = raw.into_iter().partition(|v| v.is_note);
120 Self {
121 rule_id,
122 level,
123 policy_url,
124 violations,
125 notes,
126 is_fixable,
127 }
128 }
129
130 pub fn passed(&self) -> bool {
131 self.violations.is_empty()
132 }
133}
134
135/// Execution context handed to each rule during evaluation.
136///
137/// - `registry` — available for rules that need to build and evaluate nested
138/// rules at runtime (e.g. `for_each_dir`). Tests that don't exercise
139/// nested evaluation can set this to `None`.
140/// - `facts` — resolved fact values, computed once per `Engine::run`.
141/// - `vars` — user-supplied string variables from the config's `vars:` section.
142/// - `git_tracked` — set of repo paths reported by `git ls-files`,
143/// computed once per run when at least one rule has
144/// `git_tracked_only: true`. `None` outside a git repo or when
145/// no rule asked for it. Rules that opt in consult it via
146/// [`Context::is_git_tracked`].
147/// - `git_blame` — per-file `git blame` cache, computed lazily
148/// when at least one rule reports `wants_git_blame()`. `None`
149/// when no rule asked for it. Rules consult it via
150/// [`crate::git::BlameCache::get`]; both "outside a git repo"
151/// and "blame failed for this file" surface as a `None`
152/// lookup, which the rule treats as "silent no-op."
153#[derive(Debug)]
154pub struct Context<'a> {
155 pub root: &'a Path,
156 pub index: &'a FileIndex,
157 pub registry: Option<&'a RuleRegistry>,
158 pub facts: Option<&'a FactValues>,
159 pub vars: Option<&'a HashMap<String, String>>,
160 pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
161 pub git_blame: Option<&'a crate::git::BlameCache>,
162}
163
164impl Context<'_> {
165 /// True if `rel_path` is in git's index. Returns `false` when
166 /// no tracked-set was computed (no git repo, or no rule asked
167 /// for it). Rules that opt into `git_tracked_only` therefore
168 /// silently skip every entry outside a git repo, which is the
169 /// right behaviour for the canonical "don't let X be
170 /// committed" use case.
171 pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
172 match self.git_tracked {
173 Some(set) => set.contains(rel_path),
174 None => false,
175 }
176 }
177
178 /// True if the directory at `rel_path` contains at least one
179 /// git-tracked file. Used by `dir_*` rules opting into
180 /// `git_tracked_only`. Same `None`-means-untracked semantics
181 /// as [`Context::is_git_tracked`].
182 pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
183 match self.git_tracked {
184 Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
185 None => false,
186 }
187 }
188}
189
190/// How a rule narrows its iteration to git-tracked entries.
191/// Returned by [`Rule::git_tracked_mode`]; the engine reads
192/// this at construction time to pick the right pre-filtered
193/// `FileIndex` (file-only or dir-aware) for each opted-in
194/// rule.
195///
196/// The mode is per-rule (a config might opt in some rules and
197/// not others). The engine builds at most two filtered indexes
198/// per run regardless of how many rules opt in, so the cost
199/// amortises across the whole rule set.
200///
201/// See `docs/design/v0.9/git-tracked-filtered-index.md` for
202/// the v0.9.11 structural fix this enum is the entry point of.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum GitTrackedMode {
205 /// Rule does not consult the git-tracked set. Engine
206 /// routes the rule's evaluation against the unfiltered
207 /// `FileIndex`. The default; do not override unless the
208 /// rule opts into `git_tracked_only:`.
209 Off,
210 /// Rule iterates files (`ctx.index.files()`) and the
211 /// engine narrows that to entries where
212 /// `git_tracked.contains(path)` before the rule sees them.
213 /// File-mode existence rules (`file_exists`, `file_absent`)
214 /// pick this mode when the spec's `git_tracked_only: true`.
215 FileOnly,
216 /// Rule iterates dirs (`ctx.index.dirs()`) and the engine
217 /// narrows that to dirs where
218 /// `dir_has_tracked_files(path, &git_tracked)`. Dir-mode
219 /// existence rules (`dir_exists`, `dir_absent`) pick this
220 /// mode when the spec's `git_tracked_only: true`. The
221 /// filtered index also includes the tracked files
222 /// themselves so a `dir_*` rule's nested per-file checks
223 /// (e.g. `paths:` glob) still match.
224 DirAware,
225}
226
227/// Stamp out the three boilerplate `Rule` impl methods every rule
228/// kind ships: `id`, `level`, `policy_url`. Expects the impl'ing
229/// struct to have fields named `id: String`, `level: Level`, and
230/// `policy_url: Option<String>` (the universal shape every rule
231/// builder hands back).
232///
233/// Usage inside an `impl Rule for SomeRule` block:
234///
235/// ```ignore
236/// impl Rule for FileExistsRule {
237/// alint_core::rule_common_impl!();
238///
239/// fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> { ... }
240/// // ... other trait methods specific to this rule
241/// }
242/// ```
243///
244/// The macro only covers the three universal methods. `fixer()` /
245/// `as_per_file()` / `evaluate()` / `requires_full_index()` etc.
246/// stay explicit per-rule because they encode the rule's actual
247/// behaviour, not boilerplate.
248#[macro_export]
249macro_rules! rule_common_impl {
250 () => {
251 fn id(&self) -> &str {
252 &self.id
253 }
254 fn level(&self) -> $crate::Level {
255 self.level
256 }
257 fn policy_url(&self) -> Option<&str> {
258 self.policy_url.as_deref()
259 }
260 };
261}
262
263/// Trait every built-in and plugin rule implements.
264pub trait Rule: Send + Sync + std::fmt::Debug {
265 fn id(&self) -> &str;
266 fn level(&self) -> Level;
267 fn policy_url(&self) -> Option<&str> {
268 None
269 }
270 /// Whether (and how) this rule narrows its iteration to
271 /// git-tracked entries. Default [`GitTrackedMode::Off`].
272 /// Rule kinds that support `git_tracked_only:` override to
273 /// return [`GitTrackedMode::FileOnly`] (file-mode rules:
274 /// check `set.contains(path)`) or [`GitTrackedMode::DirAware`]
275 /// (dir-mode rules: check `dir_has_tracked_files(path, set)`)
276 /// when the user opts in.
277 ///
278 /// The engine collects the tracked-paths set (via
279 /// `git ls-files`) once per run when ANY rule returns a
280 /// non-`Off` mode, then builds a pre-filtered `FileIndex`
281 /// for each mode and routes opted-in rules to the right
282 /// `Context`. Rules iterate `ctx.index.files()` /
283 /// `ctx.index.dirs()` exactly as before — the index is
284 /// already narrowed, so no per-rule `if self.git_tracked_only
285 /// && !ctx.is_git_tracked(...)` runtime check is needed.
286 /// Closes the same recurrence-risk shape as v0.9.10's
287 /// `Scope`-owns-`scope_filter` fix:
288 /// `docs/design/v0.9/git-tracked-filtered-index.md`.
289 fn git_tracked_mode(&self) -> GitTrackedMode {
290 GitTrackedMode::Off
291 }
292
293 /// Whether this rule needs `git blame` output on
294 /// [`Context`]. Default `false`; the `git_blame_age` rule
295 /// kind overrides to return `true`. The engine builds the
296 /// shared [`crate::git::BlameCache`] once per run when any
297 /// rule opts in, so multiple blame-aware rules over
298 /// overlapping `paths:` re-use the parsed result.
299 fn wants_git_blame(&self) -> bool {
300 false
301 }
302
303 /// Permit this rule's *read* sites to read a config-declared path
304 /// that escapes the repo root. Default no-op (hard confinement).
305 /// The loader calls this post-build with the result of the
306 /// top-level `allow_out_of_root:` policy for the rule's id/kind;
307 /// only the read-confinement rule kinds override it to store the
308 /// flag. NEVER reachable from an `extends:`'d ruleset — the policy
309 /// is parsed from the user's own top-level config only, mirroring
310 /// the `SPAWNING_RULE_KINDS` trust gate. A build site that forgets
311 /// to call this leaves the rule confined (the safe default). See
312 /// `docs/design/v0.12/allow_out_of_root.md`.
313 fn set_allow_out_of_root(&mut self, _allow: bool) {}
314
315 /// In `--changed` mode, return `true` to evaluate this rule
316 /// against the **full** [`FileIndex`] rather than the
317 /// changed-only filtered subset. Default `false` (per-file
318 /// semantics — the rule sees only changed files in scope).
319 ///
320 /// Cross-file rules (`pair`, `for_each_dir`,
321 /// `every_matching_has`, `unique_by`, `dir_contains`,
322 /// `dir_only_contains`) override to `true` because their
323 /// inputs span the whole tree by definition — a verdict on
324 /// the changed file depends on what's still in the rest of
325 /// the tree. Existence rules (`file_exists`, `file_absent`,
326 /// `dir_exists`, `dir_absent`) likewise consult the whole
327 /// tree to answer "is X present?" correctly.
328 fn requires_full_index(&self) -> bool {
329 false
330 }
331
332 /// In `--changed` mode, return the [`Scope`](crate::Scope)
333 /// this rule is scoped to (typically the rule's `paths:`
334 /// field). The engine intersects the scope with the
335 /// changed-set; rules whose scope doesn't intersect are
336 /// skipped, which is the optimisation `--changed` exists
337 /// for.
338 ///
339 /// Default `None` ("no scope information") means the rule is
340 /// always evaluated. Cross-file rules deliberately leave this
341 /// as `None` (they always evaluate per the roadmap contract).
342 /// Per-file rules with a single `Scope` field should override
343 /// to return `Some(&self.scope)`.
344 fn path_scope(&self) -> Option<&crate::scope::Scope> {
345 None
346 }
347
348 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
349
350 /// Optional automatic-fix strategy. Rules whose violations can be
351 /// mechanically corrected (e.g. creating a missing file, removing a
352 /// forbidden one, renaming to the correct case) return a
353 /// [`Fixer`] here; the default implementation reports the rule as
354 /// unfixable.
355 fn fixer(&self) -> Option<&dyn Fixer> {
356 None
357 }
358
359 /// Opt into the file-major dispatch path. Per-file rules that
360 /// can evaluate one file at a time given a pre-loaded byte
361 /// slice override this to return `Some(self)`; cross-file
362 /// rules and any rule with `requires_full_index() == true`
363 /// leave it as `None` and keep evaluating under the rule-
364 /// major loop.
365 ///
366 /// When the engine has multiple per-file rules sharing one
367 /// scope, the file-major loop reads each matched file once
368 /// and dispatches to every applicable per-file rule against
369 /// the same byte buffer — coalescing N reads of one file
370 /// into 1.
371 fn as_per_file(&self) -> Option<&dyn PerFileRule> {
372 None
373 }
374}
375
376/// File-major dispatch entry-point for a per-file rule.
377///
378/// Rules that can evaluate one file at a time given a pre-loaded
379/// byte slice implement this trait alongside [`Rule`] and opt
380/// into the file-major path via [`Rule::as_per_file`]. The
381/// engine reads each file once per evaluation pass and calls
382/// `evaluate_file` on every per-file rule whose
383/// [`path_scope`](PerFileRule::path_scope) matches that file —
384/// avoiding the per-rule `std::fs::read` the rule-major loop
385/// would otherwise duplicate.
386///
387/// Implementations MUST NOT call `std::fs::read` themselves; the
388/// `bytes` argument is the engine's already-read content. The
389/// rule's existing [`Rule::evaluate`] implementation (which does
390/// read the file) stays in place as the rule-major fallback —
391/// it's still the path used by `alint fix` (sequential
392/// filesystem mutation rules out coalesced reads there) and by
393/// fallback test harnesses.
394pub trait PerFileRule: Send + Sync + std::fmt::Debug {
395 /// The rule's scope. The engine checks
396 /// `path_scope().matches(path)` before calling
397 /// `evaluate_file`; a rule that returns
398 /// [`Scope::match_all`](crate::scope::Scope::match_all) is
399 /// in scope for every file.
400 fn path_scope(&self) -> &crate::scope::Scope;
401
402 /// Evaluate one file given the engine's already-read byte
403 /// content. The `path` is the relative path from the lint
404 /// root; the rule should `with_path(path.into())` (or clone
405 /// the matched [`FileEntry::path`](crate::walker::FileEntry::path)
406 /// if it has one in hand) on emitted violations.
407 fn evaluate_file(&self, ctx: &Context<'_>, path: &Path, bytes: &[u8])
408 -> Result<Vec<Violation>>;
409
410 /// Optional lower bound on the bytes the rule needs to
411 /// evaluate. Default `None` means "I need the whole file."
412 /// Used as a hint; the engine in v0.9.3 reads the whole
413 /// file regardless and hands it to every applicable rule —
414 /// the hint is reserved for a future engine-side bounded-
415 /// read optimisation.
416 fn max_bytes_needed(&self) -> Option<usize> {
417 None
418 }
419}
420
421/// Rule-major fallback for [`PerFileRule`] implementors.
422///
423/// Every per-file rule needs a [`Rule::evaluate`] body — the engine's
424/// file-major fast path uses [`PerFileRule::evaluate_file`] directly,
425/// but `alint fix` (sequential filesystem mutation) and a handful of
426/// test harnesses still drive rules through [`Rule::evaluate`]. The
427/// loop is mechanical:
428///
429/// ```text
430/// for entry in ctx.index.files() {
431/// if scope doesn't match { continue }
432/// let bytes = std::fs::read(full)? // continue on read failure
433/// violations.extend(self.evaluate_file(ctx, path, &bytes)?)
434/// }
435/// ```
436///
437/// Twenty-five rules ship the same loop verbatim. Calling
438/// `eval_per_file(self, ctx)` from `Rule::evaluate` collapses each
439/// of them to a one-liner. The helper takes `&R: PerFileRule` so
440/// it inlines for static dispatch.
441///
442/// Read failures (file deleted mid-walk, permission flake) skip the
443/// file silently to match the engine's file-major behaviour at
444/// `crate::engine` line ~506.
445pub fn eval_per_file<R: PerFileRule + ?Sized>(
446 rule: &R,
447 ctx: &Context<'_>,
448) -> Result<Vec<Violation>> {
449 let mut violations = Vec::new();
450 for entry in ctx.index.files() {
451 if !rule.path_scope().matches(&entry.path, ctx.index) {
452 continue;
453 }
454 let full = ctx.root.join(&entry.path);
455 let Ok(bytes) = std::fs::read(&full) else {
456 continue;
457 };
458 violations.extend(rule.evaluate_file(ctx, &entry.path, &bytes)?);
459 }
460 Ok(violations)
461}
462
463/// Runtime context for applying a fix.
464#[derive(Debug)]
465pub struct FixContext<'a> {
466 pub root: &'a Path,
467 /// When true, fixers must describe what they would do without
468 /// touching the filesystem.
469 pub dry_run: bool,
470 /// Max bytes a content-editing fix will read + rewrite.
471 /// `None` means no cap. Honored by the `read_for_fix` helper
472 /// (and any custom fixer that opts in).
473 pub fix_size_limit: Option<u64>,
474}
475
476/// The result of applying (or simulating) one fix against one violation.
477#[derive(Debug, Clone)]
478pub enum FixOutcome {
479 /// The fix was applied (or would be, under `dry_run`). The string
480 /// is a human-readable one-liner — e.g. `"created LICENSE"`,
481 /// `"would remove target/debug.log"`.
482 Applied(String),
483 /// The fixer intentionally did nothing; the string explains why
484 /// (e.g. `"already exists"`, `"no path on violation"`). This is
485 /// distinct from a hard error returned via `Result::Err`.
486 Skipped(String),
487}
488
489/// A proposed fix expressed as data, so a caller can turn it into an
490/// editor edit (an LSP `WorkspaceEdit`) instead of writing the file.
491/// Returned by [`Fixer::fix_edit`]. Paths are relative to the alint
492/// root, matching [`Violation::path`].
493///
494/// `fix_edit` is the non-writing sibling of [`Fixer::apply`]: `apply`
495/// mutates the filesystem (for `alint fix`); `fix_edit` describes the
496/// same change so the editor can apply it to the buffer (with undo).
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub enum FixEdit {
499 /// Replace the full contents of an existing file.
500 SetContent { path: PathBuf, content: Vec<u8> },
501 /// Create a file that doesn't exist yet.
502 CreateFile { path: PathBuf, content: Vec<u8> },
503 /// Delete a file.
504 DeleteFile { path: PathBuf },
505 /// Rename a file (same directory or not).
506 RenameFile { from: PathBuf, to: PathBuf },
507}
508
509/// A mechanical corrector for a specific rule's violations.
510pub trait Fixer: Send + Sync + std::fmt::Debug {
511 /// Short human-readable summary of what this fixer does,
512 /// independent of any specific violation.
513 fn describe(&self) -> String;
514
515 /// Apply the fix against a single violation.
516 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
517
518 /// Express the fix for `violation` as a [`FixEdit`] without touching
519 /// the filesystem, given the current `bytes` of the violation's file
520 /// (empty for create-style fixers) and the workspace `root` (so
521 /// content sourced from a template can be read). Returns `None` when
522 /// the fixer has no editor-expressible form, or when there's nothing
523 /// to change.
524 ///
525 /// Used by the LSP server to offer an "Apply fix" code action as a
526 /// `WorkspaceEdit`. The default returns `None` — a fixer opts in by
527 /// overriding it. Implementations MUST NOT write to disk (reading a
528 /// declared template is allowed). Unlike [`apply`](Self::apply),
529 /// this is not `fix_size_limit`-guarded: the caller already holds
530 /// the bytes (an open editor buffer), so size is bounded by what the
531 /// editor opened.
532 fn fix_edit(&self, violation: &Violation, bytes: &[u8], root: &Path) -> Option<FixEdit> {
533 let _ = (violation, bytes, root);
534 None
535 }
536}
537
538/// Result of [`read_for_fix`] — either the bytes of the file,
539/// or a [`FixOutcome::Skipped`] the caller should return.
540///
541/// Content-editing fixers (`file_prepend`, `file_append`,
542/// `file_trim_trailing_whitespace`, …) funnel their initial read
543/// through this helper so the `fix_size_limit` guard is enforced
544/// uniformly: over-limit files are reported as `Skipped` with a
545/// clear reason, and a one-line warning is printed to stderr so
546/// scripted runs notice.
547#[derive(Debug)]
548pub enum ReadForFix {
549 Bytes(Vec<u8>),
550 Skipped(FixOutcome),
551}
552
553/// Check whether `abs` is within the `fix_size_limit` on `ctx`.
554/// Returns `Some(outcome)` when the file is over-limit (the
555/// caller returns this directly); returns `None` when the fix
556/// can proceed. Emits a one-line stderr warning on over-limit.
557///
558/// Use this in fixers that modify the file without reading the
559/// full body (e.g. streaming append). For read-modify-write
560/// flows, prefer [`read_for_fix`] which folds the check in.
561pub fn check_fix_size(
562 abs: &Path,
563 display_path: &std::path::Path,
564 ctx: &FixContext<'_>,
565) -> Result<Option<FixOutcome>> {
566 let Some(limit) = ctx.fix_size_limit else {
567 return Ok(None);
568 };
569 let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
570 path: abs.to_path_buf(),
571 source,
572 })?;
573 if metadata.len() > limit {
574 let reason = format!(
575 "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
576 `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
577 to fix files this large.",
578 display_path.display(),
579 metadata.len(),
580 limit,
581 );
582 eprintln!("alint: warning: {reason}");
583 return Ok(Some(FixOutcome::Skipped(reason)));
584 }
585 Ok(None)
586}
587
588/// Read `abs` subject to the size limit on `ctx`. Over-limit
589/// files return `ReadForFix::Skipped(Outcome::Skipped(_))` and
590/// emit a one-line stderr warning; in-limit files return
591/// `ReadForFix::Bytes(...)`. Pass-through I/O errors propagate.
592pub fn read_for_fix(
593 abs: &Path,
594 display_path: &std::path::Path,
595 ctx: &FixContext<'_>,
596) -> Result<ReadForFix> {
597 if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
598 return Ok(ReadForFix::Skipped(outcome));
599 }
600 let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
601 path: abs.to_path_buf(),
602 source,
603 })?;
604 Ok(ReadForFix::Bytes(bytes))
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 fn empty_index() -> FileIndex {
612 FileIndex::default()
613 }
614
615 #[test]
616 fn violation_builder_sets_fields_via_chain() {
617 let v = Violation::new("trailing whitespace")
618 .with_path(Path::new("src/main.rs"))
619 .with_location(12, 4);
620 assert_eq!(v.message, "trailing whitespace");
621 assert_eq!(v.path.as_deref(), Some(Path::new("src/main.rs")));
622 assert_eq!(v.line, Some(12));
623 assert_eq!(v.column, Some(4));
624 }
625
626 #[test]
627 fn violation_new_starts_with_no_path_or_location() {
628 let v = Violation::new("global note");
629 assert!(v.path.is_none());
630 assert!(v.line.is_none());
631 assert!(v.column.is_none());
632 }
633
634 #[test]
635 fn new_partitions_notes_out_of_violations() {
636 let raw = vec![
637 Violation::new("real one"),
638 Violation::new("a skipped entry").as_note(),
639 Violation::new("real two"),
640 ];
641 let r = RuleResult::new("x".into(), Level::Error, None, raw, false);
642 assert_eq!(r.violations.len(), 2, "notes excluded from violations");
643 assert_eq!(r.notes.len(), 1);
644 assert_eq!(r.notes[0].message, "a skipped entry");
645 assert!(!r.passed(), "real violations present → not passed");
646
647 // A result whose only finding is a note passes (notes never
648 // affect pass/fail).
649 let only_notes = RuleResult::new(
650 "y".into(),
651 Level::Error,
652 None,
653 vec![Violation::new("just a note").as_note()],
654 false,
655 );
656 assert!(only_notes.passed(), "notes-only result passes");
657 assert!(only_notes.violations.is_empty());
658 assert_eq!(only_notes.notes.len(), 1);
659 }
660
661 #[test]
662 fn rule_result_passed_iff_violations_empty() {
663 let mut r = RuleResult {
664 rule_id: "x".into(),
665 level: Level::Error,
666 policy_url: None,
667 violations: Vec::new(),
668 notes: Vec::new(),
669 is_fixable: false,
670 };
671 assert!(r.passed());
672 r.violations.push(Violation::new("oops"));
673 assert!(!r.passed());
674 }
675
676 #[test]
677 fn context_is_git_tracked_returns_false_outside_repo() {
678 let idx = empty_index();
679 let ctx = Context {
680 root: Path::new("/tmp"),
681 index: &idx,
682 registry: None,
683 facts: None,
684 vars: None,
685 git_tracked: None, // outside-a-repo / no rule opted in
686 git_blame: None,
687 };
688 assert!(!ctx.is_git_tracked(Path::new("anything.rs")));
689 assert!(!ctx.dir_has_tracked_files(Path::new("src")));
690 }
691
692 #[test]
693 fn context_is_git_tracked_consults_set_when_present() {
694 let mut tracked: std::collections::HashSet<std::path::PathBuf> =
695 std::collections::HashSet::new();
696 tracked.insert(std::path::PathBuf::from("src/main.rs"));
697 let idx = empty_index();
698 let ctx = Context {
699 root: Path::new("/tmp"),
700 index: &idx,
701 registry: None,
702 facts: None,
703 vars: None,
704 git_tracked: Some(&tracked),
705 git_blame: None,
706 };
707 assert!(ctx.is_git_tracked(Path::new("src/main.rs")));
708 assert!(!ctx.is_git_tracked(Path::new("README.md")));
709 }
710
711 /// Stand-in `Rule` impl that returns the trait defaults.
712 /// Lets us assert the documented defaults without dragging
713 /// in a real registered rule.
714 #[derive(Debug)]
715 struct DefaultRule;
716
717 impl Rule for DefaultRule {
718 fn id(&self) -> &'static str {
719 "default"
720 }
721 fn level(&self) -> Level {
722 Level::Warning
723 }
724 fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
725 Ok(Vec::new())
726 }
727 }
728
729 #[test]
730 fn rule_trait_defaults_are_safe_no_ops() {
731 let r = DefaultRule;
732 assert_eq!(r.policy_url(), None);
733 assert_eq!(r.git_tracked_mode(), GitTrackedMode::Off);
734 assert!(!r.wants_git_blame());
735 assert!(!r.requires_full_index());
736 assert!(r.path_scope().is_none());
737 assert!(r.fixer().is_none());
738 }
739
740 #[test]
741 fn check_fix_size_returns_none_when_limit_disabled() {
742 let dir = tempfile::tempdir().unwrap();
743 let f = dir.path().join("a.txt");
744 std::fs::write(&f, b"hello").unwrap();
745 let ctx = FixContext {
746 root: dir.path(),
747 dry_run: false,
748 fix_size_limit: None,
749 };
750 let outcome = check_fix_size(&f, Path::new("a.txt"), &ctx).unwrap();
751 assert!(outcome.is_none());
752 }
753
754 #[test]
755 fn check_fix_size_skips_over_limit_files() {
756 let dir = tempfile::tempdir().unwrap();
757 let f = dir.path().join("big.txt");
758 std::fs::write(&f, vec![b'x'; 1024]).unwrap();
759 let ctx = FixContext {
760 root: dir.path(),
761 dry_run: false,
762 fix_size_limit: Some(64),
763 };
764 let outcome = check_fix_size(&f, Path::new("big.txt"), &ctx).unwrap();
765 match outcome {
766 Some(FixOutcome::Skipped(reason)) => {
767 assert!(reason.contains("exceeds fix_size_limit"));
768 assert!(reason.contains("big.txt"));
769 }
770 other => panic!("expected Skipped, got {other:?}"),
771 }
772 }
773
774 #[test]
775 fn read_for_fix_returns_bytes_when_in_limit() {
776 let dir = tempfile::tempdir().unwrap();
777 let f = dir.path().join("a.txt");
778 std::fs::write(&f, b"hello").unwrap();
779 let ctx = FixContext {
780 root: dir.path(),
781 dry_run: false,
782 fix_size_limit: Some(1 << 20),
783 };
784 match read_for_fix(&f, Path::new("a.txt"), &ctx).unwrap() {
785 ReadForFix::Bytes(b) => assert_eq!(b, b"hello"),
786 ReadForFix::Skipped(_) => panic!("expected Bytes, got Skipped"),
787 }
788 }
789
790 #[test]
791 fn read_for_fix_returns_skipped_when_over_limit() {
792 let dir = tempfile::tempdir().unwrap();
793 let f = dir.path().join("big.txt");
794 std::fs::write(&f, vec![b'x'; 1024]).unwrap();
795 let ctx = FixContext {
796 root: dir.path(),
797 dry_run: false,
798 fix_size_limit: Some(64),
799 };
800 match read_for_fix(&f, Path::new("big.txt"), &ctx).unwrap() {
801 ReadForFix::Skipped(FixOutcome::Skipped(_)) => {}
802 ReadForFix::Skipped(FixOutcome::Applied(_)) => {
803 panic!("expected Skipped, got Skipped(Applied)")
804 }
805 ReadForFix::Bytes(_) => panic!("expected Skipped, got Bytes"),
806 }
807 }
808
809 #[test]
810 fn fix_outcome_variants_are_constructible() {
811 // Sanity: documented variant shapes haven't drifted.
812 let _applied = FixOutcome::Applied("created LICENSE".into());
813 let _skipped = FixOutcome::Skipped("already exists".into());
814 }
815}