Skip to main content

alint_core/
engine.rs

1use std::collections::HashMap;
2use std::path::Path;
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}
50
51impl Engine {
52    /// Backward-compatible: wrap each rule in a [`RuleEntry`] with no `when`.
53    pub fn new(rules: Vec<Box<dyn Rule>>, registry: RuleRegistry) -> Self {
54        let entries = rules.into_iter().map(RuleEntry::new).collect();
55        Self {
56            entries,
57            registry,
58            facts: Vec::new(),
59            vars: HashMap::new(),
60            fix_size_limit: Some(1 << 20),
61        }
62    }
63
64    /// Construct from rule entries (each carrying an optional `when`).
65    pub fn from_entries(entries: Vec<RuleEntry>, registry: RuleRegistry) -> Self {
66        Self {
67            entries,
68            registry,
69            facts: Vec::new(),
70            vars: HashMap::new(),
71            fix_size_limit: Some(1 << 20),
72        }
73    }
74
75    #[must_use]
76    pub fn with_fix_size_limit(mut self, limit: Option<u64>) -> Self {
77        self.fix_size_limit = limit;
78        self
79    }
80
81    #[must_use]
82    pub fn with_facts(mut self, facts: Vec<FactSpec>) -> Self {
83        self.facts = facts;
84        self
85    }
86
87    #[must_use]
88    pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
89        self.vars = vars;
90        self
91    }
92
93    pub fn rule_count(&self) -> usize {
94        self.entries.len()
95    }
96
97    pub fn run(&self, root: &Path, index: &FileIndex) -> Result<Report> {
98        let fact_values = evaluate_facts(&self.facts, root, index)?;
99        let ctx = Context {
100            root,
101            index,
102            registry: Some(&self.registry),
103            facts: Some(&fact_values),
104            vars: Some(&self.vars),
105        };
106        let when_env = WhenEnv {
107            facts: &fact_values,
108            vars: &self.vars,
109        };
110        let results: Vec<RuleResult> = self
111            .entries
112            .par_iter()
113            .filter_map(|entry| run_entry(entry, &ctx, &when_env, &fact_values))
114            .collect();
115        Ok(Report { results })
116    }
117
118    /// Evaluate every rule and apply fixers for their violations.
119    /// Fixes run sequentially — rules whose fixers touch the filesystem
120    /// must not race. Rules with no fixer contribute
121    /// [`FixStatus::Unfixable`] entries so the caller sees them in the
122    /// report. Rules that pass (no violations) are omitted from the
123    /// result, same as [`Engine::run`]'s usual behaviour.
124    pub fn fix(&self, root: &Path, index: &FileIndex, dry_run: bool) -> Result<FixReport> {
125        let fact_values = evaluate_facts(&self.facts, root, index)?;
126        let ctx = Context {
127            root,
128            index,
129            registry: Some(&self.registry),
130            facts: Some(&fact_values),
131            vars: Some(&self.vars),
132        };
133        let when_env = WhenEnv {
134            facts: &fact_values,
135            vars: &self.vars,
136        };
137        let fix_ctx = FixContext {
138            root,
139            dry_run,
140            fix_size_limit: self.fix_size_limit,
141        };
142
143        let mut results: Vec<FixRuleResult> = Vec::new();
144        for entry in &self.entries {
145            if let Some(expr) = &entry.when {
146                match expr.evaluate(&when_env) {
147                    Ok(true) => {}
148                    Ok(false) => continue,
149                    Err(e) => {
150                        results.push(FixRuleResult {
151                            rule_id: entry.rule.id().to_string(),
152                            level: entry.rule.level(),
153                            items: vec![FixItem {
154                                violation: Violation::new(format!("when evaluation error: {e}")),
155                                status: FixStatus::Unfixable,
156                            }],
157                        });
158                        continue;
159                    }
160                }
161            }
162            let violations = match entry.rule.evaluate(&ctx) {
163                Ok(v) => v,
164                Err(e) => vec![Violation::new(format!("rule error: {e}"))],
165            };
166            if violations.is_empty() {
167                continue;
168            }
169            let fixer = entry.rule.fixer();
170            let items: Vec<FixItem> = violations
171                .into_iter()
172                .map(|v| {
173                    let status = match fixer {
174                        Some(f) => match f.apply(&v, &fix_ctx) {
175                            Ok(FixOutcome::Applied(s)) => FixStatus::Applied(s),
176                            Ok(FixOutcome::Skipped(s)) => FixStatus::Skipped(s),
177                            Err(e) => FixStatus::Skipped(format!("fix error: {e}")),
178                        },
179                        None => FixStatus::Unfixable,
180                    };
181                    FixItem {
182                        violation: v,
183                        status,
184                    }
185                })
186                .collect();
187            results.push(FixRuleResult {
188                rule_id: entry.rule.id().to_string(),
189                level: entry.rule.level(),
190                items,
191            });
192        }
193        Ok(FixReport { results })
194    }
195}
196
197fn run_entry(
198    entry: &RuleEntry,
199    ctx: &Context<'_>,
200    when_env: &WhenEnv<'_>,
201    _facts: &FactValues,
202) -> Option<RuleResult> {
203    if let Some(expr) = &entry.when {
204        match expr.evaluate(when_env) {
205            Ok(true) => {} // proceed
206            Ok(false) => return None,
207            Err(e) => {
208                return Some(RuleResult {
209                    rule_id: entry.rule.id().to_string(),
210                    level: entry.rule.level(),
211                    policy_url: entry.rule.policy_url().map(str::to_string),
212                    violations: vec![Violation::new(format!("when evaluation error: {e}"))],
213                });
214            }
215        }
216    }
217    Some(run_one(entry.rule.as_ref(), ctx))
218}
219
220fn run_one(rule: &dyn Rule, ctx: &Context<'_>) -> RuleResult {
221    let violations = match rule.evaluate(ctx) {
222        Ok(v) => v,
223        Err(e) => vec![Violation::new(format!("rule error: {e}"))],
224    };
225    RuleResult {
226        rule_id: rule.id().to_string(),
227        level: rule.level(),
228        policy_url: rule.policy_url().map(str::to_string),
229        violations,
230    }
231}