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}