1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use rayon::prelude::*;
5
6use crate::error::Result;
7use crate::facts::{FactSpec, FactValues, evaluate_facts};
8use crate::registry::RuleRegistry;
9use crate::report::{FixItem, FixReport, FixRuleResult, FixStatus, Report};
10use crate::rule::{Context, FixContext, FixOutcome, Rule, RuleResult, Violation};
11use crate::walker::FileIndex;
12use crate::when::{WhenEnv, WhenExpr};
13
14#[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#[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 changed_paths: Option<HashSet<PathBuf>>,
54}
55
56impl Engine {
57 pub fn new(rules: Vec<Box<dyn Rule>>, registry: RuleRegistry) -> Self {
59 let entries = rules.into_iter().map(RuleEntry::new).collect();
60 Self {
61 entries,
62 registry,
63 facts: Vec::new(),
64 vars: HashMap::new(),
65 fix_size_limit: Some(1 << 20),
66 changed_paths: None,
67 }
68 }
69
70 pub fn from_entries(entries: Vec<RuleEntry>, registry: RuleRegistry) -> Self {
72 Self {
73 entries,
74 registry,
75 facts: Vec::new(),
76 vars: HashMap::new(),
77 fix_size_limit: Some(1 << 20),
78 changed_paths: None,
79 }
80 }
81
82 #[must_use]
83 pub fn with_fix_size_limit(mut self, limit: Option<u64>) -> Self {
84 self.fix_size_limit = limit;
85 self
86 }
87
88 #[must_use]
89 pub fn with_facts(mut self, facts: Vec<FactSpec>) -> Self {
90 self.facts = facts;
91 self
92 }
93
94 #[must_use]
95 pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
96 self.vars = vars;
97 self
98 }
99
100 #[must_use]
111 pub fn with_changed_paths(mut self, set: HashSet<PathBuf>) -> Self {
112 self.changed_paths = Some(set);
113 self
114 }
115
116 pub fn rule_count(&self) -> usize {
117 self.entries.len()
118 }
119
120 pub fn run(&self, root: &Path, index: &FileIndex) -> Result<Report> {
121 if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
125 return Ok(Report {
126 results: Vec::new(),
127 });
128 }
129
130 let fact_values = evaluate_facts(&self.facts, root, index)?;
131 let git_tracked = self.collect_git_tracked_if_needed(root);
132 let git_blame = self.build_blame_cache_if_needed(root);
133 let filtered_index = self.build_filtered_index(index);
134 let full_ctx = Context {
135 root,
136 index,
137 registry: Some(&self.registry),
138 facts: Some(&fact_values),
139 vars: Some(&self.vars),
140 git_tracked: git_tracked.as_ref(),
141 git_blame: git_blame.as_ref(),
142 };
143 let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
144 root,
145 index: fi,
146 registry: Some(&self.registry),
147 facts: Some(&fact_values),
148 vars: Some(&self.vars),
149 git_tracked: git_tracked.as_ref(),
150 git_blame: git_blame.as_ref(),
151 });
152 let when_env = WhenEnv {
153 facts: &fact_values,
154 vars: &self.vars,
155 iter: None,
156 };
157 let results: Vec<RuleResult> = self
158 .entries
159 .par_iter()
160 .filter_map(|entry| {
161 if self.skip_for_changed(entry.rule.as_ref()) {
162 return None;
163 }
164 let ctx = pick_ctx(entry.rule.as_ref(), &full_ctx, filtered_ctx.as_ref());
165 run_entry(entry, ctx, &when_env, &fact_values)
166 })
167 .collect();
168 Ok(Report { results })
169 }
170
171 pub fn fix(&self, root: &Path, index: &FileIndex, dry_run: bool) -> Result<FixReport> {
178 if self.changed_paths.as_ref().is_some_and(HashSet::is_empty) {
179 return Ok(FixReport {
180 results: Vec::new(),
181 });
182 }
183
184 let fact_values = evaluate_facts(&self.facts, root, index)?;
185 let git_tracked = self.collect_git_tracked_if_needed(root);
186 let git_blame = self.build_blame_cache_if_needed(root);
187 let filtered_index = self.build_filtered_index(index);
188 let full_ctx = Context {
189 root,
190 index,
191 registry: Some(&self.registry),
192 facts: Some(&fact_values),
193 vars: Some(&self.vars),
194 git_tracked: git_tracked.as_ref(),
195 git_blame: git_blame.as_ref(),
196 };
197 let filtered_ctx = filtered_index.as_ref().map(|fi| Context {
198 root,
199 index: fi,
200 registry: Some(&self.registry),
201 facts: Some(&fact_values),
202 vars: Some(&self.vars),
203 git_tracked: git_tracked.as_ref(),
204 git_blame: git_blame.as_ref(),
205 });
206 let when_env = WhenEnv {
207 facts: &fact_values,
208 vars: &self.vars,
209 iter: None,
210 };
211 let fix_ctx = FixContext {
212 root,
213 dry_run,
214 fix_size_limit: self.fix_size_limit,
215 };
216
217 let mut results: Vec<FixRuleResult> = Vec::new();
218 for entry in &self.entries {
219 if self.skip_for_changed(entry.rule.as_ref()) {
220 continue;
221 }
222 let ctx = pick_ctx(entry.rule.as_ref(), &full_ctx, filtered_ctx.as_ref());
223 if let Some(expr) = &entry.when {
224 match expr.evaluate(&when_env) {
225 Ok(true) => {}
226 Ok(false) => continue,
227 Err(e) => {
228 results.push(FixRuleResult {
229 rule_id: entry.rule.id().to_string(),
230 level: entry.rule.level(),
231 items: vec![FixItem {
232 violation: Violation::new(format!("when evaluation error: {e}")),
233 status: FixStatus::Unfixable,
234 }],
235 });
236 continue;
237 }
238 }
239 }
240 let violations = match entry.rule.evaluate(ctx) {
241 Ok(v) => v,
242 Err(e) => vec![Violation::new(format!("rule error: {e}"))],
243 };
244 if violations.is_empty() {
245 continue;
246 }
247 let fixer = entry.rule.fixer();
248 let items: Vec<FixItem> = violations
249 .into_iter()
250 .map(|v| {
251 let status = match fixer {
252 Some(f) => match f.apply(&v, &fix_ctx) {
253 Ok(FixOutcome::Applied(s)) => FixStatus::Applied(s),
254 Ok(FixOutcome::Skipped(s)) => FixStatus::Skipped(s),
255 Err(e) => FixStatus::Skipped(format!("fix error: {e}")),
256 },
257 None => FixStatus::Unfixable,
258 };
259 FixItem {
260 violation: v,
261 status,
262 }
263 })
264 .collect();
265 results.push(FixRuleResult {
266 rule_id: entry.rule.id().to_string(),
267 level: entry.rule.level(),
268 items,
269 });
270 }
271 Ok(FixReport { results })
272 }
273
274 fn collect_git_tracked_if_needed(
283 &self,
284 root: &Path,
285 ) -> Option<std::collections::HashSet<std::path::PathBuf>> {
286 let any_wants = self.entries.iter().any(|e| e.rule.wants_git_tracked());
287 if !any_wants {
288 return None;
289 }
290 crate::git::collect_tracked_paths(root)
291 }
292
293 fn build_blame_cache_if_needed(&self, root: &Path) -> Option<crate::git::BlameCache> {
307 let any_wants = self.entries.iter().any(|e| e.rule.wants_git_blame());
308 if !any_wants {
309 return None;
310 }
311 crate::git::collect_tracked_paths(root)?;
315 Some(crate::git::BlameCache::new(root.to_path_buf()))
316 }
317
318 fn build_filtered_index(&self, full: &FileIndex) -> Option<FileIndex> {
323 let set = self.changed_paths.as_ref()?;
324 let entries = full
325 .entries
326 .iter()
327 .filter(|e| set.contains(&e.path))
328 .cloned()
329 .collect();
330 Some(FileIndex { entries })
331 }
332
333 fn skip_for_changed(&self, rule: &dyn Rule) -> bool {
339 let Some(set) = &self.changed_paths else {
340 return false;
341 };
342 let Some(scope) = rule.path_scope() else {
343 return false;
344 };
345 !set.iter().any(|p| scope.matches(p))
346 }
347}
348
349fn pick_ctx<'a>(
354 rule: &dyn Rule,
355 full_ctx: &'a Context<'a>,
356 filtered_ctx: Option<&'a Context<'a>>,
357) -> &'a Context<'a> {
358 if rule.requires_full_index() {
359 full_ctx
360 } else {
361 filtered_ctx.unwrap_or(full_ctx)
362 }
363}
364
365fn run_entry(
366 entry: &RuleEntry,
367 ctx: &Context<'_>,
368 when_env: &WhenEnv<'_>,
369 _facts: &FactValues,
370) -> Option<RuleResult> {
371 if let Some(expr) = &entry.when {
372 match expr.evaluate(when_env) {
373 Ok(true) => {} Ok(false) => return None,
375 Err(e) => {
376 return Some(RuleResult {
377 rule_id: entry.rule.id().to_string(),
378 level: entry.rule.level(),
379 policy_url: entry.rule.policy_url().map(str::to_string),
380 violations: vec![Violation::new(format!("when evaluation error: {e}"))],
381 is_fixable: entry.rule.fixer().is_some(),
382 });
383 }
384 }
385 }
386 Some(run_one(entry.rule.as_ref(), ctx))
387}
388
389fn run_one(rule: &dyn Rule, ctx: &Context<'_>) -> RuleResult {
390 let violations = match rule.evaluate(ctx) {
391 Ok(v) => v,
392 Err(e) => vec![Violation::new(format!("rule error: {e}"))],
393 };
394 RuleResult {
395 rule_id: rule.id().to_string(),
396 level: rule.level(),
397 policy_url: rule.policy_url().map(str::to_string),
398 violations,
399 is_fixable: rule.fixer().is_some(),
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::level::Level;
407 use crate::scope::Scope;
408 use crate::walker::FileEntry;
409 use std::path::Path;
410
411 #[derive(Debug)]
416 struct StubRule {
417 id: String,
418 level: Level,
419 scope: Scope,
420 full_index: bool,
421 expose_scope: bool,
422 }
423
424 impl Rule for StubRule {
425 fn id(&self) -> &str {
426 &self.id
427 }
428 fn level(&self) -> Level {
429 self.level
430 }
431 fn requires_full_index(&self) -> bool {
432 self.full_index
433 }
434 fn path_scope(&self) -> Option<&Scope> {
435 self.expose_scope.then_some(&self.scope)
436 }
437 fn evaluate(&self, ctx: &Context<'_>) -> crate::error::Result<Vec<Violation>> {
438 let mut out = Vec::new();
439 for entry in ctx.index.files() {
440 if self.scope.matches(&entry.path) {
441 out.push(Violation::new("hit").with_path(&entry.path));
442 }
443 }
444 Ok(out)
445 }
446 }
447
448 fn stub(id: &str, glob: &str) -> Box<dyn Rule> {
449 Box::new(StubRule {
450 id: id.into(),
451 level: Level::Error,
452 scope: Scope::from_patterns(&[glob.to_string()]).unwrap(),
453 full_index: false,
454 expose_scope: true,
455 })
456 }
457
458 fn full_index_stub(id: &str) -> Box<dyn Rule> {
459 Box::new(StubRule {
460 id: id.into(),
461 level: Level::Error,
462 scope: Scope::match_all(),
463 full_index: true,
464 expose_scope: false,
465 })
466 }
467
468 fn idx(paths: &[&str]) -> FileIndex {
469 FileIndex {
470 entries: paths
471 .iter()
472 .map(|p| FileEntry {
473 path: std::path::PathBuf::from(p),
474 is_dir: false,
475 size: 0,
476 })
477 .collect(),
478 }
479 }
480
481 #[test]
482 fn run_empty_returns_empty_report() {
483 let engine = Engine::new(Vec::new(), RuleRegistry::new());
484 let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
485 assert!(report.results.is_empty());
486 }
487
488 #[test]
489 fn run_single_rule_emits_per_match() {
490 let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
491 let report = engine
492 .run(
493 Path::new("/fake"),
494 &idx(&["src/a.rs", "src/b.rs", "README.md"]),
495 )
496 .unwrap();
497 assert_eq!(report.results.len(), 1);
498 assert_eq!(report.results[0].violations.len(), 2);
499 }
500
501 #[test]
502 fn run_with_empty_changed_set_short_circuits() {
503 let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new())
507 .with_changed_paths(HashSet::new());
508 let report = engine.run(Path::new("/fake"), &idx(&["src/a.rs"])).unwrap();
509 assert!(report.results.is_empty());
510 }
511
512 #[test]
513 fn changed_mode_skips_rule_whose_scope_misses_diff() {
514 let mut changed = HashSet::new();
517 changed.insert(std::path::PathBuf::from("docs/README.md"));
518 let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
519 .with_changed_paths(changed);
520 let report = engine
521 .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
522 .unwrap();
523 assert!(
524 report.results.is_empty(),
525 "out-of-scope rule should be skipped: {:?}",
526 report.results,
527 );
528 }
529
530 #[test]
531 fn changed_mode_runs_rule_whose_scope_intersects_diff() {
532 let mut changed = HashSet::new();
533 changed.insert(std::path::PathBuf::from("src/a.rs"));
534 let engine = Engine::new(vec![stub("src-rule", "src/**/*.rs")], RuleRegistry::new())
535 .with_changed_paths(changed);
536 let report = engine
537 .run(Path::new("/fake"), &idx(&["src/a.rs", "src/b.rs"]))
538 .unwrap();
539 assert_eq!(report.results.len(), 1);
542 assert_eq!(report.results[0].violations.len(), 1);
543 }
544
545 #[test]
546 fn requires_full_index_rule_runs_unconditionally_in_changed_mode() {
547 let mut changed = HashSet::new();
551 changed.insert(std::path::PathBuf::from("docs/README.md"));
552 let engine = Engine::new(vec![full_index_stub("cross")], RuleRegistry::new())
553 .with_changed_paths(changed);
554 let report = engine
555 .run(Path::new("/fake"), &idx(&["src/a.rs", "docs/README.md"]))
556 .unwrap();
557 assert_eq!(report.results.len(), 1);
560 assert_eq!(report.results[0].violations.len(), 2);
561 }
562
563 #[test]
564 fn rule_count_reflects_number_of_entries() {
565 let engine = Engine::new(
566 vec![stub("a", "**"), stub("b", "**"), stub("c", "**")],
567 RuleRegistry::new(),
568 );
569 assert_eq!(engine.rule_count(), 3);
570 }
571
572 #[test]
573 fn from_entries_constructor_supports_when_clauses() {
574 let entry = RuleEntry::new(stub("gated", "**/*.rs"))
577 .with_when(crate::when::parse("false").unwrap());
578 let engine = Engine::from_entries(vec![entry], RuleRegistry::new());
579 let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
580 assert!(
581 report.results.is_empty(),
582 "when-false rule must be skipped: {:?}",
583 report.results,
584 );
585 }
586
587 #[test]
588 fn fix_size_limit_default_is_one_mib() {
589 let engine = Engine::new(Vec::new(), RuleRegistry::new());
592 let updated = engine.with_fix_size_limit(Some(42));
596 assert_eq!(updated.rule_count(), 0);
597 }
598
599 #[test]
600 fn skip_for_changed_returns_false_for_full_check() {
601 let engine = Engine::new(vec![stub("t", "**/*.rs")], RuleRegistry::new());
603 let report = engine.run(Path::new("/fake"), &idx(&["a.rs"])).unwrap();
604 assert_eq!(report.results.len(), 1);
605 }
606}