Skip to main content

alint_core/
engine.rs

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