fast_decision/engine.rs
1//! Rule execution engine.
2//!
3//! This module contains the core rule execution logic, including:
4//! - Nested field value extraction
5//! - Comparison operations (eq, ne, gt, lt, gte, lte)
6//! - Predicate evaluation (AND/OR logic)
7//! - Rule matching and execution
8//!
9//! All comparison and lookup functions are marked `#[inline]` for optimal performance.
10
11use crate::types::{Comparison, Operator, Predicate, Rule, RuleSet};
12use log::{debug, trace};
13use serde_json::Value;
14
15/// Retrieves a nested value from JSON data using dot-separated path tokens.
16///
17/// # Performance
18///
19/// This function is marked `#[inline]` and performs O(d) lookups where d is the path depth.
20/// Returns `None` immediately if any path component doesn't exist.
21///
22/// # Examples
23///
24/// ```ignore
25/// let data = json!({"user": {"profile": {"age": 25}}});
26/// let tokens = vec!["user".to_string(), "profile".to_string(), "age".to_string()];
27/// let value = get_nested_value(&data, &tokens);
28/// assert_eq!(value, Some(&json!(25)));
29/// ```
30#[inline]
31fn get_nested_value<'a>(data: &'a Value, tokens: &[String]) -> Option<&'a Value> {
32 let mut current = data;
33 for token in tokens {
34 current = current.as_object()?.get(token)?;
35 }
36 Some(current)
37}
38
39/// Compares two JSON values for equality.
40///
41/// For numeric values, uses epsilon comparison for floating-point safety.
42/// For other types, uses direct equality comparison.
43///
44/// # Performance
45///
46/// Marked `#[inline(always)]` for zero-cost abstraction in hot path.
47#[inline(always)]
48fn compare_eq(v1: &Value, v2: &Value) -> bool {
49 if let (Some(n1), Some(n2)) = (v1.as_f64(), v2.as_f64()) {
50 return (n1 - n2).abs() < f64::EPSILON;
51 }
52 v1 == v2
53}
54
55/// Greater-than comparison for numeric values.
56///
57/// Returns `false` if either value is not numeric.
58/// Marked `#[inline(always)]` for hot path optimization.
59#[inline(always)]
60fn compare_gt(v1: &Value, v2: &Value) -> bool {
61 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 > n2)
62}
63
64/// Less-than comparison for numeric values.
65///
66/// Returns `false` if either value is not numeric.
67/// Marked `#[inline(always)]` for hot path optimization.
68#[inline(always)]
69fn compare_lt(v1: &Value, v2: &Value) -> bool {
70 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 < n2)
71}
72
73/// Greater-than-or-equal comparison for numeric values.
74///
75/// Returns `false` if either value is not numeric.
76/// Marked `#[inline(always)]` for hot path optimization.
77#[inline(always)]
78fn compare_gte(v1: &Value, v2: &Value) -> bool {
79 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 >= n2)
80}
81
82/// Less-than-or-equal comparison for numeric values.
83///
84/// Returns `false` if either value is not numeric.
85/// Marked `#[inline(always)]` for hot path optimization.
86#[inline(always)]
87fn compare_lte(v1: &Value, v2: &Value) -> bool {
88 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 <= n2)
89}
90
91/// Evaluates a single comparison against data.
92///
93/// Extracts the value at the specified path and applies the comparison operator.
94/// Returns `false` if the path doesn't exist in the data.
95fn check_comparison(data: &Value, comp: &Comparison) -> bool {
96 let data_value = match get_nested_value(data, &comp.path_tokens) {
97 Some(v) => v,
98 None => return false,
99 };
100
101 match comp.op {
102 Operator::Equal => compare_eq(data_value, &comp.value),
103 Operator::NotEqual => !compare_eq(data_value, &comp.value),
104 Operator::GreaterThan => compare_gt(data_value, &comp.value),
105 Operator::LessThan => compare_lt(data_value, &comp.value),
106 Operator::GreaterThanOrEqual => compare_gte(data_value, &comp.value),
107 Operator::LessThanOrEqual => compare_lte(data_value, &comp.value),
108 }
109}
110
111/// Recursively evaluates a predicate (comparison, AND, or OR).
112///
113/// # Logic
114///
115/// - `Comparison`: Direct comparison evaluation
116/// - `And`: All child predicates must be true (short-circuits on first false)
117/// - `Or`: At least one child predicate must be true (short-circuits on first true)
118fn check_predicate(data: &Value, predicate: &Predicate) -> bool {
119 match predicate {
120 Predicate::Comparison(comp) => check_comparison(data, comp),
121 // AND logic: all child predicates must be true
122 Predicate::And(predicates) => predicates.iter().all(|p| check_predicate(data, p)),
123 // OR logic: at least one child predicate must be true
124 Predicate::Or(predicates) => predicates.iter().any(|p| check_predicate(data, p)),
125 }
126}
127
128/// The main rule execution engine.
129///
130/// Holds a compiled ruleset and provides methods to execute rules against data.
131///
132/// # Performance
133///
134/// The engine pre-sorts rules by priority during construction and performs
135/// minimal allocations during execution.
136pub struct RuleEngine {
137 ruleset: RuleSet,
138}
139
140impl RuleEngine {
141 /// Creates a new rule engine from a ruleset.
142 ///
143 /// # Warning Detection
144 ///
145 /// This constructor checks for duplicate priorities within categories
146 /// and logs warnings if found (order of execution may be non-deterministic).
147 ///
148 /// # Examples
149 ///
150 /// ```rust,no_run
151 /// use fast_decision::{RuleEngine, RuleSet};
152 /// # let rules_json = "{}";
153 /// let ruleset: RuleSet = serde_json::from_str(rules_json).unwrap();
154 /// let engine = RuleEngine::new(ruleset);
155 /// ```
156 pub fn new(ruleset: RuleSet) -> Self {
157 for (name, category) in &ruleset.categories {
158 category.warn_duplicate_priorities(name);
159 }
160 RuleEngine { ruleset }
161 }
162
163 /// Checks if a single rule matches the given data.
164 ///
165 /// Returns `Some(&rule.id)` if the rule matches, `None` otherwise.
166 /// Logs trace and debug messages via the `log` crate.
167 fn check_rule<'a>(data: &Value, rule: &'a Rule) -> Option<&'a str> {
168 trace!("Checking rule: {}", rule.id);
169 if check_predicate(data, &rule.predicate) {
170 debug!("Rule {} triggered", rule.id);
171 Some(&rule.id)
172 } else {
173 trace!("Rule {} not triggered", rule.id);
174 None
175 }
176 }
177
178 /// Executes rules from specified categories against the provided data.
179 ///
180 /// # Arguments
181 ///
182 /// * `data` - JSON data to evaluate rules against
183 /// * `categories_to_run` - List of category names to execute
184 ///
185 /// # Returns
186 ///
187 /// A vector of rule IDs (as string slices) that matched the data, in priority order.
188 ///
189 /// # Behavior
190 ///
191 /// - Rules are evaluated in priority order (lower priority value = higher precedence)
192 /// - If a category has `stop_on_first: true`, execution stops after the first match
193 /// - Non-existent categories are silently skipped
194 /// - Results accumulate across all requested categories
195 ///
196 /// # Performance
197 ///
198 /// - O(n) where n is total number of rules in requested categories
199 /// - Pre-allocates result vector with exact capacity
200 /// - Minimal allocations during execution
201 ///
202 /// # Examples
203 ///
204 /// ```rust,no_run
205 /// # use fast_decision::{RuleEngine, RuleSet};
206 /// # use serde_json::json;
207 /// # let ruleset: RuleSet = serde_json::from_str("{}").unwrap();
208 /// # let engine = RuleEngine::new(ruleset);
209 /// let data = json!({"user": {"tier": "Gold"}});
210 /// let results = engine.execute(&data, &["Pricing", "Fraud"]);
211 /// for rule_id in results {
212 /// println!("Matched rule: {}", rule_id);
213 /// }
214 /// ```
215 pub fn execute<'a>(&'a self, data: &Value, categories_to_run: &[&str]) -> Vec<&'a str> {
216 let categories: Vec<_> = categories_to_run
217 .iter()
218 .filter_map(|&name| self.ruleset.categories.get(name).map(|cat| (name, cat)))
219 .collect();
220
221 let total_rules: usize = categories.iter().map(|(_, cat)| cat.rules.len()).sum();
222 let mut results = Vec::with_capacity(total_rules);
223
224 for (_name, category) in categories {
225 debug!(
226 "Processing category: {} ({} rules)",
227 _name,
228 category.rules.len()
229 );
230
231 for rule in &category.rules {
232 if let Some(rule_id) = Self::check_rule(data, rule) {
233 results.push(rule_id);
234
235 if category.stop_on_first {
236 debug!("stop_on_first enabled, stopping after first match");
237 break;
238 }
239 }
240 }
241 }
242 results
243 }
244}