Skip to main content

alint_core/
engine.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::Instant;
6
7use rayon::prelude::*;
8
9use crate::error::Result;
10use crate::facts::{FactSpec, FactValues, evaluate_facts};
11use crate::registry::RuleRegistry;
12use crate::report::{FixItem, FixReport, FixRuleResult, FixStatus, Report};
13use crate::rule::{Context, FixContext, FixOutcome, Rule, RuleResult, Violation};
14use crate::walker::FileIndex;
15use crate::when::{WhenEnv, WhenExpr};
16
17/// Cheap helper: emit a `tracing::info!` event with elapsed
18/// nanoseconds since `start` plus arbitrary key/value pairs.
19/// Used by the engine's phase + per-rule timing breakdown so a
20/// scaling profile (`RUST_LOG=alint_core::engine=info` at
21/// 10k/100k/1M) can show which phase (or rule) is growing
22/// super-linearly. Off by default — only fires when info is
23/// enabled for this target, so production runs pay nothing.
24macro_rules! phase {
25    ($start:expr, $phase:expr $(, $k:ident = $v:expr)* $(,)?) => {
26        // u128 → u64 saturating cast: `elapsed_us` overflows u64 only
27        // after ~584,000 years of wall time. The lossy cast is
28        // intentional (we never need the high bits) — picking
29        // `try_into().unwrap_or(u64::MAX)` instead of an `as` cast
30        // also pegs the rare overflow at u64::MAX rather than
31        // silently wrapping, which keeps log readers honest.
32        #[allow(clippy::cast_possible_truncation)]
33        let elapsed_us: u64 = $start.elapsed().as_micros() as u64;
34        tracing::info!(
35            phase = $phase,
36            elapsed_us = elapsed_us,
37            $($k = $v,)*
38            "engine.phase",
39        );
40    };
41}
42
43/// Pre-filtered `FileIndex`es for git-tracked rules. v0.9.11
44/// structural fix lets the engine narrow the index handed to
45/// each opted-in rule, so the rule's `evaluate()` no longer
46/// needs to do its own `is_git_tracked(...)` check per file
47/// (the `git_tracked_only`-silently-dropped recurrence-risk
48/// shape that audit-tested in v0.9.10 is closed).
49///
50/// Each variant is `Option<FileIndex>` so the engine only pays
51/// the build cost for modes that at least one rule opts into.
52#[derive(Debug)]
53struct GitTrackedIndexes {
54    /// Index containing only files where `git_tracked.contains(path)`.
55    /// Handed to rules with [`GitTrackedMode::FileOnly`].
56    file_only: Option<FileIndex>,
57    /// Index containing dirs where `dir_has_tracked_files(path,
58    /// &git_tracked)` plus tracked files. Handed to rules with
59    /// [`GitTrackedMode::DirAware`].
60    dir_aware: Option<FileIndex>,
61}
62
63/// A rule bundled with an optional `when` expression. Rules with a `when`
64/// that evaluates to false at runtime are skipped (no `RuleResult` is
65/// produced) — same observable effect as `level: off`, but gated on facts.
66#[derive(Debug)]
67pub struct RuleEntry {
68    pub rule: Box<dyn Rule>,
69    pub when: Option<WhenExpr>,
70}
71
72impl RuleEntry {
73    pub fn new(rule: Box<dyn Rule>) -> Self {
74        Self { rule, when: None }
75    }
76
77    #[must_use]
78    pub fn with_when(mut self, expr: WhenExpr) -> Self {
79        self.when = Some(expr);
80        self
81    }
82}
83
84/// Executes a set of rules against a pre-built [`FileIndex`].
85///
86/// The engine owns a [`RuleRegistry`] so cross-file rules (e.g.
87/// `for_each_dir`) can build nested rules on demand during evaluation.
88/// Optional `facts` and `vars` (set via the builder chain) are evaluated
89/// at run time and threaded into each rule's [`Context`] and into the
90/// `when` expression evaluator that gates rules.
91#[derive(Debug)]
92pub struct Engine {
93    entries: Vec<RuleEntry>,
94    registry: RuleRegistry,
95    facts: Vec<FactSpec>,
96    vars: HashMap<String, String>,
97    fix_size_limit: Option<u64>,
98    /// In `--changed` mode, the set of paths (relative to root)
99    /// that the user wants linted. `None` means "full check"; the
100    /// engine bypasses every changed-set short-circuit. See
101    /// [`Engine::with_changed_paths`] for the contract.
102    changed_paths: Option<HashSet<PathBuf>>,
103}
104
105impl Engine {
106    /// Backward-compatible: wrap each rule in a [`RuleEntry`] with no `when`.
107    pub fn new(rules: Vec<Box<dyn Rule>>, registry: RuleRegistry) -> Self {
108        let entries = rules.into_iter().map(RuleEntry::new).collect();
109        Self {
110            entries,
111            registry,
112            facts: Vec::new(),
113            vars: HashMap::new(),
114            fix_size_limit: Some(1 << 20),
115            changed_paths: None,
116        }
117    }
118
119    /// Construct from rule entries (each carrying an optional `when`).
120    pub fn from_entries(entries: Vec<RuleEntry>, registry: RuleRegistry) -> Self {
121        Self {
122            entries,
123            registry,
124            facts: Vec::new(),
125            vars: HashMap::new(),
126            fix_size_limit: Some(1 << 20),
127            changed_paths: None,
128        }
129    }
130
131    #[must_use]
132    pub fn with_fix_size_limit(mut self, limit: Option<u64>) -> Self {
133        self.fix_size_limit = limit;
134        self
135    }
136
137    #[must_use]
138    pub fn with_facts(mut self, facts: Vec<FactSpec>) -> Self {
139        self.facts = facts;
140        self
141    }
142
143    #[must_use]
144    pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
145        self.vars = vars;
146        self
147    }
148
149    /// Restrict evaluation to the given set of paths (relative to
150    /// the alint root). Per-file rules see a [`FileIndex`]
151    /// filtered to only these paths; rules that override
152    /// [`Rule::requires_full_index`] (cross-file + existence
153    /// rules) still see the full index but are skipped when
154    /// their [`Rule::path_scope`] doesn't intersect the set.
155    ///
156    /// An empty set short-circuits to a no-op report — there's
157    /// nothing to lint. Pass `None` (or omit) to disable
158    /// `--changed` semantics entirely.
159    #[must_use]
160    pub fn with_changed_paths(mut self, set: HashSet<PathBuf>) -> Self {
161        self.changed_paths = Some(set);
162        self
163    }
164
165    pub fn rule_count(&self) -> usize {
166        self.entries.len()
167    }
168
169    // ~125 lines but each block has its own purpose (changed-set
170    // short-circuit, fact eval, git probe, filtered-index build,
171    // cross-file partition, per-file partition, assembly). Splitting
172    // would mean threading the same ~6-arg context tuple through
173    // four helpers that share lifetimes — net worse for the reader.
174    // The function reads top-to-bottom as one phased pipeline.
175    #[allow(clippy::too_many_lines)]
176    pub fn run(&self, root: &Path, index: &FileIndex) -> Result<Report> {
177        let t_total = Instant::now();
178        // Empty changed-set fast path: nothing to lint, return
179        // an empty report rather than walk the entries list at
180        // all. Saves the fact-evaluation pass too.
181        if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
182            return Ok(Report {
183                results: Vec::new(),
184            });
185        }
186
187        let t_facts = Instant::now();
188        let fact_values = evaluate_facts(&self.facts, root, index)?;
189        phase!(t_facts, "evaluate_facts", facts = self.facts.len() as u64);
190
191        let t_git = Instant::now();
192        let git_tracked = self.collect_git_tracked_if_needed(root);
193        let git_blame = self.build_blame_cache_if_needed(root);
194        phase!(t_git, "git_setup");
195
196        let t_filter = Instant::now();
197        let filtered_index = self.build_filtered_index(index);
198        phase!(
199            t_filter,
200            "build_filtered_index",
201            files = index.entries.len() as u64,
202        );
203
204        let t_git_idx = Instant::now();
205        let git_tracked_indexes = self.build_git_tracked_indexes(index, git_tracked.as_ref());
206        phase!(
207            t_git_idx,
208            "build_git_tracked_indexes",
209            built = u64::from(git_tracked_indexes.is_some()),
210        );
211
212        let full_ctx = Context {
213            root,
214            index,
215            registry: Some(&self.registry),
216            facts: Some(&fact_values),
217            vars: Some(&self.vars),
218            git_tracked: git_tracked.as_ref(),
219            git_blame: git_blame.as_ref(),
220        };
221        let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
222            root,
223            index: fi,
224            registry: Some(&self.registry),
225            facts: Some(&fact_values),
226            vars: Some(&self.vars),
227            git_tracked: git_tracked.as_ref(),
228            git_blame: git_blame.as_ref(),
229        });
230        let git_file_only_ctx = git_tracked_indexes
231            .as_ref()
232            .and_then(|gti| gti.file_only.as_ref())
233            .map(|fi| Context {
234                root,
235                index: fi,
236                registry: Some(&self.registry),
237                facts: Some(&fact_values),
238                vars: Some(&self.vars),
239                git_tracked: git_tracked.as_ref(),
240                git_blame: git_blame.as_ref(),
241            });
242        let git_dir_aware_ctx = git_tracked_indexes
243            .as_ref()
244            .and_then(|gti| gti.dir_aware.as_ref())
245            .map(|fi| Context {
246                root,
247                index: fi,
248                registry: Some(&self.registry),
249                facts: Some(&fact_values),
250                vars: Some(&self.vars),
251                git_tracked: git_tracked.as_ref(),
252                git_blame: git_blame.as_ref(),
253            });
254        let when_env = WhenEnv {
255            facts: &fact_values,
256            vars: &self.vars,
257            iter: None,
258        };
259
260        // Per-rule wall-time accumulator for the cross-file
261        // partition. One AtomicU64 per entry, indexed by
262        // entry position in `self.entries`. Workers add their
263        // rule's elapsed nanoseconds atomically; we dump the
264        // breakdown after the partition completes. Per-rule
265        // timing in a parallel partition is necessarily
266        // wall-time (a single rule can't span threads), so
267        // the totals here = sum of per-thread elapsed across
268        // workers, which still localises which rule dominates.
269        let cross_rule_ns: Vec<AtomicU64> =
270            (0..self.entries.len()).map(|_| AtomicU64::new(0)).collect();
271
272        // Cross-file partition: rules that don't opt into the
273        // file-major dispatch path (cross-file rules + per-file
274        // rules that haven't migrated yet). Same parallelism
275        // shape as v0.9.2 — rule-major par_iter.
276        let t_cross = Instant::now();
277        let cross_results: Vec<(usize, RuleResult)> = self
278            .entries
279            .par_iter()
280            .enumerate()
281            .filter_map(|(idx, entry)| {
282                if entry.rule.as_per_file().is_some() {
283                    return None;
284                }
285                if self.skip_for_changed(entry.rule.as_ref(), full_ctx.index) {
286                    return None;
287                }
288                let ctx = pick_ctx(
289                    entry.rule.as_ref(),
290                    &full_ctx,
291                    filtered_ctx.as_ref(),
292                    git_file_only_ctx.as_ref(),
293                    git_dir_aware_ctx.as_ref(),
294                );
295                let t_rule = Instant::now();
296                let result = run_entry(entry, ctx, &when_env, &fact_values);
297                // u128 → u64 saturating: same rationale as the
298                // `phase!` macro — elapsed_ns overflows u64 only
299                // after ~584 years per rule, and we want lossy
300                // truncation rather than a runtime panic on the
301                // hot path.
302                #[allow(clippy::cast_possible_truncation)]
303                let elapsed_ns = t_rule.elapsed().as_nanos() as u64;
304                cross_rule_ns[idx].fetch_add(elapsed_ns, Ordering::Relaxed);
305                result.map(|rr| (idx, rr))
306            })
307            .collect();
308        phase!(
309            t_cross,
310            "cross_file_partition",
311            rules = self
312                .entries
313                .iter()
314                .filter(|e| e.rule.as_per_file().is_none())
315                .count() as u64,
316        );
317        // Per-rule cross-file dump: skip zero-elapsed slots
318        // (rules that ran on the per-file path or were
319        // skipped by `--changed`). Sorted descending by
320        // elapsed so the worst offenders are at the top of
321        // the log.
322        if tracing::level_enabled!(tracing::Level::INFO) {
323            let mut rows: Vec<(&str, u64)> = self
324                .entries
325                .iter()
326                .enumerate()
327                .filter_map(|(idx, entry)| {
328                    let ns = cross_rule_ns[idx].load(Ordering::Relaxed);
329                    if ns == 0 {
330                        return None;
331                    }
332                    Some((entry.rule.id(), ns))
333                })
334                .collect();
335            rows.sort_by_key(|(_, ns)| std::cmp::Reverse(*ns));
336            for (rule_id, ns) in rows {
337                tracing::info!(
338                    phase = "cross_file_rule",
339                    rule = rule_id,
340                    elapsed_us = ns / 1000,
341                    "engine.phase",
342                );
343            }
344        }
345
346        // Per-file partition: file-major loop reads each file
347        // once and dispatches to every per-file rule whose scope
348        // matches. Coalesces N reads of one file across N rules
349        // sharing it.
350        let t_per_file = Instant::now();
351        let per_file_results = self.run_per_file(root, &full_ctx, filtered_ctx.as_ref(), &when_env);
352        phase!(
353            t_per_file,
354            "per_file_partition",
355            rules = self
356                .entries
357                .iter()
358                .filter(|e| e.rule.as_per_file().is_some())
359                .count() as u64,
360        );
361
362        // Final assembly preserves `self.entries` order so the
363        // output Vec is deterministic + tests that index by
364        // position keep working. Each entry slot fills from
365        // either the cross-file or per-file partition; rules
366        // filtered out (by `--changed` scope, `when: false`, or
367        // passing with no violations) leave their slot empty.
368        let t_assembly = Instant::now();
369        let mut cross_by_idx: HashMap<usize, RuleResult> = cross_results.into_iter().collect();
370        let mut per_file_by_idx: HashMap<usize, RuleResult> =
371            per_file_results.into_iter().collect();
372        let mut results = Vec::with_capacity(self.entries.len());
373        for idx in 0..self.entries.len() {
374            if let Some(rr) = cross_by_idx.remove(&idx) {
375                results.push(rr);
376            } else if let Some(rr) = per_file_by_idx.remove(&idx) {
377                results.push(rr);
378            }
379        }
380        phase!(t_assembly, "assembly", results = results.len() as u64);
381        phase!(t_total, "engine_run_total");
382        Ok(Report { results })
383    }
384
385    /// Per-file dispatch loop. Walks `index.files()` in parallel
386    /// and, for each file, calls every applicable per-file rule's
387    /// `evaluate_file` against a single `std::fs::read`. Returns
388    /// `(entry-index, RuleResult)` tuples for every per-file
389    /// rule that emitted at least one violation; passing rules
390    /// (zero violations) are omitted, matching the rule-major
391    /// path's semantics.
392    #[allow(clippy::too_many_lines)]
393    fn run_per_file<'a>(
394        &'a self,
395        root: &'a Path,
396        full_ctx: &'a Context<'a>,
397        filtered_ctx: Option<&'a Context<'a>>,
398        when_env: &'a WhenEnv<'a>,
399    ) -> Vec<(usize, RuleResult)> {
400        // Pre-filter live per-file entries: opt-in via
401        // `as_per_file`, not skipped by `--changed`, and `when`
402        // resolved. `when` evaluates against constant facts +
403        // vars (no `iter` namespace at the engine level), so its
404        // verdict is independent of the file being scanned —
405        // resolve it once per rule before entering the file
406        // loop. `when` errors short-circuit to a per-rule result
407        // with the error message; behaviour matches the
408        // rule-major path's `run_entry` for parity.
409        let mut live: Vec<(usize, &RuleEntry)> = Vec::new();
410        let mut when_errors: Vec<(usize, RuleResult)> = Vec::new();
411        for (idx, entry) in self.entries.iter().enumerate() {
412            if entry.rule.as_per_file().is_none() {
413                continue;
414            }
415            if self.skip_for_changed(entry.rule.as_ref(), full_ctx.index) {
416                continue;
417            }
418            if let Some(expr) = &entry.when {
419                match expr.evaluate(when_env) {
420                    Ok(true) => {}
421                    Ok(false) => continue,
422                    Err(e) => {
423                        when_errors.push((
424                            idx,
425                            RuleResult {
426                                rule_id: Arc::from(entry.rule.id()),
427                                level: entry.rule.level(),
428                                policy_url: entry.rule.policy_url().map(Arc::from),
429                                violations: vec![Violation::new(format!(
430                                    "when evaluation error: {e}"
431                                ))],
432                                is_fixable: entry.rule.fixer().is_some(),
433                            },
434                        ));
435                        continue;
436                    }
437                }
438            }
439            live.push((idx, entry));
440        }
441        if live.is_empty() {
442            return when_errors;
443        }
444
445        let per_file_ctx = filtered_ctx.unwrap_or(full_ctx);
446
447        // Each file-major iteration produces a Vec of
448        // `(entry-index, Violation)` tuples. The flatten
449        // gathers them all; aggregation below buckets them by
450        // entry-index back into per-rule `RuleResult`s.
451        //
452        // We iterate `index.entries` (a Vec) via `par_iter()`
453        // and filter out directories *inside* the parallel
454        // pipeline rather than calling `index.files().par_bridge()`.
455        // `par_bridge` wraps a sequential iterator using a
456        // Mutex-guarded channel; at 1M entries that lock turns
457        // into a contention bottleneck across 24 worker
458        // threads. The native `par_iter` on the underlying Vec
459        // uses Rayon's work-stealing slabs instead — same
460        // observable iteration, no shared lock on the hot
461        // path.
462        let by_file: Vec<(usize, Violation)> = per_file_ctx
463            .index
464            .entries
465            .par_iter()
466            .filter(|e| !e.is_dir)
467            .flat_map_iter(|file_entry| {
468                // 1. Decide which per-file rules apply to this
469                // file. Per-file rules expose their scope via
470                // `PerFileRule::path_scope`; we filter on it
471                // before any I/O so files no rule cares about
472                // never get read. Carrying `entry_idx` through
473                // here avoids an O(L) `position` lookup per
474                // applicable rule per file inside the inner
475                // dispatch loop below.
476                let applicable: Vec<(usize, &RuleEntry)> = live
477                    .iter()
478                    .filter(|(_, entry)| {
479                        // 1a. Path-scope glob — cheap, dropping
480                        // files no rule cares about before any
481                        // further work.
482                        // v0.9.10: `Scope::matches` consults both
483                        // path-glob AND `scope_filter` in one
484                        // call (Scope owns its optional filter
485                        // since the v0.9.10 structural fix). The
486                        // separate v0.9.6 `entry.rule.scope_filter()`
487                        // check this used to do is now folded in.
488                        entry
489                            .rule
490                            .as_per_file()
491                            .expect("live entries are per-file rules by construction")
492                            .path_scope()
493                            .matches(&file_entry.path, per_file_ctx.index)
494                    })
495                    .map(|(idx, entry)| (*idx, *entry))
496                    .collect();
497                if applicable.is_empty() {
498                    return Vec::new();
499                }
500                // 2. Read once. Read failures (file deleted
501                // mid-walk, permission flake) skip the file
502                // silently — same shape as today's per-rule
503                // `let Ok(bytes) = std::fs::read(...) else
504                // continue;`.
505                let abs = root.join(&file_entry.path);
506                let Ok(bytes) = std::fs::read(&abs) else {
507                    return Vec::new();
508                };
509                // 3. Dispatch. Every applicable rule sees the
510                // same byte slice; the file is read exactly once
511                // even though N rules may produce violations
512                // against it.
513                let mut out: Vec<(usize, Violation)> = Vec::new();
514                for (entry_idx, entry) in applicable {
515                    let pf = entry
516                        .rule
517                        .as_per_file()
518                        .expect("live entries are per-file rules by construction");
519                    let result = pf.evaluate_file(per_file_ctx, &file_entry.path, &bytes);
520                    match result {
521                        Ok(vs) => {
522                            for v in vs {
523                                out.push((entry_idx, v));
524                            }
525                        }
526                        Err(e) => {
527                            out.push((entry_idx, Violation::new(format!("rule error: {e}"))));
528                        }
529                    }
530                }
531                out
532            })
533            .collect();
534
535        // Bucket violations by entry-index, then rebuild
536        // `RuleResult` per live entry preserving each rule's
537        // metadata (level / policy_url / is_fixable).
538        let mut bucket: HashMap<usize, Vec<Violation>> = HashMap::new();
539        for (idx, v) in by_file {
540            bucket.entry(idx).or_default().push(v);
541        }
542        let mut results = when_errors;
543        for (idx, entry) in live {
544            let Some(violations) = bucket.remove(&idx) else {
545                // Rule was applicable to zero files (or every
546                // file was empty / unreadable) — passing rule;
547                // omit, matching today's behaviour.
548                continue;
549            };
550            results.push((
551                idx,
552                RuleResult {
553                    rule_id: Arc::from(entry.rule.id()),
554                    level: entry.rule.level(),
555                    policy_url: entry.rule.policy_url().map(Arc::from),
556                    violations,
557                    is_fixable: entry.rule.fixer().is_some(),
558                },
559            ));
560        }
561        results
562    }
563
564    /// Evaluate every rule and apply fixers for their violations.
565    /// Fixes run sequentially — rules whose fixers touch the filesystem
566    /// must not race. Rules with no fixer contribute
567    /// [`FixStatus::Unfixable`] entries so the caller sees them in the
568    /// report. Rules that pass (no violations) are omitted from the
569    /// result, same as [`Engine::run`]'s usual behaviour.
570    #[allow(clippy::too_many_lines)]
571    pub fn fix(&self, root: &Path, index: &FileIndex, dry_run: bool) -> Result<FixReport> {
572        if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
573            return Ok(FixReport {
574                results: Vec::new(),
575            });
576        }
577
578        let fact_values = evaluate_facts(&self.facts, root, index)?;
579        let git_tracked = self.collect_git_tracked_if_needed(root);
580        let git_blame = self.build_blame_cache_if_needed(root);
581        let filtered_index = self.build_filtered_index(index);
582        let git_tracked_indexes = self.build_git_tracked_indexes(index, git_tracked.as_ref());
583        let full_ctx = Context {
584            root,
585            index,
586            registry: Some(&self.registry),
587            facts: Some(&fact_values),
588            vars: Some(&self.vars),
589            git_tracked: git_tracked.as_ref(),
590            git_blame: git_blame.as_ref(),
591        };
592        let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
593            root,
594            index: fi,
595            registry: Some(&self.registry),
596            facts: Some(&fact_values),
597            vars: Some(&self.vars),
598            git_tracked: git_tracked.as_ref(),
599            git_blame: git_blame.as_ref(),
600        });
601        let git_file_only_ctx = git_tracked_indexes
602            .as_ref()
603            .and_then(|gti| gti.file_only.as_ref())
604            .map(|fi| Context {
605                root,
606                index: fi,
607                registry: Some(&self.registry),
608                facts: Some(&fact_values),
609                vars: Some(&self.vars),
610                git_tracked: git_tracked.as_ref(),
611                git_blame: git_blame.as_ref(),
612            });
613        let git_dir_aware_ctx = git_tracked_indexes
614            .as_ref()
615            .and_then(|gti| gti.dir_aware.as_ref())
616            .map(|fi| Context {
617                root,
618                index: fi,
619                registry: Some(&self.registry),
620                facts: Some(&fact_values),
621                vars: Some(&self.vars),
622                git_tracked: git_tracked.as_ref(),
623                git_blame: git_blame.as_ref(),
624            });
625        let when_env = WhenEnv {
626            facts: &fact_values,
627            vars: &self.vars,
628            iter: None,
629        };
630        let fix_ctx = FixContext {
631            root,
632            dry_run,
633            fix_size_limit: self.fix_size_limit,
634        };
635
636        let mut results: Vec<FixRuleResult> = Vec::new();
637        for entry in &self.entries {
638            if self.skip_for_changed(entry.rule.as_ref(), full_ctx.index) {
639                continue;
640            }
641            let ctx = pick_ctx(
642                entry.rule.as_ref(),
643                &full_ctx,
644                filtered_ctx.as_ref(),
645                git_file_only_ctx.as_ref(),
646                git_dir_aware_ctx.as_ref(),
647            );
648            if let Some(expr) = &entry.when {
649                match expr.evaluate(&when_env) {
650                    Ok(true) => {}
651                    Ok(false) => continue,
652                    Err(e) => {
653                        results.push(FixRuleResult {
654                            rule_id: Arc::from(entry.rule.id()),
655                            level: entry.rule.level(),
656                            items: vec![FixItem {
657                                violation: Violation::new(format!("when evaluation error: {e}")),
658                                status: FixStatus::Unfixable,
659                            }],
660                        });
661                        continue;
662                    }
663                }
664            }
665            let violations = match entry.rule.evaluate(ctx) {
666                Ok(v) => v,
667                Err(e) => vec![Violation::new(format!("rule error: {e}"))],
668            };
669            if violations.is_empty() {
670                continue;
671            }
672            let fixer = entry.rule.fixer();
673            let items: Vec<FixItem> = violations
674                .into_iter()
675                .map(|v| {
676                    let status = match fixer {
677                        Some(f) => match f.apply(&v, &fix_ctx) {
678                            Ok(FixOutcome::Applied(s)) => FixStatus::Applied(s),
679                            Ok(FixOutcome::Skipped(s)) => FixStatus::Skipped(s),
680                            Err(e) => FixStatus::Skipped(format!("fix error: {e}")),
681                        },
682                        None => FixStatus::Unfixable,
683                    };
684                    FixItem {
685                        violation: v,
686                        status,
687                    }
688                })
689                .collect();
690            results.push(FixRuleResult {
691                rule_id: Arc::from(entry.rule.id()),
692                level: entry.rule.level(),
693                items,
694            });
695        }
696        Ok(FixReport { results })
697    }
698
699    /// Collect git's tracked-paths set, but only if at least one
700    /// loaded rule asked for it. Most repos / configs never opt
701    /// in, so this returns `None` zero-cost in the common case.
702    /// Inside a non-git directory, or when `git` exits non-zero
703    /// (corrupt repo, missing binary), the helper also returns
704    /// `None` — rules that consult it then treat every entry as
705    /// "untracked," which is the right default for absence-style
706    /// rules with `git_tracked_only: true`.
707    fn collect_git_tracked_if_needed(
708        &self,
709        root: &Path,
710    ) -> Option<std::collections::HashSet<std::path::PathBuf>> {
711        let any_wants = self
712            .entries
713            .iter()
714            .any(|e| e.rule.git_tracked_mode() != crate::rule::GitTrackedMode::Off);
715        if !any_wants {
716            return None;
717        }
718        crate::git::collect_tracked_paths(root)
719    }
720
721    /// Build the per-file `git blame` cache when at least one
722    /// loaded rule asked for it. Returns `None` otherwise — the
723    /// common case (most configs have no `git_blame_age` rules)
724    /// pays nothing. The cache itself is empty at construction;
725    /// rules trigger blame on first access per file.
726    ///
727    /// We use [`crate::git::collect_tracked_paths`] as the
728    /// is-this-a-git-repo probe so the rule no-ops cleanly
729    /// outside a repo without per-file blame failures littering
730    /// the cache. When the user opts into BOTH `git_tracked_only`
731    /// and `git_blame_age`, the probe runs once via
732    /// [`Engine::collect_git_tracked_if_needed`] and once here —
733    /// negligible cost (sub-ms) compared to the blame work.
734    fn build_blame_cache_if_needed(&self, root: &Path) -> Option<crate::git::BlameCache> {
735        let any_wants = self.entries.iter().any(|e| e.rule.wants_git_blame());
736        if !any_wants {
737            return None;
738        }
739        // Probe: a non-git workspace short-circuits to `None` so
740        // the rule's "silent no-op outside git" path is exercised
741        // at the engine level rather than per-file.
742        crate::git::collect_tracked_paths(root)?;
743        Some(crate::git::BlameCache::new(root.to_path_buf()))
744    }
745
746    /// Build a [`FileIndex`] containing only the entries the user
747    /// said they care about (the `--changed` set). Returns `None`
748    /// when no changed-set is configured — callers fall back to
749    /// the full index.
750    fn build_filtered_index(&self, full: &FileIndex) -> Option<FileIndex> {
751        let set = self.changed_paths.as_ref()?;
752        let entries = full
753            .entries
754            .iter()
755            .filter(|e| set.contains(&*e.path))
756            .cloned()
757            .collect();
758        Some(FileIndex::from_entries(entries))
759    }
760
761    /// Build the per-mode pre-filtered indexes for git-tracked
762    /// rules. v0.9.11 structural fix for the
763    /// `git_tracked_only`-silently-dropped recurrence-risk
764    /// shape (see `docs/design/v0.9/git-tracked-filtered-index.md`).
765    ///
766    /// Returns `None` when no rule opts in (no
767    /// `GitTrackedMode::FileOnly` or `DirAware` declared) OR
768    /// when the tracked-set is unavailable (no git repo). When
769    /// `Some`, contains:
770    ///
771    /// - `file_only`: files where `tracked.contains(path)`. The
772    ///   index `file_exists`-style rules iterate via
773    ///   `ctx.index.files()`. Dirs are dropped (file-mode rules
774    ///   don't iterate dirs).
775    /// - `dir_aware`: dirs where `dir_has_tracked_files(path,
776    ///   tracked)`. The index `dir_exists`-style rules iterate
777    ///   via `ctx.index.dirs()`. Tracked files are also kept so
778    ///   any nested per-file consultation by these rules still
779    ///   works against the same index.
780    ///
781    /// Build cost: O(N) per mode (one `HashSet` lookup or one
782    /// `dir_has_tracked_files` walk per entry). Amortised across
783    /// however many rules opt into each mode.
784    fn build_git_tracked_indexes(
785        &self,
786        full: &FileIndex,
787        tracked: Option<&std::collections::HashSet<std::path::PathBuf>>,
788    ) -> Option<GitTrackedIndexes> {
789        let mut any_file_only = false;
790        let mut any_dir_aware = false;
791        for entry in &self.entries {
792            match entry.rule.git_tracked_mode() {
793                crate::rule::GitTrackedMode::Off => {}
794                crate::rule::GitTrackedMode::FileOnly => any_file_only = true,
795                crate::rule::GitTrackedMode::DirAware => any_dir_aware = true,
796            }
797        }
798        if !any_file_only && !any_dir_aware {
799            return None;
800        }
801
802        // No git repo (or `git ls-files` failed): build EMPTY
803        // indexes for the modes that rules opt into. Preserves
804        // the pre-v0.9.11 silent-no-op semantics — rules that
805        // require git_tracked_only outside a git repo iterate
806        // an empty index and fire zero violations, matching
807        // user expectations for the "don't let X be committed"
808        // pattern.
809        let Some(tracked) = tracked else {
810            return Some(GitTrackedIndexes {
811                file_only: any_file_only.then(|| FileIndex::from_entries(Vec::new())),
812                dir_aware: any_dir_aware.then(|| FileIndex::from_entries(Vec::new())),
813            });
814        };
815
816        let file_only = if any_file_only {
817            let entries = full
818                .entries
819                .iter()
820                .filter(|e| !e.is_dir && tracked.contains(&*e.path))
821                .cloned()
822                .collect();
823            Some(FileIndex::from_entries(entries))
824        } else {
825            None
826        };
827
828        let dir_aware = if any_dir_aware {
829            let entries = full
830                .entries
831                .iter()
832                .filter(|e| {
833                    if e.is_dir {
834                        crate::git::dir_has_tracked_files(&e.path, tracked)
835                    } else {
836                        tracked.contains(&*e.path)
837                    }
838                })
839                .cloned()
840                .collect();
841            Some(FileIndex::from_entries(entries))
842        } else {
843            None
844        };
845
846        Some(GitTrackedIndexes {
847            file_only,
848            dir_aware,
849        })
850    }
851
852    /// True when `--changed` mode is active AND the rule's
853    /// `path_scope` exists AND no path in the changed-set
854    /// satisfies it. Cross-file rules return `path_scope = None`
855    /// per the roadmap contract — so they always return `false`
856    /// here (i.e. never skipped).
857    fn skip_for_changed(&self, rule: &dyn Rule, index: &FileIndex) -> bool {
858        let Some(set) = &self.changed_paths else {
859            return false;
860        };
861        let Some(scope) = rule.path_scope() else {
862            return false;
863        };
864        !set.iter().any(|p| scope.matches(p, index))
865    }
866}
867
868/// Pick the [`Context`] a rule should evaluate against:
869/// `full_ctx` if it [`requires_full_index`](Rule::requires_full_index),
870/// otherwise the changed-only filtered context (falling back to
871/// `full_ctx` when no `--changed` set is configured).
872fn pick_ctx<'a>(
873    rule: &dyn Rule,
874    full_ctx: &'a Context<'a>,
875    filtered_ctx: Option<&'a Context<'a>>,
876    git_file_only_ctx: Option<&'a Context<'a>>,
877    git_dir_aware_ctx: Option<&'a Context<'a>>,
878) -> &'a Context<'a> {
879    // v0.9.11: git-tracked filtering wins over both `--changed`
880    // filtering and the full-index path. The 4 existence rules
881    // that opt in already declare `requires_full_index = true`
882    // (their verdict needs the whole tree, not just the changed
883    // subset), so this substitution is safe — we're swapping
884    // their full-index Context for a pre-narrowed one.
885    match rule.git_tracked_mode() {
886        crate::rule::GitTrackedMode::FileOnly => {
887            return git_file_only_ctx.unwrap_or(full_ctx);
888        }
889        crate::rule::GitTrackedMode::DirAware => {
890            return git_dir_aware_ctx.unwrap_or(full_ctx);
891        }
892        crate::rule::GitTrackedMode::Off => {}
893    }
894    if rule.requires_full_index() {
895        full_ctx
896    } else {
897        filtered_ctx.unwrap_or(full_ctx)
898    }
899}
900
901fn run_entry(
902    entry: &RuleEntry,
903    ctx: &Context<'_>,
904    when_env: &WhenEnv<'_>,
905    _facts: &FactValues,
906) -> Option<RuleResult> {
907    if let Some(expr) = &entry.when {
908        match expr.evaluate(when_env) {
909            Ok(true) => {} // proceed
910            Ok(false) => return None,
911            Err(e) => {
912                return Some(RuleResult {
913                    rule_id: Arc::from(entry.rule.id()),
914                    level: entry.rule.level(),
915                    policy_url: entry.rule.policy_url().map(Arc::from),
916                    violations: vec![Violation::new(format!("when evaluation error: {e}"))],
917                    is_fixable: entry.rule.fixer().is_some(),
918                });
919            }
920        }
921    }
922    Some(run_one(entry.rule.as_ref(), ctx))
923}
924
925fn run_one(rule: &dyn Rule, ctx: &Context<'_>) -> RuleResult {
926    let violations = match rule.evaluate(ctx) {
927        Ok(v) => v,
928        Err(e) => vec![Violation::new(format!("rule error: {e}"))],
929    };
930    RuleResult {
931        rule_id: Arc::from(rule.id()),
932        level: rule.level(),
933        policy_url: rule.policy_url().map(Arc::from),
934        violations,
935        is_fixable: rule.fixer().is_some(),
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::level::Level;
943    use crate::scope::Scope;
944    use crate::walker::FileEntry;
945    use std::path::Path;
946
947    /// Stub rule: emits one violation per matched file in scope.
948    /// Configurable to advertise `requires_full_index` for
949    /// cross-file rule simulation, and a `path_scope` for
950    /// changed-mode tests.
951    #[derive(Debug)]
952    struct StubRule {
953        id: String,
954        level: Level,
955        scope: Scope,
956        full_index: bool,
957        expose_scope: bool,
958    }
959
960    impl Rule for StubRule {
961        fn id(&self) -> &str {
962            &self.id
963        }
964        fn level(&self) -> Level {
965            self.level
966        }
967        fn requires_full_index(&self) -> bool {
968            self.full_index
969        }
970        fn path_scope(&self) -> Option<&Scope> {
971            self.expose_scope.then_some(&self.scope)
972        }
973        fn evaluate(&self, ctx: &Context<'_>) -> crate::error::Result<Vec<Violation>> {
974            let mut out = Vec::new();
975            for entry in ctx.index.files() {
976                if self.scope.matches(&entry.path, ctx.index) {
977                    out.push(Violation::new("hit").with_path(entry.path.clone()));
978                }
979            }
980            Ok(out)
981        }
982    }
983
984    fn stub(id: &str, glob: &str) -> Box<dyn Rule> {
985        Box::new(StubRule {
986            id: id.into(),
987            level: Level::Error,
988            scope: Scope::from_patterns(&[glob.to_string()]).unwrap(),
989            full_index: false,
990            expose_scope: true,
991        })
992    }
993
994    fn full_index_stub(id: &str) -> Box<dyn Rule> {
995        Box::new(StubRule {
996            id: id.into(),
997            level: Level::Error,
998            scope: Scope::match_all(),
999            full_index: true,
1000            expose_scope: false,
1001        })
1002    }
1003
1004    fn idx(paths: &[&str]) -> FileIndex {
1005        FileIndex::from_entries(
1006            paths
1007                .iter()
1008                .map(|p| FileEntry {
1009                    path: std::path::Path::new(p).into(),
1010                    is_dir: false,
1011                    size: 0,
1012                })
1013                .collect(),
1014        )
1015    }
1016
1017    #[test]
1018    fn run_empty_returns_empty_report() {
1019        let engine = Engine::new(Vec::new(), RuleRegistry::new());
1020        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
1021        assert!(report.results.is_empty());
1022    }
1023
1024    #[test]
1025    fn run_single_rule_emits_per_match() {
1026        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
1027        let report = engine
1028            .run(
1029                Path::new("/fake"),
1030                &idx(&["src/a.rs", "src/b.rs", "README.md"]),
1031            )
1032            .unwrap();
1033        assert_eq!(report.results.len(), 1);
1034        assert_eq!(report.results[0].violations.len(), 2);
1035    }
1036
1037    #[test]
1038    fn run_with_empty_changed_set_short_circuits() {
1039        // Per the contract: empty `--changed` set means "lint
1040        // nothing"; the engine returns an empty Report without
1041        // even evaluating facts.
1042        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new())
1043            .with_changed_paths(HashSet::new());
1044        let report = engine.run(Path::new("/fake"), &idx(&["src/a.rs"])).unwrap();
1045        assert!(report.results.is_empty());
1046    }
1047
1048    #[test]
1049    fn changed_mode_skips_rule_whose_scope_misses_diff() {
1050        // Rule scoped to `src/**`; changed-set has only docs/
1051        // → rule skipped (no result emitted).
1052        let mut changed = HashSet::new();
1053        changed.insert(std::path::PathBuf::from("docs/README.md"));
1054        let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
1055            .with_changed_paths(changed);
1056        let report = engine
1057            .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
1058            .unwrap();
1059        assert!(
1060            report.results.is_empty(),
1061            "out-of-scope rule should be skipped: {:?}",
1062            report.results,
1063        );
1064    }
1065
1066    #[test]
1067    fn changed_mode_runs_rule_whose_scope_intersects_diff() {
1068        let mut changed = HashSet::new();
1069        changed.insert(std::path::PathBuf::from("src/a.rs"));
1070        let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
1071            .with_changed_paths(changed);
1072        let report = engine
1073            .run(Path::new("/fake"), &idx(&["src/a.rs", "src/b.rs"]))
1074            .unwrap();
1075        // Filtered index: only `src/a.rs` is visible. Rule
1076        // matches it → 1 violation.
1077        assert_eq!(report.results.len(), 1);
1078        assert_eq!(report.results[0].violations.len(), 1);
1079    }
1080
1081    #[test]
1082    fn requires_full_index_rule_runs_unconditionally_in_changed_mode() {
1083        // A rule with `requires_full_index = true` and no
1084        // `path_scope` opts out of the changed-set filter
1085        // entirely — its verdict is over the whole tree.
1086        let mut changed = HashSet::new();
1087        changed.insert(std::path::PathBuf::from("docs/README.md"));
1088        let engine = Engine::new(vec![full_index_stub("cross")], RuleRegistry::new())
1089            .with_changed_paths(changed);
1090        let report = engine
1091            .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
1092            .unwrap();
1093        // `cross` ran against the full index (not the filtered
1094        // one), so it sees both files.
1095        assert_eq!(report.results.len(), 1);
1096        assert_eq!(report.results[0].violations.len(), 2);
1097    }
1098
1099    #[test]
1100    fn rule_count_reflects_number_of_entries() {
1101        let engine = Engine::new(
1102            vec![stub("a", "**"), stub("b", "**"), stub("c", "**")],
1103            RuleRegistry::new(),
1104        );
1105        assert_eq!(engine.rule_count(), 3);
1106    }
1107
1108    #[test]
1109    fn from_entries_constructor_supports_when_clauses() {
1110        // A rule wrapped with a `when: false` expression should
1111        // be skipped during run — no result emitted.
1112        let entry = RuleEntry::new(stub("gated", "**/*.rs"))
1113            .with_when(crate::when::parse("false").unwrap());
1114        let engine = Engine::from_entries(vec![entry], RuleRegistry::new());
1115        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
1116        assert!(
1117            report.results.is_empty(),
1118            "when-false rule must be skipped: {:?}",
1119            report.results,
1120        );
1121    }
1122
1123    #[test]
1124    fn fix_size_limit_default_is_one_mib() {
1125        // The builder default; tests that override engines via
1126        // `with_fix_size_limit` rely on this baseline.
1127        let engine = Engine::new(Vec::new(), RuleRegistry::new());
1128        // Implementation detail intentionally exposed for tests.
1129        // We can only verify the value indirectly via `with_*`
1130        // returning a different limit; assert the builder works.
1131        let updated = engine.with_fix_size_limit(Some(42));
1132        assert_eq!(updated.rule_count(), 0);
1133    }
1134
1135    #[test]
1136    fn skip_for_changed_returns_false_for_full_check() {
1137        // No `--changed` set → rule never skipped on that basis.
1138        let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
1139        let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
1140        assert_eq!(report.results.len(), 1);
1141    }
1142
1143    /// Per-file rule that emits one violation per file based on
1144    /// the byte content prefix. Used to verify the file-major
1145    /// dispatch path actually hands the bytes to the rule and
1146    /// aggregates the violations correctly.
1147    #[derive(Debug)]
1148    struct PerFileStub {
1149        id: String,
1150        scope: Scope,
1151        prefix: Vec<u8>,
1152    }
1153
1154    impl Rule for PerFileStub {
1155        fn id(&self) -> &str {
1156            &self.id
1157        }
1158        fn level(&self) -> Level {
1159            Level::Error
1160        }
1161        fn evaluate(&self, _ctx: &Context<'_>) -> crate::error::Result<Vec<Violation>> {
1162            // Rule-major fallback: not exercised when
1163            // `as_per_file` is set + the engine routes to the
1164            // file-major loop.
1165            Ok(Vec::new())
1166        }
1167        fn as_per_file(&self) -> Option<&dyn crate::PerFileRule> {
1168            Some(self)
1169        }
1170    }
1171
1172    impl crate::PerFileRule for PerFileStub {
1173        fn path_scope(&self) -> &Scope {
1174            &self.scope
1175        }
1176        fn evaluate_file(
1177            &self,
1178            _ctx: &Context<'_>,
1179            path: &std::path::Path,
1180            bytes: &[u8],
1181        ) -> crate::error::Result<Vec<Violation>> {
1182            if !bytes.starts_with(&self.prefix) {
1183                return Ok(vec![
1184                    Violation::new("missing prefix")
1185                        .with_path(std::sync::Arc::<std::path::Path>::from(path)),
1186                ]);
1187            }
1188            Ok(Vec::new())
1189        }
1190    }
1191
1192    #[test]
1193    fn dispatch_flip_routes_per_file_rule_through_file_major_loop() {
1194        // Real filesystem so the engine's `std::fs::read` works.
1195        // The PerFileStub fires when a file does NOT start with
1196        // `MAGIC` — exercises the slice-handing-in path end-to-end.
1197        let tmp = tempfile::tempdir().unwrap();
1198        std::fs::write(tmp.path().join("good.txt"), b"MAGIC + payload").unwrap();
1199        std::fs::write(tmp.path().join("bad.txt"), b"no magic here").unwrap();
1200
1201        let rule = Box::new(PerFileStub {
1202            id: "needs-magic".into(),
1203            scope: Scope::from_patterns(&["**/*.txt".to_string()]).unwrap(),
1204            prefix: b"MAGIC".to_vec(),
1205        });
1206        let engine = Engine::new(vec![rule], RuleRegistry::new());
1207
1208        let opts = crate::WalkOptions::default();
1209        let index = crate::walk(tmp.path(), &opts).unwrap();
1210        let report = engine.run(tmp.path(), &index).unwrap();
1211
1212        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1213        let r = &report.results[0];
1214        assert_eq!(&*r.rule_id, "needs-magic");
1215        assert_eq!(r.violations.len(), 1, "violations: {:?}", r.violations);
1216        assert_eq!(
1217            r.violations[0].path.as_deref(),
1218            Some(std::path::Path::new("bad.txt")),
1219        );
1220    }
1221
1222    #[test]
1223    fn dispatch_flip_aggregates_multiple_per_file_rules() {
1224        // Two per-file rules sharing one scope: the file-major
1225        // loop reads each file once and dispatches both rules
1226        // against the same byte buffer. Verifies the aggregation
1227        // step buckets violations per rule correctly (not
1228        // per-file).
1229        let tmp = tempfile::tempdir().unwrap();
1230        std::fs::write(tmp.path().join("a.txt"), b"ZZZ stuff").unwrap();
1231        std::fs::write(tmp.path().join("b.txt"), b"BBB stuff").unwrap();
1232
1233        let rule_a = Box::new(PerFileStub {
1234            id: "needs-AAA".into(),
1235            scope: Scope::from_patterns(&["**/*.txt".to_string()]).unwrap(),
1236            prefix: b"AAA".to_vec(),
1237        });
1238        let rule_b = Box::new(PerFileStub {
1239            id: "needs-BBB".into(),
1240            scope: Scope::from_patterns(&["**/*.txt".to_string()]).unwrap(),
1241            prefix: b"BBB".to_vec(),
1242        });
1243        let engine = Engine::new(vec![rule_a, rule_b], RuleRegistry::new());
1244
1245        let opts = crate::WalkOptions::default();
1246        let index = crate::walk(tmp.path(), &opts).unwrap();
1247        let report = engine.run(tmp.path(), &index).unwrap();
1248
1249        // `needs-AAA` fires on both files (neither starts with
1250        // "AAA"). `needs-BBB` fires only on `a.txt`.
1251        let by_id: HashMap<&str, &RuleResult> =
1252            report.results.iter().map(|r| (&*r.rule_id, r)).collect();
1253        assert_eq!(
1254            by_id.len(),
1255            2,
1256            "expected both rules in the report: {:?}",
1257            report.results
1258        );
1259        assert_eq!(by_id["needs-AAA"].violations.len(), 2);
1260        assert_eq!(by_id["needs-BBB"].violations.len(), 1);
1261        assert_eq!(
1262            by_id["needs-BBB"].violations[0].path.as_deref(),
1263            Some(std::path::Path::new("a.txt")),
1264        );
1265    }
1266
1267    #[test]
1268    fn dispatch_flip_passes_when_no_violations() {
1269        // A per-file rule that finds no violations in any file
1270        // should be omitted from the report entirely (matching
1271        // the rule-major path's "passing rules omitted"
1272        // semantics).
1273        let tmp = tempfile::tempdir().unwrap();
1274        std::fs::write(tmp.path().join("a.txt"), b"MAGIC ok").unwrap();
1275
1276        let rule = Box::new(PerFileStub {
1277            id: "needs-magic".into(),
1278            scope: Scope::from_patterns(&["**/*.txt".to_string()]).unwrap(),
1279            prefix: b"MAGIC".to_vec(),
1280        });
1281        let engine = Engine::new(vec![rule], RuleRegistry::new());
1282
1283        let opts = crate::WalkOptions::default();
1284        let index = crate::walk(tmp.path(), &opts).unwrap();
1285        let report = engine.run(tmp.path(), &index).unwrap();
1286
1287        assert!(report.results.is_empty(), "results: {:?}", report.results);
1288    }
1289
1290    #[test]
1291    fn dispatch_flip_preserves_cross_file_rules_unchanged() {
1292        // A rule that opts out of `as_per_file` (the default
1293        // `None`) keeps the rule-major path. Mixing with a
1294        // per-file rule should produce both results.
1295        let tmp = tempfile::tempdir().unwrap();
1296        std::fs::write(tmp.path().join("a.txt"), b"hi").unwrap();
1297
1298        let cross_rule = stub("cross", "**/*.txt");
1299        let per_file_rule = Box::new(PerFileStub {
1300            id: "needs-magic".into(),
1301            scope: Scope::from_patterns(&["**/*.txt".to_string()]).unwrap(),
1302            prefix: b"MAGIC".to_vec(),
1303        });
1304        let engine = Engine::new(vec![cross_rule, per_file_rule], RuleRegistry::new());
1305
1306        let opts = crate::WalkOptions::default();
1307        let index = crate::walk(tmp.path(), &opts).unwrap();
1308        let report = engine.run(tmp.path(), &index).unwrap();
1309
1310        assert_eq!(report.results.len(), 2, "results: {:?}", report.results);
1311        // Order follows entry-registration order.
1312        assert_eq!(&*report.results[0].rule_id, "cross");
1313        assert_eq!(&*report.results[1].rule_id, "needs-magic");
1314    }
1315}