Skip to main content

fsm_guards/
lib.rs

1//! Fuel-metered JSONLogic guard evaluation for FSM transition engines.
2//!
3//! `fsm-guards` evaluates [JSONLogic](https://jsonlogic.com) rules as boolean
4//! predicates with three production features missing from the upstream
5//! `jsonlogic-rs` crate:
6//!
7//! - **In-loop fuel metering** — a node-visit budget threaded through the
8//!   recursive evaluator. Short-circuit operators (`or`, `and`, `if`) charge
9//!   only for branches they actually visit. A rule like
10//!   `{"or": [true, <200-node-branch>]}` with `fuel: 10` succeeds in 2 node
11//!   visits, not 200.
12//! - **Typed errors** — [`GuardError`] carries five distinct variants so
13//!   callers can distinguish missing variables from type errors from budget
14//!   exhaustion.
15//! - **Per-evaluation custom operators** — register extension functions on
16//!   [`EvalCtx`] for each call. No global registry; no cross-tenant state.
17//!
18//! # Quick start
19//!
20//! ```rust
21//! use fsm_guards::{evaluate, EvalCtx};
22//! use serde_json::json;
23//!
24//! let ctx = EvalCtx::new();
25//! let rule = json!({"and": [
26//!     {"==": [{"var": "status"}, "active"]},
27//!     {">=": [{"var": "score"}, 80]}
28//! ]});
29//! let data = json!({"status": "active", "score": 95});
30//!
31//! assert!(evaluate(&rule, &data, &ctx).unwrap());
32//! ```
33//!
34//! # Two-tier API
35//!
36//! ## High-level: [`evaluate`]
37//!
38//! ```rust
39//! use fsm_guards::{evaluate, EvalCtx, GuardError};
40//! use serde_json::json;
41//!
42//! let ctx = EvalCtx::new().with_fuel(50);
43//!
44//! // MissingVar: pre-walk detects the absent key before evaluation starts.
45//! let rule = json!({"var": "ghost"});
46//! let data = json!({"status": "active"});
47//! assert!(matches!(evaluate(&rule, &data, &ctx), Err(GuardError::MissingVar(k)) if k == "ghost"));
48//! ```
49//!
50//! ## Low-level: [`apply`]
51//!
52//! Returns the raw JSONLogic result value — useful when the result is not a
53//! boolean (e.g. extracting a field value or building a derived value).
54//!
55//! ```rust
56//! use fsm_guards::apply;
57//! use serde_json::json;
58//!
59//! let result = apply(&json!({"cat": ["hello", " ", "world"]}), &json!({})).unwrap();
60//! assert_eq!(result, json!("hello world"));
61//! ```
62//!
63//! # Custom operators
64//!
65//! Register domain-specific operators on [`EvalCtx`]. They receive
66//! already-evaluated argument values (not raw AST nodes) and compose naturally
67//! inside compound expressions.
68//!
69//! ```rust
70//! use fsm_guards::{evaluate, EvalCtx};
71//! use serde_json::{json, Value};
72//!
73//! let ctx = EvalCtx::new()
74//!     .with_op("state_in", |args| {
75//!         // args[0] = current state value, args[1..] = allowed states
76//!         Ok(Value::Bool(args[1..].contains(&args[0])))
77//!     });
78//!
79//! let rule = json!({"state_in": [{"var": "status"}, "active", "pending"]});
80//! let data = json!({"status": "active"});
81//! assert!(evaluate(&rule, &data, &ctx).unwrap());
82//!
83//! let data_blocked = json!({"status": "blocked"});
84//! assert!(!evaluate(&rule, &data_blocked, &ctx).unwrap());
85//! ```
86//!
87//! # Fuel accounting
88//!
89//! Fuel is decremented for every node visit — operators *and* literal values.
90//! Short-circuit operators charge only for visited branches.
91//!
92//! ```rust
93//! use fsm_guards::{evaluate, EvalCtx, GuardError};
94//! use serde_json::json;
95//!
96//! // {"or": [true, <deep-branch>]} with fuel:5 — short-circuits on `true`,
97//! // deep branch never visited, only ~2 nodes charged.
98//! let mut deep = json!({"==": [1, 2]});
99//! for _ in 0..50 {
100//!     deep = json!({"and": [deep, {"==": [1, 2]}]});
101//! }
102//! let rule = json!({"or": [true, deep]});
103//! let ctx = EvalCtx::new().with_fuel(5);
104//! assert_eq!(evaluate(&rule, &json!({}), &ctx).unwrap(), true);
105//!
106//! // A rule that exceeds the budget:
107//! let deep_rule = json!({"and": [{"==": [1,1]}, {"and": [{"==": [1,1]}, {"==": [1,1]}]}]});
108//! let tight_ctx = EvalCtx::new().with_fuel(4);
109//! assert!(matches!(
110//!     evaluate(&deep_rule, &json!({}), &tight_ctx),
111//!     Err(GuardError::FuelExceeded { limit: 4 })
112//! ));
113//! ```
114//!
115//! # Thread safety
116//!
117//! [`EvalCtx`] is `Send + Sync`. Wrap in `Arc` to share across threads; each
118//! `evaluate()` call uses its own stack-local fuel counter.
119//!
120//! ```rust
121//! use fsm_guards::{evaluate, EvalCtx};
122//! use serde_json::json;
123//! use std::sync::Arc;
124//!
125//! let ctx = Arc::new(EvalCtx::new());
126//! let ctx2 = Arc::clone(&ctx);
127//! let rule = json!({"==": [1, 1]});
128//! let data = json!({});
129//!
130//! let handle = std::thread::spawn(move || evaluate(&rule, &data, &ctx2).unwrap());
131//! assert!(handle.join().unwrap());
132//! ```
133
134pub mod ctx;
135pub mod engine;
136pub mod error;
137pub mod guard;
138
139pub use ctx::{CustomOp, EvalCtx};
140pub use engine::apply;
141pub use error::{ApplyError, GuardError};
142pub use guard::evaluate;