# fsm-guards
Fuel-metered JSONLogic guard evaluation for FSM transition engines.
A fork of [jsonlogic-rs](https://crates.io/crates/jsonlogic-rs) v0.5, extended with:
- **In-loop fuel metering** — node-visit budget that short-circuits correctly: `{"or": [true, huge_branch]}` with `fuel: 10` costs 2 nodes, not 1000.
- **Typed error classification** — `GuardError` with five distinct variants replacing stringly-typed errors.
- **Per-evaluation custom operators** — register extension functions on `EvalCtx` for each call; no global state.
## Install
```toml
[dependencies]
fsm-guards = "0.1"
```
## Quick start
```rust
use fsm_guards::{evaluate, EvalCtx};
use serde_json::json;
let ctx = EvalCtx::new()
.with_fuel(50)
.with_op("state_in", |args| {
Ok(serde_json::Value::Bool(args[1..].contains(&args[0])))
});
let rule = json!({
"and": [
{"state_in": [{"var": "status"}, "active", "pending"]},
{">=": [{"var": "score"}, 80]}
]
});
let data = json!({"status": "active", "score": 95});
assert!(evaluate(&rule, &data, &ctx).unwrap());
```
## API
### `evaluate` — guard tier
```rust
pub fn evaluate(rule: &Value, data: &Value, ctx: &EvalCtx) -> Result<bool, GuardError>
```
1. Pre-walks for `{"var": "X"}` references whose root key is absent from `data` → `GuardError::MissingVar`.
2. Runs the evaluator with `ctx.fuel` as the node budget.
3. Coerces the result to `bool` (JSONLogic truthy: null/false/0/""/[] → false).
### `apply` — raw tier
```rust
pub fn apply(rule: &Value, data: &Value) -> Result<Value, ApplyError>
```
No fuel limit. No custom operators. Returns the raw JSONLogic result value.
### `EvalCtx`
```rust
let ctx = EvalCtx::new() // fuel: 200, no custom ops
.with_fuel(50)
.with_op("my_op", |args| Ok(args[0].clone()));
```
`EvalCtx` is `Send + Sync`. Wrap in `Arc` to share across threads; each `evaluate` call uses its own stack-local fuel counter.
### Error variants
| `GuardError::Malformed(msg)` | Structural AST problem or unknown operator |
| `GuardError::MissingVar(path)` | `{"var": "X"}` where root key absent from data |
| `GuardError::TypeError(msg)` | Type coercion or argument-arity error |
| `GuardError::FuelExceeded { limit }` | Node budget exhausted |
| `GuardError::CustomOpFailed { op, source }` | Registered operator returned `Err` |
## Fuel accounting
Fuel is decremented for every node visit — literals, operators, all values. Short-circuit operators charge only for visited branches:
```
{"or": [true, <200-node-branch>]} with fuel:10 → Ok(true) // 2 nodes visited
{"or": [false, {"==": [1,1]}]} with fuel:10 → Ok(true) // 3 nodes visited
```
Default: 200 nodes. FastYoke's production limit: 50 nodes for trigger predicates, 200 for guard rules.
## JSONLogic compatibility
Passes 273/278 of the [JSONLogic test suite](https://jsonlogic.com/tests.json). The 5 failures are inherited jsonlogic-rs v0.5 limitations:
- Array literals containing `{"var": "..."}` — vars inside array literals are not recursively evaluated
- `?:` ternary operator — not implemented
- `missing` with an expression argument — does not pre-evaluate its argument
## License
MIT. Forked from [jsonlogic-rs](https://crates.io/crates/jsonlogic-rs) v0.5 by Bestow Inc.