fast_decision/types.rs
1//! Data structures and type definitions.
2//!
3//! This module contains all data structures used by the rule engine:
4//! - [`RuleSet`]: Top-level container for all categories
5//! - [`Category`]: Collection of rules with evaluation settings
6//! - [`Rule`]: Individual rule with conditions and action
7//! - [`Predicate`]: AST for condition evaluation (Comparison, AND, OR)
8//! - [`Comparison`]: Single field comparison operation
9//! - [`Operator`]: Comparison operators
10//!
11//! All types implement custom deserialization for optimal memory layout.
12
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16
17/// Converts a dot-separated path string into a boxed slice of tokens.
18///
19/// # Performance
20///
21/// Uses `Box<[String]>` instead of `Vec<String>` to save 8 bytes per comparison
22/// (eliminates capacity field).
23///
24/// # Examples
25///
26/// ```ignore
27/// let tokens = tokenize_path("user.profile.age");
28/// assert_eq!(tokens.len(), 3);
29/// ```
30fn tokenize_path(path: &str) -> Box<[String]> {
31 path.split('.')
32 .map(|s| s.to_owned())
33 .collect::<Vec<_>>()
34 .into_boxed_slice()
35}
36
37/// Comparison operators.
38///
39/// # Memory Layout
40///
41/// Uses `#[repr(u8)]` for minimal memory footprint (1 byte per operator).
42///
43/// # Supported Operators
44///
45/// ## Comparison Operators
46/// - `$equals`: Equal
47/// - `$not-equals`: Not equal
48/// - `$greater-than`: Greater than
49/// - `$less-than`: Less than
50/// - `$greater-than-or-equals`: Greater than or equal
51/// - `$less-than-or-equals`: Less than or equal
52///
53/// ## Membership Operators
54/// - `$in`: Value is in array
55/// - `$not-in`: Value is not in array
56///
57/// ## String Operators
58/// - `$contains`: Case-sensitive substring check
59/// - `$starts-with`: String starts with value
60/// - `$ends-with`: String ends with value
61/// - `$regex`: Regular expression matching
62#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
63#[repr(u8)]
64pub enum Operator {
65 // Comparison operators
66 #[serde(rename = "$equals")]
67 Equal,
68 #[serde(rename = "$not-equals")]
69 NotEqual,
70 #[serde(rename = "$greater-than")]
71 GreaterThan,
72 #[serde(rename = "$less-than")]
73 LessThan,
74 #[serde(rename = "$greater-than-or-equals")]
75 GreaterThanOrEqual,
76 #[serde(rename = "$less-than-or-equals")]
77 LessThanOrEqual,
78
79 // Membership operators
80 #[serde(rename = "$in")]
81 In,
82 #[serde(rename = "$not-in")]
83 NotIn,
84
85 // String operators
86 #[serde(rename = "$contains")]
87 Contains,
88 #[serde(rename = "$starts-with")]
89 StartsWith,
90 #[serde(rename = "$ends-with")]
91 EndsWith,
92 #[serde(rename = "$regex")]
93 Regex,
94}
95
96/// A single field comparison operation.
97///
98/// # Fields
99///
100/// - `path_tokens`: Tokenized field path (e.g., `["user", "tier"]` for `"user.tier"`)
101/// - `op`: Comparison operator
102/// - `value`: Expected value to compare against
103///
104/// # Memory Optimization
105///
106/// Uses `Box<[String]>` for path tokens to minimize memory overhead.
107#[derive(Debug, Clone, Serialize)]
108pub struct Comparison {
109 #[serde(rename = "path")]
110 pub path_tokens: Box<[String]>,
111 pub op: Operator,
112 pub value: Value,
113}
114
115/// Abstract Syntax Tree (AST) node for condition evaluation.
116///
117/// Predicates can be nested to form complex logical expressions.
118///
119/// # Variants
120///
121/// - `Comparison`: Leaf node (single field comparison)
122/// - `And`: All child predicates must be true
123/// - `Or`: At least one child predicate must be true
124///
125/// # Examples
126///
127/// Simple comparison:
128/// ```json
129/// {"user.tier": {"$eq": "Gold"}}
130/// ```
131///
132/// Complex AND:
133/// ```json
134/// {"user.tier": {"$eq": "Gold"}, "amount": {"$gt": 100}}
135/// ```
136///
137/// Explicit OR:
138/// ```json
139/// {"$or": [{"tier": {"$eq": "Gold"}}, {"tier": {"$eq": "Platinum"}}]}
140/// ```
141#[derive(Debug, Clone, Serialize)]
142#[serde(untagged)]
143pub enum Predicate {
144 Comparison(Comparison),
145 #[serde(rename = "$and")]
146 And(Vec<Predicate>),
147 #[serde(rename = "$or")]
148 Or(Vec<Predicate>),
149}
150
151/// A category containing multiple rules with evaluation settings.
152///
153/// # Fields
154///
155/// - `stop_on_first`: If `true`, evaluation stops after the first matching rule
156/// - `rules`: List of rules (automatically sorted by priority during deserialization)
157///
158/// # Priority Sorting
159///
160/// Rules are sorted by priority (lower value = higher precedence) when deserialized.
161#[derive(Debug, Clone)]
162pub struct Category {
163 pub stop_on_first: bool,
164 pub rules: Vec<Rule>,
165}
166
167/// An individual rule with conditions and action.
168///
169/// # Fields
170///
171/// - `id`: Unique identifier for the rule
172/// - `priority`: Evaluation priority (lower = higher precedence, default: 0)
173/// - `predicate`: Condition tree (deserialized from `conditions` field)
174/// - `action`: Action identifier (informational, not evaluated by engine)
175/// - `metadata`: Optional metadata for tracing, compliance, or custom annotations
176///
177/// # JSON Format
178///
179/// ```json
180/// {
181/// "id": "Premium_User",
182/// "priority": 1,
183/// "conditions": {"user.tier": {"$equals": "Gold"}},
184/// "action": "apply_discount",
185/// "metadata": {
186/// "source": "Pricing Rules v2.3",
187/// "tags": ["premium", "discount"]
188/// }
189/// }
190/// ```
191///
192/// The `metadata` field is optional and will be included in evaluation results if present.
193#[derive(Debug, Clone, Serialize)]
194pub struct Rule {
195 pub id: String,
196 pub priority: i32,
197 #[serde(rename = "conditions")]
198 pub predicate: Predicate,
199 pub action: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub metadata: Option<serde_json::Map<String, Value>>,
202}
203
204impl Predicate {
205 /// Recursively deserializes a serde_json::Value into a Predicate AST.
206 fn deserialize_from_value(value: Value) -> Result<Self, String> {
207 let map = value
208 .as_object()
209 .ok_or_else(|| format!("Predicate must be a JSON object{}", ""))?;
210
211 let mut predicates = Vec::new();
212
213 for (key, val) in map {
214 match key.as_str() {
215 // Handle explicit AND/OR operators
216 "$and" | "$or" => {
217 let arr = val
218 .as_array()
219 .ok_or_else(|| format!("'{}' must be an array of objects", key))?;
220
221 if map.len() > 1 {
222 return Err(format!(
223 "If '{}' is present, it must be the only top-level key in the predicate",
224 key
225 ));
226 }
227
228 let children: Result<Vec<Predicate>, _> = arr
229 .iter()
230 .cloned()
231 .map(Predicate::deserialize_from_value) // Recursive call
232 .collect();
233
234 let children = children?;
235
236 return match key.as_str() {
237 "$and" => Ok(Predicate::And(children)),
238 "$or" => Ok(Predicate::Or(children)),
239 _ => unreachable!(),
240 };
241 }
242 // Handle field path (leaf node)
243 field_path => {
244 // This must be an object of operators: {"path": {"$op": value}}
245 let operators_map = val.as_object().ok_or_else(|| {
246 format!(
247 "Value for field path '{}' must be an object of operators",
248 field_path
249 )
250 })?;
251
252 // Flat structure of conditions (implicit AND)
253 for (op_str, comp_value) in operators_map {
254 let op = match op_str.as_str() {
255 "$equals" => Operator::Equal,
256 "$not-equals" => Operator::NotEqual,
257 "$greater-than" => Operator::GreaterThan,
258 "$less-than" => Operator::LessThan,
259 "$greater-than-or-equals" => Operator::GreaterThanOrEqual,
260 "$less-than-or-equals" => Operator::LessThanOrEqual,
261 "$in" => Operator::In,
262 "$not-in" => Operator::NotIn,
263 "$contains" => Operator::Contains,
264 "$starts-with" => Operator::StartsWith,
265 "$ends-with" => Operator::EndsWith,
266 "$regex" => Operator::Regex,
267 _ => return Err(format!("Unknown operator: {}", op_str)),
268 };
269
270 predicates.push(Predicate::Comparison(Comparison {
271 path_tokens: tokenize_path(field_path),
272 op,
273 value: comp_value.clone(),
274 }));
275 }
276 }
277 }
278 }
279
280 // If we reached this point, we processed a flat structure (implicit AND).
281 match predicates.len() {
282 0 => Err(format!(
283 "Rule condition must contain at least one comparison{}",
284 ""
285 )),
286 1 => Ok(predicates.pop().unwrap()), // Single condition
287 _ => Ok(Predicate::And(predicates)), // Implicit AND
288 }
289 }
290}
291
292impl Category {
293 /// Checks for rules with duplicate priorities and logs warnings.
294 ///
295 /// Duplicate priorities may result in non-deterministic evaluation order
296 /// for rules with the same priority value.
297 ///
298 /// # Arguments
299 ///
300 /// * `category_name` - Name of the category (for logging)
301 pub fn warn_duplicate_priorities(&self, category_name: &str) {
302 use std::collections::HashMap;
303 let mut priority_count: HashMap<i32, Vec<&str>> = HashMap::new();
304
305 for rule in &self.rules {
306 priority_count
307 .entry(rule.priority)
308 .or_default()
309 .push(&rule.id);
310 }
311
312 for (priority, ids) in priority_count {
313 if ids.len() > 1 {
314 log::warn!(
315 "Category '{}': Multiple rules with priority {}: {:?}",
316 category_name,
317 priority,
318 ids
319 );
320 }
321 }
322 }
323}
324
325impl<'de> Deserialize<'de> for Category {
326 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
327 where
328 D: Deserializer<'de>,
329 {
330 #[derive(Deserialize)]
331 #[serde(untagged)]
332 enum CategoryHelper {
333 WithConfig {
334 stop_on_first: bool,
335 rules: Vec<Rule>,
336 },
337 Simple(Vec<Rule>),
338 }
339
340 match CategoryHelper::deserialize(deserializer)? {
341 CategoryHelper::WithConfig {
342 stop_on_first,
343 mut rules,
344 } => {
345 rules.sort_by_key(|r| r.priority);
346 Ok(Category {
347 stop_on_first,
348 rules,
349 })
350 }
351 CategoryHelper::Simple(mut rules) => {
352 rules.sort_by_key(|r| r.priority);
353 Ok(Category {
354 stop_on_first: false,
355 rules,
356 })
357 }
358 }
359 }
360}
361
362impl<'de> Deserialize<'de> for Rule {
363 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364 where
365 D: Deserializer<'de>,
366 {
367 #[derive(Deserialize)]
368 struct RuleHelper {
369 id: String,
370 #[serde(default)]
371 priority: i32,
372 conditions: Value,
373 action: String,
374 #[serde(default)]
375 metadata: Option<serde_json::Map<String, Value>>,
376 }
377
378 let helper = RuleHelper::deserialize(deserializer)?;
379
380 let predicate = Predicate::deserialize_from_value(helper.conditions)
381 .map_err(serde::de::Error::custom)?;
382
383 Ok(Rule {
384 id: helper.id,
385 priority: helper.priority,
386 predicate,
387 action: helper.action,
388 metadata: helper.metadata,
389 })
390 }
391}
392
393/// Top-level container for all rule categories.
394///
395/// # JSON Format
396///
397/// ```json
398/// {
399/// "categories": {
400/// "Pricing": {
401/// "stop_on_first": true,
402/// "rules": [...]
403/// },
404/// "Fraud": {
405/// "stop_on_first": false,
406/// "rules": [...]
407/// }
408/// }
409/// }
410/// ```
411///
412/// # Performance
413///
414/// Uses `HashMap` for O(1) category lookup by name.
415#[derive(Debug, Deserialize, Clone)]
416pub struct RuleSet {
417 pub categories: HashMap<String, Category>,
418}