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}