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