Skip to main content

jsonlogic_fast/
lib.rs

1//! # jsonlogic-fast
2//!
3//! **Fast, embeddable, cross-runtime JSON-Logic evaluation.**
4//!
5//! Implements the full [JsonLogic](https://jsonlogic.com/) specification with
6//! batch evaluation, numeric coercion, and parallel execution via Rayon
7//! (sequential on WASM).
8//!
9//! ## Quick start
10//!
11//! ```rust
12//! use jsonlogic_fast::evaluate;
13//!
14//! let rule = r#"{">":[{"var":"age"},18]}"#;
15//! let context = r#"{"age":25}"#;
16//! let result = evaluate(rule, context).unwrap();
17//! assert_eq!(result, serde_json::json!(true));
18//! ```
19//!
20//! ## Batch evaluation
21//!
22//! ```rust
23//! use jsonlogic_fast::evaluate_batch;
24//!
25//! let rule = r#"{"var":"score"}"#;
26//! let contexts = vec![
27//!     r#"{"score":90}"#.to_string(),
28//!     r#"{"score":45}"#.to_string(),
29//! ];
30//! let results = evaluate_batch(rule, &contexts).unwrap();
31//! assert_eq!(results, vec![serde_json::json!(90), serde_json::json!(45)]);
32//! ```
33
34pub mod error;
35pub mod extract;
36
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39
40use error::{RuleEngineError, RuleEngineResult};
41use extract::extract_f64;
42
43#[cfg(not(target_arch = "wasm32"))]
44use rayon::prelude::*;
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47/// Result of a single rule evaluation with optional error.
48pub struct EvaluationResult {
49    pub result: Option<Value>,
50    pub error: Option<String>,
51}
52
53impl EvaluationResult {
54    fn ok(result: Value) -> Self {
55        Self {
56            result: Some(result),
57            error: None,
58        }
59    }
60
61    fn err(error: impl Into<String>) -> Self {
62        Self {
63            result: None,
64            error: Some(error.into()),
65        }
66    }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70/// Numeric evaluation result with optional error.
71pub struct NumericEvaluationResult {
72    pub result: f64,
73    pub error: Option<String>,
74}
75
76fn parse_rule(rule_json: &str) -> RuleEngineResult<Value> {
77    serde_json::from_str(rule_json).map_err(|e| RuleEngineError::InvalidRule(e.to_string()))
78}
79
80fn parse_context(context_json: &str) -> RuleEngineResult<Value> {
81    serde_json::from_str(context_json).map_err(|e| RuleEngineError::InvalidContext(e.to_string()))
82}
83
84fn apply_rule(rule: &Value, context: &Value) -> RuleEngineResult<Value> {
85    jsonlogic::apply(rule, context).map_err(|e| RuleEngineError::Evaluation(e.to_string()))
86}
87
88fn evaluate_context(rule: &Value, context_json: &str) -> EvaluationResult {
89    let context = match parse_context(context_json) {
90        Ok(context) => context,
91        Err(error) => return EvaluationResult::err(error.to_string()),
92    };
93
94    match apply_rule(rule, &context) {
95        Ok(result) => EvaluationResult::ok(result),
96        Err(error) => EvaluationResult::err(error.to_string()),
97    }
98}
99
100fn default_validation_context() -> Value {
101    json!({
102        "amount": 100.0,
103        "country": "MX",
104        "method": "CREDIT_CARD",
105        "active": true,
106        "count": 3,
107        "score": 720,
108        "tags": ["vip", "beta"],
109        "user": {
110            "tier": "gold",
111            "region": "north"
112        },
113        "metrics": {
114            "total_volume": 1000.0,
115            "chargebacks": 1
116        },
117        "null_value": null
118    })
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122fn map_contexts<T, F>(contexts_json: &[String], evaluator: F) -> Vec<T>
123where
124    T: Send,
125    F: Fn(&str) -> T + Sync + Send,
126{
127    contexts_json
128        .par_iter()
129        .map(|context_json| evaluator(context_json))
130        .collect()
131}
132
133#[cfg(target_arch = "wasm32")]
134fn map_contexts<T, F>(contexts_json: &[String], evaluator: F) -> Vec<T>
135where
136    F: Fn(&str) -> T,
137{
138    contexts_json
139        .iter()
140        .map(|context_json| evaluator(context_json))
141        .collect()
142}
143
144#[cfg(not(target_arch = "wasm32"))]
145fn available_threads() -> usize {
146    rayon::current_num_threads()
147}
148
149#[cfg(target_arch = "wasm32")]
150fn available_threads() -> usize {
151    1
152}
153
154/// Evaluate a JSON-Logic rule against a single JSON context.
155pub fn evaluate(rule_json: &str, context_json: &str) -> RuleEngineResult<Value> {
156    let rule = parse_rule(rule_json)?;
157    let context = parse_context(context_json)?;
158    apply_rule(&rule, &context)
159}
160
161/// Alias for [`evaluate`].
162pub fn evaluate_rule(rule_json: &str, context_json: &str) -> RuleEngineResult<Value> {
163    evaluate(rule_json, context_json)
164}
165
166/// Evaluate a rule and coerce the result to `f64`.
167pub fn evaluate_numeric(rule_json: &str, context_json: &str) -> RuleEngineResult<f64> {
168    extract_f64(evaluate(rule_json, context_json)?)
169}
170
171/// Evaluate a rule against many contexts (parallel on native, sequential on WASM).
172pub fn evaluate_batch(rule_json: &str, contexts_json: &[String]) -> RuleEngineResult<Vec<Value>> {
173    let rule = parse_rule(rule_json)?;
174    Ok(map_contexts(contexts_json, |context_json| {
175        evaluate_context(&rule, context_json)
176            .result
177            .unwrap_or(Value::Null)
178    }))
179}
180
181/// Like [`evaluate_batch`] but returns [`EvaluationResult`] with error details.
182pub fn evaluate_batch_detailed(
183    rule_json: &str,
184    contexts_json: &[String],
185) -> RuleEngineResult<Vec<EvaluationResult>> {
186    let rule = parse_rule(rule_json)?;
187    Ok(map_contexts(contexts_json, |context_json| {
188        evaluate_context(&rule, context_json)
189    }))
190}
191
192/// Batch-evaluate and coerce every result to `f64`.
193pub fn evaluate_batch_numeric(
194    rule_json: &str,
195    contexts_json: &[String],
196) -> RuleEngineResult<Vec<f64>> {
197    let results = evaluate_batch_detailed(rule_json, contexts_json)?;
198    Ok(results
199        .into_iter()
200        .map(|item| match item.result {
201            Some(result) => extract_f64(result).unwrap_or(0.0),
202            None => 0.0,
203        })
204        .collect())
205}
206
207/// Like [`evaluate_batch_numeric`] but returns [`NumericEvaluationResult`] with errors.
208pub fn evaluate_batch_numeric_detailed(
209    rule_json: &str,
210    contexts_json: &[String],
211) -> RuleEngineResult<Vec<NumericEvaluationResult>> {
212    let results = evaluate_batch_detailed(rule_json, contexts_json)?;
213    Ok(results
214        .into_iter()
215        .map(|item| match (item.result, item.error) {
216            (Some(result), None) => match extract_f64(result) {
217                Ok(value) => NumericEvaluationResult {
218                    result: value,
219                    error: None,
220                },
221                Err(error) => NumericEvaluationResult {
222                    result: 0.0,
223                    error: Some(error.to_string()),
224                },
225            },
226            (_, Some(error)) => NumericEvaluationResult {
227                result: 0.0,
228                error: Some(error),
229            },
230            (None, None) => NumericEvaluationResult {
231                result: 0.0,
232                error: Some("Unknown evaluation failure".to_string()),
233            },
234        })
235        .collect())
236}
237
238/// Validate that a rule can be evaluated without errors.
239pub fn validate_rule(rule_json: &str) -> RuleEngineResult<bool> {
240    let rule = parse_rule(rule_json)?;
241    let context = default_validation_context();
242
243    apply_rule(&rule, &context)
244        .map(|_| true)
245        .map_err(|error| RuleEngineError::Evaluation(format!("Rule validation failed: {error}")))
246}
247
248/// Serialize a [`Value`] to a JSON string.
249pub fn serialize_value(value: &Value) -> RuleEngineResult<String> {
250    serde_json::to_string(value).map_err(|e| RuleEngineError::Serialization(e.to_string()))
251}
252
253/// Serialize any `Serialize` implementor to a JSON string.
254pub fn serialize<T: Serialize>(value: &T) -> RuleEngineResult<String> {
255    serde_json::to_string(value).map_err(|e| RuleEngineError::Serialization(e.to_string()))
256}
257
258/// Return engine metadata (version, parallelism mode, thread count).
259pub fn get_core_info() -> Value {
260    json!({
261        "engine": "jsonlogic-fast",
262        "version": env!("CARGO_PKG_VERSION"),
263        "parallelism": if cfg!(target_arch = "wasm32") { "sequential" } else { "rayon" },
264        "available_threads": available_threads(),
265        "evaluator": "jsonlogic-rs",
266        "result_model": "serde_json::Value"
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use serde_json::json;
273
274    use super::*;
275
276    #[test]
277    fn evaluate_preserves_string_results() {
278        let rule = r#"{"if":[{"==":[{"var":"country"},"MX"]},"domestic","intl"]}"#;
279        let context = r#"{"country":"MX"}"#;
280
281        assert_eq!(evaluate(rule, context).unwrap(), json!("domestic"));
282    }
283
284    #[test]
285    fn evaluate_numeric_coerces_boolean_results() {
286        let rule = r#"{"==":[{"var":"country"},"MX"]}"#;
287        let context = r#"{"country":"MX"}"#;
288
289        assert_eq!(evaluate_numeric(rule, context).unwrap(), 1.0);
290    }
291
292    #[test]
293    fn evaluate_batch_returns_null_for_invalid_contexts() {
294        let rule = r#"{"var":"amount"}"#;
295        let contexts = vec![r#"{"amount":10}"#.to_string(), "{bad json}".to_string()];
296
297        assert_eq!(
298            evaluate_batch(rule, &contexts).unwrap(),
299            vec![json!(10), Value::Null]
300        );
301    }
302
303    #[test]
304    fn evaluate_batch_detailed_reports_errors() {
305        let rule = r#"{"var":"amount"}"#;
306        let contexts = vec!["{}".to_string(), "{bad json}".to_string()];
307        let results = evaluate_batch_detailed(rule, &contexts).unwrap();
308
309        assert_eq!(results[0].result, Some(Value::Null));
310        assert!(results[1]
311            .error
312            .as_ref()
313            .is_some_and(|message| message.contains("Error parsing context")));
314    }
315
316    #[test]
317    fn evaluate_batch_numeric_keeps_fail_safe_zeroes() {
318        let rule = r#"{"var":"amount"}"#;
319        let contexts = vec!["{}".to_string(), "{bad json}".to_string()];
320
321        assert_eq!(
322            evaluate_batch_numeric(rule, &contexts).unwrap(),
323            vec![0.0, 0.0]
324        );
325    }
326
327    #[test]
328    fn validate_rule_accepts_generic_contexts() {
329        let rule = r#"{"cat":[{"var":"user.tier"},"-",{"var":"country"}]}"#;
330        assert!(validate_rule(rule).unwrap());
331    }
332
333    // -----------------------------------------------------------------------
334    // Nested var paths
335    // -----------------------------------------------------------------------
336
337    #[test]
338    fn var_dot_notation_resolves_nested_objects() {
339        let rule = r#"{"var":"user.address.city"}"#;
340        let context = r#"{"user":{"address":{"city":"Monterrey"}}}"#;
341        assert_eq!(evaluate(rule, context).unwrap(), json!("Monterrey"));
342    }
343
344    #[test]
345    fn var_with_default_value_on_missing_path() {
346        let rule = r#"{"var":["user.phone","N/A"]}"#;
347        let context = r#"{"user":{"name":"Ana"}}"#;
348        assert_eq!(evaluate(rule, context).unwrap(), json!("N/A"));
349    }
350
351    #[test]
352    fn var_array_index_access() {
353        let rule = r#"{"var":"items.1"}"#;
354        let context = r#"{"items":["a","b","c"]}"#;
355        assert_eq!(evaluate(rule, context).unwrap(), json!("b"));
356    }
357
358    // -----------------------------------------------------------------------
359    // Recursive / nested if
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn nested_if_evaluates_correctly() {
364        let rule = r#"{"if":[true,{"if":[false,1,2]},3]}"#;
365        let context = r#"{}"#;
366        assert_eq!(evaluate(rule, context).unwrap(), json!(2));
367    }
368
369    #[test]
370    fn deeply_nested_conditionals() {
371        let rule =
372            r#"{"if":[{">":[{"var":"x"},10]},{"if":[{">":[{"var":"x"},20]},"high","mid"]},"low"]}"#;
373        let context = r#"{"x":25}"#;
374        assert_eq!(evaluate(rule, context).unwrap(), json!("high"));
375    }
376
377    // -----------------------------------------------------------------------
378    // Data types: null, boolean, number, string, array, object
379    // -----------------------------------------------------------------------
380
381    #[test]
382    fn evaluate_null_literal() {
383        let rule = r#"{"var":"missing_key"}"#;
384        let context = r#"{"other":1}"#;
385        assert_eq!(evaluate(rule, context).unwrap(), Value::Null);
386    }
387
388    #[test]
389    fn evaluate_boolean_literal() {
390        let rule = r#"{"==":[true,true]}"#;
391        assert_eq!(evaluate(rule, r#"{}"#).unwrap(), json!(true));
392    }
393
394    #[test]
395    fn evaluate_number_result() {
396        let rule = r#"{"+":[1,2,3]}"#;
397        assert_eq!(evaluate(rule, r#"{}"#).unwrap(), json!(6.0));
398    }
399
400    #[test]
401    fn evaluate_string_cat() {
402        let rule = r#"{"cat":["hello"," ","world"]}"#;
403        assert_eq!(evaluate(rule, r#"{}"#).unwrap(), json!("hello world"));
404    }
405
406    // -----------------------------------------------------------------------
407    // Arithmetic operators including edge cases
408    // -----------------------------------------------------------------------
409
410    #[test]
411    fn arithmetic_addition_and_subtraction() {
412        assert_eq!(evaluate(r#"{"+":[10,5]}"#, "{}").unwrap(), json!(15.0));
413        assert_eq!(evaluate(r#"{"-":[10,5]}"#, "{}").unwrap(), json!(5.0));
414    }
415
416    #[test]
417    fn arithmetic_multiply_and_divide() {
418        assert_eq!(evaluate(r#"{"*":[4,3]}"#, "{}").unwrap(), json!(12.0));
419        assert_eq!(evaluate(r#"{"/":[10,4]}"#, "{}").unwrap(), json!(2.5));
420    }
421
422    #[test]
423    fn modulo_operator() {
424        assert_eq!(evaluate(r#"{"%":[10,3]}"#, "{}").unwrap(), json!(1.0));
425    }
426
427    // -----------------------------------------------------------------------
428    // Comparison operators
429    // -----------------------------------------------------------------------
430
431    #[test]
432    fn comparison_operators() {
433        assert_eq!(evaluate(r#"{">":[5,3]}"#, "{}").unwrap(), json!(true));
434        assert_eq!(evaluate(r#"{"<":[5,3]}"#, "{}").unwrap(), json!(false));
435        assert_eq!(evaluate(r#"{">=":[5,5]}"#, "{}").unwrap(), json!(true));
436        assert_eq!(evaluate(r#"{"<=":[4,5]}"#, "{}").unwrap(), json!(true));
437        assert_eq!(evaluate(r#"{"==":[5,5]}"#, "{}").unwrap(), json!(true));
438        assert_eq!(evaluate(r#"{"!=":[5,3]}"#, "{}").unwrap(), json!(true));
439    }
440
441    // -----------------------------------------------------------------------
442    // Logical operators with short-circuit
443    // -----------------------------------------------------------------------
444
445    #[test]
446    fn logical_and_short_circuits() {
447        let rule = r#"{"and":[false,{"var":"missing"}]}"#;
448        assert_eq!(evaluate(rule, r#"{}"#).unwrap(), json!(false));
449    }
450
451    #[test]
452    fn logical_or_short_circuits() {
453        let rule = r#"{"or":[true,{"var":"missing"}]}"#;
454        assert_eq!(evaluate(rule, r#"{}"#).unwrap(), json!(true));
455    }
456
457    #[test]
458    fn logical_not() {
459        assert_eq!(evaluate(r#"{"!":[true]}"#, "{}").unwrap(), json!(false));
460        assert_eq!(evaluate(r#"{"!":[false]}"#, "{}").unwrap(), json!(true));
461    }
462
463    #[test]
464    fn double_negation() {
465        assert_eq!(evaluate(r#"{"!!":[1]}"#, "{}").unwrap(), json!(true));
466        assert_eq!(evaluate(r#"{"!!":[0]}"#, "{}").unwrap(), json!(false));
467    }
468
469    // -----------------------------------------------------------------------
470    // Array operators: map, filter, reduce, in, merge, missing, missing_some
471    // -----------------------------------------------------------------------
472
473    #[test]
474    fn map_operator() {
475        let rule = r#"{"map":[{"var":"items"},{"*":[{"var":""},2]}]}"#;
476        let context = r#"{"items":[1,2,3]}"#;
477        assert_eq!(evaluate(rule, context).unwrap(), json!([2.0, 4.0, 6.0]));
478    }
479
480    #[test]
481    fn filter_operator() {
482        let rule = r#"{"filter":[{"var":"items"},{">":[{"var":""},2]}]}"#;
483        let context = r#"{"items":[1,2,3,4,5]}"#;
484        assert_eq!(evaluate(rule, context).unwrap(), json!([3, 4, 5]));
485    }
486
487    #[test]
488    fn reduce_operator() {
489        let rule =
490            r#"{"reduce":[{"var":"items"},{"+":[{"var":"current"},{"var":"accumulator"}]},0]}"#;
491        let context = r#"{"items":[1,2,3,4]}"#;
492        let result =
493            evaluate(rule, context).unwrap_or_else(|e| panic!("reduce evaluation failed: {e}"));
494        let n = result
495            .as_f64()
496            .unwrap_or_else(|| panic!("expected numeric result, got: {result}"));
497        assert!((n - 10.0).abs() < 1e-6, "expected 10.0, got {n}");
498    }
499
500    #[test]
501    fn in_operator_string() {
502        assert_eq!(
503            evaluate(r#"{"in":["Spring","Springfield"]}"#, "{}").unwrap(),
504            json!(true)
505        );
506    }
507
508    #[test]
509    fn in_operator_array() {
510        assert_eq!(
511            evaluate(r#"{"in":["banana",["apple","banana","cherry"]]}"#, "{}").unwrap(),
512            json!(true)
513        );
514    }
515
516    #[test]
517    fn merge_operator() {
518        assert_eq!(
519            evaluate(r#"{"merge":[[1,2],[3,4]]}"#, "{}").unwrap(),
520            json!([1, 2, 3, 4])
521        );
522    }
523
524    #[test]
525    fn missing_operator() {
526        let rule = r#"{"missing":["a","b","c"]}"#;
527        let context = r#"{"a":1,"c":3}"#;
528        assert_eq!(evaluate(rule, context).unwrap(), json!(["b"]));
529    }
530
531    #[test]
532    fn missing_some_operator() {
533        let rule = r#"{"missing_some":[1,["a","b","c"]]}"#;
534        let context = r#"{"a":1}"#;
535        assert_eq!(evaluate(rule, context).unwrap(), json!([]));
536    }
537
538    // -----------------------------------------------------------------------
539    // Between (ternary <, <=)
540    // -----------------------------------------------------------------------
541
542    #[test]
543    fn between_operator() {
544        assert_eq!(
545            evaluate(r#"{"<":[1,{"var":"x"},10]}"#, r#"{"x":5}"#).unwrap(),
546            json!(true)
547        );
548        assert_eq!(
549            evaluate(r#"{"<=":[1,{"var":"x"},10]}"#, r#"{"x":10}"#).unwrap(),
550            json!(true)
551        );
552    }
553
554    // -----------------------------------------------------------------------
555    // Large / complex rules
556    // -----------------------------------------------------------------------
557
558    #[test]
559    fn complex_rule_with_many_operators() {
560        let rule = r#"{
561            "if":[
562                {"and":[
563                    {">":[{"var":"score"},700]},
564                    {"==":[{"var":"country"},"MX"]},
565                    {"in":[{"var":"tier"},["gold","platinum"]]}
566                ]},
567                {"*":[{"var":"amount"},0.025]},
568                {"*":[{"var":"amount"},0.035]}
569            ]
570        }"#;
571        let context = r#"{"score":750,"country":"MX","tier":"gold","amount":1000}"#;
572        assert_eq!(evaluate_numeric(rule, context).unwrap(), 25.0);
573    }
574}