Skip to main content

alint_core/
engine.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use rayon::prelude::*;
5
6use crate::error::Result;
7use crate::facts::{FactSpec, FactValues, evaluate_facts};
8use crate::registry::RuleRegistry;
9use crate::report::{FixItem, FixReport, FixRuleResult, FixStatus, Report};
10use crate::rule::{Context, FixContext, FixOutcome, Rule, RuleResult, Violation};
11use crate::walker::FileIndex;
12use crate::when::{WhenEnv, WhenExpr};
13
14/// A rule bundled with an optional `when` expression. Rules with a `when`
15/// that evaluates to false at runtime are skipped (no `RuleResult` is
16/// produced) — same observable effect as `level: off`, but gated on facts.
17#[derive(Debug)]
18pub struct RuleEntry {
19    pub rule: Box<dyn Rule>,
20    pub when: Option<WhenExpr>,
21}
22
23impl RuleEntry {
24    pub fn new(rule: Box<dyn Rule>) -> Self {
25        Self { rule, when: None }
26    }
27
28    #[must_use]
29    pub fn with_when(mut self, expr: WhenExpr) -> Self {
30        self.when = Some(expr);
31        self
32    }
33}
34
35/// Executes a set of rules against a pre-built [`FileIndex`].
36///
37/// The engine owns a [`RuleRegistry`] so cross-file rules (e.g.
38/// `for_each_dir`) can build nested rules on demand during evaluation.
39/// Optional `facts` and `vars` (set via the builder chain) are evaluated
40/// at run time and threaded into each rule's [`Context`] and into the
41/// `when` expression evaluator that gates rules.
42#[derive(Debug)]
43pub struct Engine {
44    entries: Vec<RuleEntry>,
45    registry: RuleRegistry,
46    facts: Vec<FactSpec>,
47    vars: HashMap<String, String>,
48    fix_size_limit: Option<u64>,
49    /// In `--changed` mode, the set of paths (relative to root)
50    /// that the user wants linted. `None` means "full check"; the
51    /// engine bypasses every changed-set short-circuit. See
52    /// [`Engine::with_changed_paths`] for the contract.
53    changed_paths: Option<HashSet<PathBuf>>,
54}
55
56impl Engine {
57    /// Backward-compatible: wrap each rule in a [`RuleEntry`] with no `when`.
58    pub fn new(rules: Vec<Box<dyn Rule>>, registry: RuleRegistry) -> Self {
59        let entries = rules.into_iter().map(RuleEntry::new).collect();
60        Self {
61            entries,
62            registry,
63            facts: Vec::new(),
64            vars: HashMap::new(),
65            fix_size_limit: Some(1 << 20),
66            changed_paths: None,
67        }
68    }
69
70    /// Construct from rule entries (each carrying an optional `when`).
71    pub fn from_entries(entries: Vec<RuleEntry>, registry: RuleRegistry) -> Self {
72        Self {
73            entries,
74            registry,
75            facts: Vec::new(),
76            vars: HashMap::new(),
77            fix_size_limit: Some(1 << 20),
78            changed_paths: None,
79        }
80    }
81
82    #[must_use]
83    pub fn with_fix_size_limit(mut self, limit: Option<u64>) -> Self {
84        self.fix_size_limit = limit;
85        self
86    }
87
88    #[must_use]
89    pub fn with_facts(mut self, facts: Vec<FactSpec>) -> Self {
90        self.facts = facts;
91        self
92    }
93
94    #[must_use]
95    pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
96        self.vars = vars;
97        self
98    }
99
100    /// Restrict evaluation to the given set of paths (relative to
101    /// the alint root). Per-file rules see a [`FileIndex`]
102    /// filtered to only these paths; rules that override
103    /// [`Rule::requires_full_index`] (cross-file + existence
104    /// rules) still see the full index but are skipped when
105    /// their [`Rule::path_scope`] doesn't intersect the set.
106    ///
107    /// An empty set short-circuits to a no-op report — there's
108    /// nothing to lint. Pass `None` (or omit) to disable
109    /// `--changed` semantics entirely.
110    #[must_use]
111    pub fn with_changed_paths(mut self, set: HashSet<PathBuf>) -> Self {
112        self.changed_paths = Some(set);
113        self
114    }
115
116    pub fn rule_count(&self) -> usize {
117        self.entries.len()
118    }
119
120    pub fn run(&self, root: &Path, index: &FileIndex) -> Result<Report> {
121        // Empty changed-set fast path: nothing to lint, return
122        // an empty report rather than walk the entries list at
123        // all. Saves the fact-evaluation pass too.
124        if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
125            return Ok(Report {
126                results: Vec::new(),
127            });
128        }
129
130        let fact_values = evaluate_facts(&self.facts, root, index)?;
131        let git_tracked = self.collect_git_tracked_if_needed(root);
132        let git_blame = self.build_blame_cache_if_needed(root);
133        let filtered_index = self.build_filtered_index(index);
134        let full_ctx = Context {
135            root,
136            index,
137            registry: Some(&self.registry),
138            facts: Some(&fact_values),
139            vars: Some(&self.vars),
140            git_tracked: git_tracked.as_ref(),
141            git_blame: git_blame.as_ref(),
142        };
143        let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
144            root,
145            index: fi,
146            registry: Some(&self.registry),
147            facts: Some(&fact_values),
148            vars: Some(&self.vars),
149            git_tracked: git_tracked.as_ref(),
150            git_blame: git_blame.as_ref(),
151        });
152        let when_env = WhenEnv {
153            facts: &fact_values,
154            vars: &self.vars,
155            iter: None,
156        };
157        let results: Vec<RuleResult> = self
158            .entries
159            .par_iter()
160            .filter_map(|entry| {
161                if self.skip_for_changed(entry.rule.as_ref()) {
162                    return None;
163                }
164                let ctx = pick_ctx(entry.rule.as_ref(), &full_ctx, filtered_ctx.as_ref());
165                run_entry(entry, ctx, &when_env, &fact_values)
166            })
167            .collect();
168        Ok(Report { results })
169    }
170
171    /// Evaluate every rule and apply fixers for their violations.
172    /// Fixes run sequentially — rules whose fixers touch the filesystem
173    /// must not race. Rules with no fixer contribute
174    /// [`FixStatus::Unfixable`] entries so the caller sees them in the
175    /// report. Rules that pass (no violations) are omitted from the
176    /// result, same as [`Engine::run`]'s usual behaviour.
177    pub fn fix(&self, root: &Path, index: &FileIndex, dry_run: bool) -> Result<FixReport> {
178        if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
179            return Ok(FixReport {
180                results: Vec::new(),
181            });
182        }
183
184        let fact_values = evaluate_facts(&self.facts, root, index)?;
185        let git_tracked = self.collect_git_tracked_if_needed(root);
186        let git_blame = self.build_blame_cache_if_needed(root);
187        let filtered_index = self.build_filtered_index(index);
188        let full_ctx = Context {
189            root,
190            index,
191            registry: Some(&self.registry),
192            facts: Some(&fact_values),
193            vars: Some(&self.vars),
194            git_tracked: git_tracked.as_ref(),
195            git_blame: git_blame.as_ref(),
196        };
197        let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
198            root,
199            index: fi,
200            registry: Some(&self.registry),
201            facts: Some(&fact_values),
202            vars: Some(&self.vars),
203            git_tracked: git_tracked.as_ref(),
204            git_blame: git_blame.as_ref(),
205        });
206        let when_env = WhenEnv {
207            facts: &fact_values,
208            vars: &self.vars,
209            iter: None,
210        };
211        let fix_ctx = FixContext {
212            root,
213            dry_run,
214            fix_size_limit: self.fix_size_limit,
215        };
216
217        let mut results: Vec<FixRuleResult> = Vec::new();
218        for entry in &self.entries {
219            if self.skip_for_changed(entry.rule.as_ref()) {
220                continue;
221            }
222            let ctx = pick_ctx(entry.rule.as_ref(), &full_ctx, filtered_ctx.as_ref());
223            if let Some(expr) = &entry.when {
224                match expr.evaluate(&when_env) {
225                    Ok(true) => {}
226                    Ok(false) => continue,
227                    Err(e) => {
228                        results.push(FixRuleResult {
229                            rule_id: entry.rule.id().to_string(),
230                            level: entry.rule.level(),
231                            items: vec![FixItem {
232                                violation: Violation::new(format!("when evaluation error: {e}")),
233                                status: FixStatus::Unfixable,
234                            }],
235                        });
236                        continue;
237                    }
238                }
239            }
240            let violations = match entry.rule.evaluate(ctx) {
241                Ok(v) => v,
242                Err(e) => vec![Violation::new(format!("rule error: {e}"))],
243            };
244            if violations.is_empty() {
245                continue;
246            }
247            let fixer = entry.rule.fixer();
248            let items: Vec<FixItem> = violations
249                .into_iter()
250                .map(|v| {
251                    let status = match fixer {
252                        Some(f) => match f.apply(&v, &fix_ctx) {
253                            Ok(FixOutcome::Applied(s)) => FixStatus::Applied(s),
254                            Ok(FixOutcome::Skipped(s)) => FixStatus::Skipped(s),
255                            Err(e) => FixStatus::Skipped(format!("fix error: {e}")),
256                        },
257                        None => FixStatus::Unfixable,
258                    };
259                    FixItem {
260                        violation: v,
261                        status,
262                    }
263                })
264                .collect();
265            results.push(FixRuleResult {
266                rule_id: entry.rule.id().to_string(),
267                level: entry.rule.level(),
268                items,
269            });
270        }
271        Ok(FixReport { results })
272    }
273
274    /// Collect git's tracked-paths set, but only if at least one
275    /// loaded rule asked for it. Most repos / configs never opt
276    /// in, so this returns `None` zero-cost in the common case.
277    /// Inside a non-git directory, or when `git` exits non-zero
278    /// (corrupt repo, missing binary), the helper also returns
279    /// `None` — rules that consult it then treat every entry as
280    /// "untracked," which is the right default for absence-style
281    /// rules with `git_tracked_only: true`.
282    fn collect_git_tracked_if_needed(
283        &self,
284        root: &Path,
285    ) -> Option<std::collections::HashSet<std::path::PathBuf>> {
286        let any_wants = self.entries.iter().any(|e| e.rule.wants_git_tracked());
287        if !any_wants {
288            return None;
289        }
290        crate::git::collect_tracked_paths(root)
291    }
292
293    /// Build the per-file `git blame` cache when at least one
294    /// loaded rule asked for it. Returns `None` otherwise — the
295    /// common case (most configs have no `git_blame_age` rules)
296    /// pays nothing. The cache itself is empty at construction;
297    /// rules trigger blame on first access per file.
298    ///
299    /// We use [`crate::git::collect_tracked_paths`] as the
300    /// is-this-a-git-repo probe so the rule no-ops cleanly
301    /// outside a repo without per-file blame failures littering
302    /// the cache. When the user opts into BOTH `git_tracked_only`
303    /// and `git_blame_age`, the probe runs once via
304    /// [`Engine::collect_git_tracked_if_needed`] and once here —
305    /// negligible cost (sub-ms) compared to the blame work.
306    fn build_blame_cache_if_needed(&self, root: &Path) -> Option<crate::git::BlameCache> {
307        let any_wants = self.entries.iter().any(|e| e.rule.wants_git_blame());
308        if !any_wants {
309            return None;
310        }
311        // Probe: a non-git workspace short-circuits to `None` so
312        // the rule's "silent no-op outside git" path is exercised
313        // at the engine level rather than per-file.
314        crate::git::collect_tracked_paths(root)?;
315        Some(crate::git::BlameCache::new(root.to_path_buf()))
316    }
317
318    /// Build a [`FileIndex`] containing only the entries the user
319    /// said they care about (the `--changed` set). Returns `None`
320    /// when no changed-set is configured — callers fall back to
321    /// the full index.
322    fn build_filtered_index(&self, full: &FileIndex) -> Option<FileIndex> {
323        let set = self.changed_paths.as_ref()?;
324        let entries = full
325            .entries
326            .iter()
327            .filter(|e| set.contains(&e.path))
328            .cloned()
329            .collect();
330        Some(FileIndex { entries })
331    }
332
333    /// True when `--changed` mode is active AND the rule's
334    /// `path_scope` exists AND no path in the changed-set
335    /// satisfies it. Cross-file rules return `path_scope = None`
336    /// per the roadmap contract — so they always return `false`
337    /// here (i.e. never skipped).
338    fn skip_for_changed(&self, rule: &dyn Rule) -> bool {
339        let Some(set) = &self.changed_paths else {
340            return false;
341        };
342        let Some(scope) = rule.path_scope() else {
343            return false;
344        };
345        !set.iter().any(|p| scope.matches(p))
346    }
347}
348
349/// Pick the [`Context`] a rule should evaluate against:
350/// `full_ctx` if it [`requires_full_index`](Rule::requires_full_index),
351/// otherwise the changed-only filtered context (falling back to
352/// `full_ctx` when no `--changed` set is configured).
353fn pick_ctx<'a>(
354    rule: &dyn Rule,
355    full_ctx: &'a Context<'a>,
356    filtered_ctx: Option<&'a Context<'a>>,
357) -> &'a Context<'a> {
358    if rule.requires_full_index() {
359        full_ctx
360    } else {
361        filtered_ctx.unwrap_or(full_ctx)
362    }
363}
364
365fn run_entry(
366    entry: &RuleEntry,
367    ctx: &Context<'_>,
368    when_env: &WhenEnv<'_>,
369    _facts: &FactValues,
370) -> Option<RuleResult> {
371    if let Some(expr) = &entry.when {
372        match expr.evaluate(when_env) {
373            Ok(true) => {} // proceed
374            Ok(false) => return None,
375            Err(e) => {
376                return Some(RuleResult {
377                    rule_id: entry.rule.id().to_string(),
378                    level: entry.rule.level(),
379                    policy_url: entry.rule.policy_url().map(str::to_string),
380                    violations: vec![Violation::new(format!("when evaluation error: {e}"))],
381                    is_fixable: entry.rule.fixer().is_some(),
382                });
383            }
384        }
385    }
386    Some(run_one(entry.rule.as_ref(), ctx))
387}
388
389fn run_one(rule: &dyn Rule, ctx: &Context<'_>) -> RuleResult {
390    let violations = match rule.evaluate(ctx) {
391        Ok(v) => v,
392        Err(e) => vec![Violation::new(format!("rule error: {e}"))],
393    };
394    RuleResult {
395        rule_id: rule.id().to_string(),
396        level: rule.level(),
397        policy_url: rule.policy_url().map(str::to_string),
398        violations,
399        is_fixable: rule.fixer().is_some(),
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::level::Level;
407    use crate::scope::Scope;
408    use crate::walker::FileEntry;
409    use std::path::Path;
410
411    /// Stub rule: emits one violation per matched file in scope.
412    /// Configurable to advertise `requires_full_index` for
413    /// cross-file rule simulation, and a `path_scope` for
414    /// changed-mode tests.
415    #[derive(Debug)]
416    struct StubRule {
417        id: String,
418        level: Level,
419        scope: Scope,
420        full_index: bool,
421        expose_scope: bool,
422    }
423
424    impl Rule for StubRule {
425        fn id(&self) -> &str {
426            &self.id
427        }
428        fn level(&self) -> Level {
429            self.level
430        }
431        fn requires_full_index(&self) -> bool {
432            self.full_index
433        }
434        fn path_scope(&self) -> Option<&Scope> {
435            self.expose_scope.then_some(&self.scope)
436        }
437        fn evaluate(&self, ctx: &Context<'_>) -> crate::error::Result<Vec<Violation>> {
438            let mut out = Vec::new();
439            for entry in ctx.index.files() {
440                if self.scope.matches(&entry.path) {
441                    out.push(Violation::new("hit").with_path(&entry.path));
442                }
443            }
444            Ok(out)
445        }
446    }
447
448    fn stub(id: &str, glob: &str) -> Box<dyn Rule> {
449        Box::new(StubRule {
450            id: id.into(),
451            level: Level::Error,
452            scope: Scope::from_patterns(&[glob.to_string()]).unwrap(),
453            full_index: false,
454            expose_scope: true,
455        })
456    }
457
458    fn full_index_stub(id: &str) -> Box<dyn Rule> {
459        Box::new(StubRule {
460            id: id.into(),
461            level: Level::Error,
462            scope: Scope::match_all(),
463            full_index: true,
464            expose_scope: false,
465        })
466    }
467
468    fn idx(paths: &[&str]) -> FileIndex {
469        FileIndex {
470            entries: paths
471                .iter()
472                .map(|p| FileEntry {
473                    path: std::path::PathBuf::from(p),
474                    is_dir: false,
475                    size: 0,
476                })
477                .collect(),
478        }
479    }
480
481    #[test]
482    fn run_empty_returns_empty_report() {
483        let engine = Engine::new(Vec::new(), RuleRegistry::new());
484        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
485        assert!(report.results.is_empty());
486    }
487
488    #[test]
489    fn run_single_rule_emits_per_match() {
490        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
491        let report = engine
492            .run(
493                Path::new("/fake"),
494                &idx(&["src/a.rs", "src/b.rs", "README.md"]),
495            )
496            .unwrap();
497        assert_eq!(report.results.len(), 1);
498        assert_eq!(report.results[0].violations.len(), 2);
499    }
500
501    #[test]
502    fn run_with_empty_changed_set_short_circuits() {
503        // Per the contract: empty `--changed` set means "lint
504        // nothing"; the engine returns an empty Report without
505        // even evaluating facts.
506        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new())
507            .with_changed_paths(HashSet::new());
508        let report = engine.run(Path::new("/fake"), &idx(&["src/a.rs"])).unwrap();
509        assert!(report.results.is_empty());
510    }
511
512    #[test]
513    fn changed_mode_skips_rule_whose_scope_misses_diff() {
514        // Rule scoped to `src/**`; changed-set has only docs/
515        // → rule skipped (no result emitted).
516        let mut changed = HashSet::new();
517        changed.insert(std::path::PathBuf::from("docs/README.md"));
518        let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
519            .with_changed_paths(changed);
520        let report = engine
521            .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
522            .unwrap();
523        assert!(
524            report.results.is_empty(),
525            "out-of-scope rule should be skipped: {:?}",
526            report.results,
527        );
528    }
529
530    #[test]
531    fn changed_mode_runs_rule_whose_scope_intersects_diff() {
532        let mut changed = HashSet::new();
533        changed.insert(std::path::PathBuf::from("src/a.rs"));
534        let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
535            .with_changed_paths(changed);
536        let report = engine
537            .run(Path::new("/fake"), &idx(&["src/a.rs", "src/b.rs"]))
538            .unwrap();
539        // Filtered index: only `src/a.rs` is visible. Rule
540        // matches it → 1 violation.
541        assert_eq!(report.results.len(), 1);
542        assert_eq!(report.results[0].violations.len(), 1);
543    }
544
545    #[test]
546    fn requires_full_index_rule_runs_unconditionally_in_changed_mode() {
547        // A rule with `requires_full_index = true` and no
548        // `path_scope` opts out of the changed-set filter
549        // entirely — its verdict is over the whole tree.
550        let mut changed = HashSet::new();
551        changed.insert(std::path::PathBuf::from("docs/README.md"));
552        let engine = Engine::new(vec![full_index_stub("cross")], RuleRegistry::new())
553            .with_changed_paths(changed);
554        let report = engine
555            .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
556            .unwrap();
557        // `cross` ran against the full index (not the filtered
558        // one), so it sees both files.
559        assert_eq!(report.results.len(), 1);
560        assert_eq!(report.results[0].violations.len(), 2);
561    }
562
563    #[test]
564    fn rule_count_reflects_number_of_entries() {
565        let engine = Engine::new(
566            vec![stub("a", "**"), stub("b", "**"), stub("c", "**")],
567            RuleRegistry::new(),
568        );
569        assert_eq!(engine.rule_count(), 3);
570    }
571
572    #[test]
573    fn from_entries_constructor_supports_when_clauses() {
574        // A rule wrapped with a `when: false` expression should
575        // be skipped during run — no result emitted.
576        let entry = RuleEntry::new(stub("gated", "**/*.rs"))
577            .with_when(crate::when::parse("false").unwrap());
578        let engine = Engine::from_entries(vec![entry], RuleRegistry::new());
579        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
580        assert!(
581            report.results.is_empty(),
582            "when-false rule must be skipped: {:?}",
583            report.results,
584        );
585    }
586
587    #[test]
588    fn fix_size_limit_default_is_one_mib() {
589        // The builder default; tests that override engines via
590        // `with_fix_size_limit` rely on this baseline.
591        let engine = Engine::new(Vec::new(), RuleRegistry::new());
592        // Implementation detail intentionally exposed for tests.
593        // We can only verify the value indirectly via `with_*`
594        // returning a different limit; assert the builder works.
595        let updated = engine.with_fix_size_limit(Some(42));
596        assert_eq!(updated.rule_count(), 0);
597    }
598
599    #[test]
600    fn skip_for_changed_returns_false_for_full_check() {
601        // No `--changed` set → rule never skipped on that basis.
602        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
603        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
604        assert_eq!(report.results.len(), 1);
605    }
606}