fast_decision/engine.rs
1//! Rule evaluation engine.
2//!
3//! This module contains the core rule evaluation logic, including:
4//! - Nested field value extraction
5//! - Comparison operations (equals, not-equals, greater-than, less-than, greater-than-or-equals, less-than-or-equals)
6//! - Membership operations (in, not-in)
7//! - String operations (contains, starts-with, ends-with, regex)
8//! - Predicate evaluation (AND/OR logic)
9//! - Rule matching and evaluation
10//!
11//! All comparison and lookup functions are marked `#[inline]` for optimal performance.
12
13use crate::types::{Comparison, Operator, Predicate, Rule, RuleSet};
14use log::{debug, trace};
15use regex::Regex;
16use serde_json::Value;
17
18/// Retrieves a nested value from JSON data using dot-separated path tokens.
19///
20/// # Performance
21///
22/// This function is marked `#[inline]` and performs O(d) lookups where d is the path depth.
23/// Returns `None` immediately if any path component doesn't exist.
24///
25/// # Examples
26///
27/// ```ignore
28/// let data = json!({"user": {"profile": {"age": 25}}});
29/// let tokens = vec!["user".to_string(), "profile".to_string(), "age".to_string()];
30/// let value = get_nested_value(&data, &tokens);
31/// assert_eq!(value, Some(&json!(25)));
32/// ```
33#[inline]
34fn get_nested_value<'a>(data: &'a Value, tokens: &[String]) -> Option<&'a Value> {
35 let mut current = data;
36 for token in tokens {
37 current = current.as_object()?.get(token)?;
38 }
39 Some(current)
40}
41
42/// Compares two JSON values for equality.
43///
44/// For numeric values, uses epsilon comparison for floating-point safety.
45/// For other types, uses direct equality comparison.
46///
47/// # Performance
48///
49/// Marked `#[inline(always)]` for zero-cost abstraction in hot path.
50#[inline(always)]
51fn compare_eq(v1: &Value, v2: &Value) -> bool {
52 if let (Some(n1), Some(n2)) = (v1.as_f64(), v2.as_f64()) {
53 return (n1 - n2).abs() < f64::EPSILON;
54 }
55 if let (Some(b1), Some(b2)) = (v1.as_bool(), v2.as_bool()) {
56 return b1 == b2;
57 }
58 v1 == v2
59}
60
61/// Greater-than comparison for numeric values.
62///
63/// Returns `false` if either value is not numeric.
64/// Marked `#[inline(always)]` for hot path optimization.
65#[inline(always)]
66fn compare_gt(v1: &Value, v2: &Value) -> bool {
67 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 > n2)
68}
69
70/// Less-than comparison for numeric values.
71///
72/// Returns `false` if either value is not numeric.
73/// Marked `#[inline(always)]` for hot path optimization.
74#[inline(always)]
75fn compare_lt(v1: &Value, v2: &Value) -> bool {
76 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 < n2)
77}
78
79/// Greater-than-or-equal comparison for numeric values.
80///
81/// Returns `false` if either value is not numeric.
82/// Marked `#[inline(always)]` for hot path optimization.
83#[inline(always)]
84fn compare_gte(v1: &Value, v2: &Value) -> bool {
85 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 >= n2)
86}
87
88/// Less-than-or-equal comparison for numeric values.
89///
90/// Returns `false` if either value is not numeric.
91/// Marked `#[inline(always)]` for hot path optimization.
92#[inline(always)]
93fn compare_lte(v1: &Value, v2: &Value) -> bool {
94 matches!((v1.as_f64(), v2.as_f64()), (Some(n1), Some(n2)) if n1 <= n2)
95}
96
97/// Checks if a value is in an array.
98///
99/// Returns `true` if the data value equals any element in the array.
100/// Returns `false` if the comparison value is not an array.
101/// Marked `#[inline(always)]` for hot path optimization.
102#[inline(always)]
103fn compare_in(v1: &Value, v2: &Value) -> bool {
104 if let Some(arr) = v2.as_array() {
105 return arr.iter().any(|elem| compare_eq(v1, elem));
106 }
107 false
108}
109
110/// Checks if a value is not in an array.
111///
112/// Returns `true` if the data value does not equal any element in the array.
113/// Returns `false` if the comparison value is not an array.
114/// Marked `#[inline(always)]` for hot path optimization.
115#[inline(always)]
116fn compare_not_in(v1: &Value, v2: &Value) -> bool {
117 !compare_in(v1, v2)
118}
119
120/// Case-sensitive substring check for strings.
121///
122/// Returns `true` if v1 contains v2 as a substring.
123/// Returns `false` if either value is not a string.
124/// Marked `#[inline(always)]` for hot path optimization.
125#[inline(always)]
126fn compare_contains(v1: &Value, v2: &Value) -> bool {
127 matches!(
128 (v1.as_str(), v2.as_str()),
129 (Some(s1), Some(s2)) if s1.contains(s2)
130 )
131}
132
133/// Checks if a string starts with a value.
134///
135/// Returns `true` if v1 starts with v2.
136/// Returns `false` if either value is not a string.
137/// Marked `#[inline(always)]` for hot path optimization.
138#[inline(always)]
139fn compare_starts_with(v1: &Value, v2: &Value) -> bool {
140 matches!(
141 (v1.as_str(), v2.as_str()),
142 (Some(s1), Some(s2)) if s1.starts_with(s2)
143 )
144}
145
146/// Checks if a string ends with a value.
147///
148/// Returns `true` if v1 ends with v2.
149/// Returns `false` if either value is not a string.
150/// Marked `#[inline(always)]` for hot path optimization.
151#[inline(always)]
152fn compare_ends_with(v1: &Value, v2: &Value) -> bool {
153 matches!(
154 (v1.as_str(), v2.as_str()),
155 (Some(s1), Some(s2)) if s1.ends_with(s2)
156 )
157}
158
159/// Regular expression matching for strings.
160///
161/// Compiles regex pattern and matches against data value.
162/// Returns `false` if either value is not a string or regex is invalid.
163///
164/// # Performance Note
165///
166/// This function compiles the regex on each call. For better performance
167/// in hot paths, consider pre-compiling patterns (future optimization).
168#[inline]
169fn compare_regex(v1: &Value, v2: &Value) -> bool {
170 if let (Some(text), Some(pattern)) = (v1.as_str(), v2.as_str()) {
171 if let Ok(re) = Regex::new(pattern) {
172 return re.is_match(text);
173 }
174 }
175 false
176}
177
178/// Evaluates a single comparison against data.
179///
180/// Extracts the value at the specified path and applies the comparison operator.
181/// Returns `false` if the path doesn't exist in the data.
182fn check_comparison(data: &Value, comp: &Comparison) -> bool {
183 let data_value = match get_nested_value(data, &comp.path_tokens) {
184 Some(v) => v,
185 None => return false,
186 };
187
188 match comp.op {
189 Operator::Equal => compare_eq(data_value, &comp.value),
190 Operator::NotEqual => !compare_eq(data_value, &comp.value),
191 Operator::GreaterThan => compare_gt(data_value, &comp.value),
192 Operator::LessThan => compare_lt(data_value, &comp.value),
193 Operator::GreaterThanOrEqual => compare_gte(data_value, &comp.value),
194 Operator::LessThanOrEqual => compare_lte(data_value, &comp.value),
195 Operator::In => compare_in(data_value, &comp.value),
196 Operator::NotIn => compare_not_in(data_value, &comp.value),
197 Operator::Contains => compare_contains(data_value, &comp.value),
198 Operator::StartsWith => compare_starts_with(data_value, &comp.value),
199 Operator::EndsWith => compare_ends_with(data_value, &comp.value),
200 Operator::Regex => compare_regex(data_value, &comp.value),
201 }
202}
203
204/// Recursively evaluates a predicate (comparison, AND, or OR).
205///
206/// # Logic
207///
208/// - `Comparison`: Direct comparison evaluation
209/// - `And`: All child predicates must be true (short-circuits on first false)
210/// - `Or`: At least one child predicate must be true (short-circuits on first true)
211fn check_predicate(data: &Value, predicate: &Predicate) -> bool {
212 match predicate {
213 Predicate::Comparison(comp) => check_comparison(data, comp),
214 // AND logic: all child predicates must be true
215 Predicate::And(predicates) => predicates.iter().all(|p| check_predicate(data, p)),
216 // OR logic: at least one child predicate must be true
217 Predicate::Or(predicates) => predicates.iter().any(|p| check_predicate(data, p)),
218 }
219}
220
221/// The main rule evaluation engine.
222///
223/// Holds a compiled ruleset and provides methods to evaluate rules against data.
224///
225/// # Performance
226///
227/// The engine pre-sorts rules by priority during construction and performs
228/// minimal allocations during evaluation.
229pub struct RuleEngine {
230 ruleset: RuleSet,
231}
232
233impl RuleEngine {
234 /// Creates a new rule engine from a ruleset.
235 ///
236 /// # Warning Detection
237 ///
238 /// This constructor checks for duplicate priorities within categories
239 /// and logs warnings if found (order of evaluation may be non-deterministic).
240 ///
241 /// # Examples
242 ///
243 /// ```rust,no_run
244 /// use fast_decision::{RuleEngine, RuleSet};
245 /// # let rules_json = "{}";
246 /// let ruleset: RuleSet = serde_json::from_str(rules_json).unwrap();
247 /// let engine = RuleEngine::new(ruleset);
248 /// ```
249 pub fn new(ruleset: RuleSet) -> Self {
250 for (name, category) in &ruleset.categories {
251 category.warn_duplicate_priorities(name);
252 }
253 RuleEngine { ruleset }
254 }
255
256 /// Checks if a single rule matches the given data.
257 ///
258 /// Returns `Some(&rule)` if the rule matches, `None` otherwise.
259 /// Logs trace and debug messages via the `log` crate.
260 fn check_rule<'a>(data: &Value, rule: &'a Rule) -> Option<&'a Rule> {
261 trace!("Checking rule: {}", rule.id);
262 if check_predicate(data, &rule.predicate) {
263 debug!("Rule {} triggered", rule.id);
264 Some(rule)
265 } else {
266 trace!("Rule {} not triggered", rule.id);
267 None
268 }
269 }
270
271 /// Evaluates rules from specified categories against the provided data.
272 ///
273 /// # Arguments
274 ///
275 /// * `data` - JSON data to evaluate rules against
276 /// * `categories_to_run` - List of category names to evaluate
277 ///
278 /// # Returns
279 ///
280 /// A vector of references to Rule objects that matched the data, in priority order.
281 ///
282 /// # Behavior
283 ///
284 /// - Rules are evaluated in priority order (lower priority value = higher precedence)
285 /// - If a category has `stop_on_first: true`, evaluation stops after the first match
286 /// - Non-existent categories are silently skipped
287 /// - Results accumulate across all requested categories
288 ///
289 /// # Performance
290 ///
291 /// - O(n) where n is total number of rules in requested categories
292 /// - Pre-allocates result vector with exact capacity
293 /// - Minimal allocations during evaluation
294 ///
295 /// # Examples
296 ///
297 /// ```rust,no_run
298 /// # use fast_decision::{RuleEngine, RuleSet};
299 /// # use serde_json::json;
300 /// # let ruleset: RuleSet = serde_json::from_str("{}").unwrap();
301 /// # let engine = RuleEngine::new(ruleset);
302 /// let data = json!({"user": {"tier": "Gold"}});
303 /// let results = engine.evaluate_rules(&data, &["Pricing", "Fraud"]);
304 /// for rule in results {
305 /// println!("Matched rule: {} - {}", rule.id, rule.action);
306 /// }
307 /// ```
308 pub fn evaluate_rules<'a>(&'a self, data: &Value, categories_to_run: &[&str]) -> Vec<&'a Rule> {
309 let categories: Vec<_> = categories_to_run
310 .iter()
311 .filter_map(|&name| self.ruleset.categories.get(name).map(|cat| (name, cat)))
312 .collect();
313
314 let total_rules: usize = categories.iter().map(|(_, cat)| cat.rules.len()).sum();
315 let mut results = Vec::with_capacity(total_rules);
316
317 for (_name, category) in categories {
318 debug!(
319 "Processing category: {} ({} rules)",
320 _name,
321 category.rules.len()
322 );
323
324 for rule in &category.rules {
325 if let Some(matched_rule) = Self::check_rule(data, rule) {
326 results.push(matched_rule);
327
328 if category.stop_on_first {
329 debug!("stop_on_first enabled, stopping after first match");
330 break;
331 }
332 }
333 }
334 }
335 results
336 }
337}