fsm-guards 0.1.1

Fuel-metered JSONLogic guard evaluation with per-call custom operators — forked from jsonlogic-rs
Documentation
use crate::ctx::EvalCtx;
use crate::engine::eval::{apply_value, truthy};
use crate::error::{ApplyError, GuardError};
use serde_json::Value;

/// Evaluate a JSONLogic rule as a boolean FSM guard.
///
/// 1. Pre-walk: detect `{"var": "X"}` references whose root key is absent from
///    `data`. Returns `GuardError::MissingVar` before the evaluator runs.
/// 2. Run `engine::eval::apply_value` with `ctx.fuel` as the node budget.
/// 3. Coerce the result `Value` to `bool` using JSONLogic truthy semantics.
///
/// # Examples
///
/// Basic predicate:
/// ```rust
/// use fsm_guards::{evaluate, EvalCtx};
/// use serde_json::json;
///
/// let ctx = EvalCtx::new();
/// assert!(evaluate(&json!({">=": [{"var": "age"}, 18]}), &json!({"age": 21}), &ctx).unwrap());
/// ```
///
/// Missing variable (pre-walk, before evaluator runs):
/// ```rust
/// use fsm_guards::{evaluate, EvalCtx, GuardError};
/// use serde_json::json;
///
/// let ctx = EvalCtx::new();
/// let err = evaluate(&json!({"var": "missing_key"}), &json!({"other": 1}), &ctx).unwrap_err();
/// assert!(matches!(err, GuardError::MissingVar(k) if k == "missing_key"));
/// ```
///
/// Fuel exceeded:
/// ```rust
/// use fsm_guards::{evaluate, EvalCtx, GuardError};
/// use serde_json::json;
///
/// let ctx = EvalCtx::new().with_fuel(1);
/// // {"==": [1, 1]} visits 3 nodes (==, 1, 1) — exceeds fuel:1.
/// let err = evaluate(&json!({"==": [1, 1]}), &json!({}), &ctx).unwrap_err();
/// assert!(matches!(err, GuardError::FuelExceeded { limit: 1 }));
/// ```
pub fn evaluate(rule: &Value, data: &Value, ctx: &EvalCtx) -> Result<bool, GuardError> {
    if let Some(missing) = first_missing_var(rule, data) {
        return Err(GuardError::MissingVar(missing));
    }

    let mut fuel = ctx.fuel;
    match apply_value(rule, data, ctx, &mut fuel) {
        Ok(v) => Ok(truthy(&v)),
        Err(ApplyError::FuelExceeded) => Err(GuardError::FuelExceeded { limit: ctx.fuel }),
        Err(ApplyError::CustomOpFailed { op, source }) => {
            Err(GuardError::CustomOpFailed { op, source })
        }
        Err(ApplyError::UnknownOperator(op)) => {
            Err(GuardError::Malformed(format!("unknown operator: {op}")))
        }
        Err(ApplyError::InvalidArguments(msg)) => {
            let lower = msg.to_lowercase();
            let is_type_error = lower.contains("type")
                || lower.contains("expected")
                || lower.contains("could not")
                || lower.contains("must be")
                || lower.contains("got ");
            if is_type_error {
                Err(GuardError::TypeError(msg))
            } else {
                Err(GuardError::Malformed(msg))
            }
        }
    }
}

/// Walk a JSONLogic rule; return the first `{"var": "X"}` whose ROOT key
/// (leftmost dot-segment) is absent from the top-level data object.
fn first_missing_var(rule: &Value, data: &Value) -> Option<String> {
    let data_obj = data.as_object()?;
    collect_var_refs(rule).into_iter().find(|path| {
        let root = path.split('.').next().unwrap_or("");
        !root.is_empty() && !data_obj.contains_key(root)
    })
}

fn collect_var_refs(rule: &Value) -> Vec<String> {
    let mut refs = Vec::new();
    walk_for_vars(rule, &mut refs);
    refs
}

fn walk_for_vars(v: &Value, refs: &mut Vec<String>) {
    match v {
        Value::Object(map) => {
            if let Some(var_arg) = map.get("var") {
                match var_arg {
                    Value::String(s) if !s.is_empty() => refs.push(s.clone()),
                    Value::Array(arr) => {
                        // Only flag as potentially missing when there is no default (single-element
                        // array). A two-element array `["key", default]` always resolves to the
                        // default when the key is absent, so it can never be "missing".
                        if arr.len() == 1 {
                            if let Some(Value::String(s)) = arr.first() {
                                if !s.is_empty() {
                                    refs.push(s.clone());
                                }
                            }
                        }
                    }
                    _ => {}
                }
                return;
            }
            for child in map.values() {
                walk_for_vars(child, refs);
            }
        }
        Value::Array(arr) => {
            for child in arr {
                walk_for_vars(child, refs);
            }
        }
        _ => {}
    }
}