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}
51
52impl RuleResult {
53 pub fn passed(&self) -> bool {
54 self.violations.is_empty()
55 }
56}
57
58/// Execution context handed to each rule during evaluation.
59///
60/// - `registry` — available for rules that need to build and evaluate nested
61/// rules at runtime (e.g. `for_each_dir`). Tests that don't exercise
62/// nested evaluation can set this to `None`.
63/// - `facts` — resolved fact values, computed once per `Engine::run`.
64/// - `vars` — user-supplied string variables from the config's `vars:` section.
65#[derive(Debug)]
66pub struct Context<'a> {
67 pub root: &'a Path,
68 pub index: &'a FileIndex,
69 pub registry: Option<&'a RuleRegistry>,
70 pub facts: Option<&'a FactValues>,
71 pub vars: Option<&'a HashMap<String, String>>,
72}
73
74/// Trait every built-in and plugin rule implements.
75pub trait Rule: Send + Sync + std::fmt::Debug {
76 fn id(&self) -> &str;
77 fn level(&self) -> Level;
78 fn policy_url(&self) -> Option<&str> {
79 None
80 }
81 fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
82
83 /// Optional automatic-fix strategy. Rules whose violations can be
84 /// mechanically corrected (e.g. creating a missing file, removing a
85 /// forbidden one, renaming to the correct case) return a
86 /// [`Fixer`] here; the default implementation reports the rule as
87 /// unfixable.
88 fn fixer(&self) -> Option<&dyn Fixer> {
89 None
90 }
91}
92
93/// Runtime context for applying a fix.
94#[derive(Debug)]
95pub struct FixContext<'a> {
96 pub root: &'a Path,
97 /// When true, fixers must describe what they would do without
98 /// touching the filesystem.
99 pub dry_run: bool,
100 /// Max bytes a content-editing fix will read + rewrite.
101 /// `None` means no cap. Honored by the `read_for_fix` helper
102 /// (and any custom fixer that opts in).
103 pub fix_size_limit: Option<u64>,
104}
105
106/// The result of applying (or simulating) one fix against one violation.
107#[derive(Debug, Clone)]
108pub enum FixOutcome {
109 /// The fix was applied (or would be, under `dry_run`). The string
110 /// is a human-readable one-liner — e.g. `"created LICENSE"`,
111 /// `"would remove target/debug.log"`.
112 Applied(String),
113 /// The fixer intentionally did nothing; the string explains why
114 /// (e.g. `"already exists"`, `"no path on violation"`). This is
115 /// distinct from a hard error returned via `Result::Err`.
116 Skipped(String),
117}
118
119/// A mechanical corrector for a specific rule's violations.
120pub trait Fixer: Send + Sync + std::fmt::Debug {
121 /// Short human-readable summary of what this fixer does,
122 /// independent of any specific violation.
123 fn describe(&self) -> String;
124
125 /// Apply the fix against a single violation.
126 fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
127}
128
129/// Result of [`read_for_fix`] — either the bytes of the file,
130/// or a [`FixOutcome::Skipped`] the caller should return.
131///
132/// Content-editing fixers (`file_prepend`, `file_append`,
133/// `file_trim_trailing_whitespace`, …) funnel their initial read
134/// through this helper so the `fix_size_limit` guard is enforced
135/// uniformly: over-limit files are reported as `Skipped` with a
136/// clear reason, and a one-line warning is printed to stderr so
137/// scripted runs notice.
138#[derive(Debug)]
139pub enum ReadForFix {
140 Bytes(Vec<u8>),
141 Skipped(FixOutcome),
142}
143
144/// Check whether `abs` is within the `fix_size_limit` on `ctx`.
145/// Returns `Some(outcome)` when the file is over-limit (the
146/// caller returns this directly); returns `None` when the fix
147/// can proceed. Emits a one-line stderr warning on over-limit.
148///
149/// Use this in fixers that modify the file without reading the
150/// full body (e.g. streaming append). For read-modify-write
151/// flows, prefer [`read_for_fix`] which folds the check in.
152pub fn check_fix_size(
153 abs: &Path,
154 display_path: &std::path::Path,
155 ctx: &FixContext<'_>,
156) -> Result<Option<FixOutcome>> {
157 let Some(limit) = ctx.fix_size_limit else {
158 return Ok(None);
159 };
160 let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
161 path: abs.to_path_buf(),
162 source,
163 })?;
164 if metadata.len() > limit {
165 let reason = format!(
166 "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
167 `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
168 to fix files this large.",
169 display_path.display(),
170 metadata.len(),
171 limit,
172 );
173 eprintln!("alint: warning: {reason}");
174 return Ok(Some(FixOutcome::Skipped(reason)));
175 }
176 Ok(None)
177}
178
179/// Read `abs` subject to the size limit on `ctx`. Over-limit
180/// files return `ReadForFix::Skipped(Outcome::Skipped(_))` and
181/// emit a one-line stderr warning; in-limit files return
182/// `ReadForFix::Bytes(...)`. Pass-through I/O errors propagate.
183pub fn read_for_fix(
184 abs: &Path,
185 display_path: &std::path::Path,
186 ctx: &FixContext<'_>,
187) -> Result<ReadForFix> {
188 if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
189 return Ok(ReadForFix::Skipped(outcome));
190 }
191 let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
192 path: abs.to_path_buf(),
193 source,
194 })?;
195 Ok(ReadForFix::Bytes(bytes))
196}