Expand description
Fuel-metered JSONLogic guard evaluation for FSM transition engines.
fsm-guards evaluates JSONLogic rules as boolean
predicates with three production features missing from the upstream
jsonlogic-rs crate:
- In-loop fuel metering — a node-visit budget threaded through the
recursive evaluator. Short-circuit operators (
or,and,if) charge only for branches they actually visit. A rule like{"or": [true, <200-node-branch>]}withfuel: 10succeeds in 2 node visits, not 200. - Typed errors —
GuardErrorcarries five distinct variants so callers can distinguish missing variables from type errors from budget exhaustion. - Per-evaluation custom operators — register extension functions on
EvalCtxfor each call. No global registry; no cross-tenant state.
§Quick start
use fsm_guards::{evaluate, EvalCtx};
use serde_json::json;
let ctx = EvalCtx::new();
let rule = json!({"and": [
{"==": [{"var": "status"}, "active"]},
{">=": [{"var": "score"}, 80]}
]});
let data = json!({"status": "active", "score": 95});
assert!(evaluate(&rule, &data, &ctx).unwrap());§Two-tier API
§High-level: evaluate
use fsm_guards::{evaluate, EvalCtx, GuardError};
use serde_json::json;
let ctx = EvalCtx::new().with_fuel(50);
// MissingVar: pre-walk detects the absent key before evaluation starts.
let rule = json!({"var": "ghost"});
let data = json!({"status": "active"});
assert!(matches!(evaluate(&rule, &data, &ctx), Err(GuardError::MissingVar(k)) if k == "ghost"));§Low-level: apply
Returns the raw JSONLogic result value — useful when the result is not a boolean (e.g. extracting a field value or building a derived value).
use fsm_guards::apply;
use serde_json::json;
let result = apply(&json!({"cat": ["hello", " ", "world"]}), &json!({})).unwrap();
assert_eq!(result, json!("hello world"));§Custom operators
Register domain-specific operators on EvalCtx. They receive
already-evaluated argument values (not raw AST nodes) and compose naturally
inside compound expressions.
use fsm_guards::{evaluate, EvalCtx};
use serde_json::{json, Value};
let ctx = EvalCtx::new()
.with_op("state_in", |args| {
// args[0] = current state value, args[1..] = allowed states
Ok(Value::Bool(args[1..].contains(&args[0])))
});
let rule = json!({"state_in": [{"var": "status"}, "active", "pending"]});
let data = json!({"status": "active"});
assert!(evaluate(&rule, &data, &ctx).unwrap());
let data_blocked = json!({"status": "blocked"});
assert!(!evaluate(&rule, &data_blocked, &ctx).unwrap());§Fuel accounting
Fuel is decremented for every node visit — operators and literal values. Short-circuit operators charge only for visited branches.
use fsm_guards::{evaluate, EvalCtx, GuardError};
use serde_json::json;
// {"or": [true, <deep-branch>]} with fuel:5 — short-circuits on `true`,
// deep branch never visited, only ~2 nodes charged.
let mut deep = json!({"==": [1, 2]});
for _ in 0..50 {
deep = json!({"and": [deep, {"==": [1, 2]}]});
}
let rule = json!({"or": [true, deep]});
let ctx = EvalCtx::new().with_fuel(5);
assert_eq!(evaluate(&rule, &json!({}), &ctx).unwrap(), true);
// A rule that exceeds the budget:
let deep_rule = json!({"and": [{"==": [1,1]}, {"and": [{"==": [1,1]}, {"==": [1,1]}]}]});
let tight_ctx = EvalCtx::new().with_fuel(4);
assert!(matches!(
evaluate(&deep_rule, &json!({}), &tight_ctx),
Err(GuardError::FuelExceeded { limit: 4 })
));§Thread safety
EvalCtx is Send + Sync. Wrap in Arc to share across threads; each
evaluate() call uses its own stack-local fuel counter.
use fsm_guards::{evaluate, EvalCtx};
use serde_json::json;
use std::sync::Arc;
let ctx = Arc::new(EvalCtx::new());
let ctx2 = Arc::clone(&ctx);
let rule = json!({"==": [1, 1]});
let data = json!({});
let handle = std::thread::spawn(move || evaluate(&rule, &data, &ctx2).unwrap());
assert!(handle.join().unwrap());Re-exports§
pub use ctx::CustomOp;pub use ctx::EvalCtx;pub use engine::apply;pub use error::ApplyError;pub use error::GuardError;pub use guard::evaluate;