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
17macro_rules! phase {
25 ($start:expr, $phase:expr $(, $k:ident = $v:expr)* $(,)?) => {
26 #[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#[derive(Debug)]
53struct GitTrackedIndexes {
54 file_only: Option<FileIndex>,
57 dir_aware: Option<FileIndex>,
61}
62
63#[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#[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 changed_paths: Option<HashSet<PathBuf>>,
103}
104
105impl Engine {
106 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 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 #[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 #[allow(clippy::too_many_lines)]
176 pub fn run(&self, root: &Path, index: &FileIndex) -> Result<Report> {
177 let t_total = Instant::now();
178 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 let cross_rule_ns: Vec<AtomicU64> =
270 (0..self.entries.len()).map(|_| AtomicU64::new(0)).collect();
271
272 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 #[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 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 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 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 #[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 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 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 let applicable: Vec<(usize, &RuleEntry)> = live
477 .iter()
478 .filter(|(_, entry)| {
479 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 let abs = root.join(&file_entry.path);
506 let Ok(bytes) = std::fs::read(&abs) else {
507 return Vec::new();
508 };
509 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 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 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 #[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 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 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 crate::git::collect_tracked_paths(root)?;
743 Some(crate::git::BlameCache::new(root.to_path_buf()))
744 }
745
746 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 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 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 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
868fn 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 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) => {} 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 #[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 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 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 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 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 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 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 let engine = Engine::new(Vec::new(), RuleRegistry::new());
1128 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 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 #[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 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 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 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 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 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 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 assert_eq!(&*report.results[0].rule_id, "cross");
1313 assert_eq!(&*report.results[1].rule_id, "needs-magic");
1314 }
1315}