fsm-guards 0.1.1

Fuel-metered JSONLogic guard evaluation with per-call custom operators — forked from jsonlogic-rs
Documentation
use fsm_guards::{evaluate, EvalCtx, GuardError};
use serde_json::json;
use std::sync::Arc;

// ── basic outcomes ────────────────────────────────────────────────────────────

#[test]
fn ok_true() {
    let ctx = EvalCtx::new();
    assert_eq!(evaluate(&json!({"==": [1, 1]}), &json!({}), &ctx).unwrap(), true);
}

#[test]
fn ok_false() {
    let ctx = EvalCtx::new();
    assert_eq!(evaluate(&json!({"==": [1, 2]}), &json!({}), &ctx).unwrap(), false);
}

// ── MissingVar ────────────────────────────────────────────────────────────────

#[test]
fn missing_var_root() {
    let ctx = EvalCtx::new();
    let rule = json!({"var": "status"});
    let data = json!({"other": 1});
    assert!(matches!(
        evaluate(&rule, &data, &ctx),
        Err(GuardError::MissingVar(s)) if s == "status"
    ));
}

#[test]
fn missing_var_nested_key_is_ok() {
    // Only the ROOT key triggers MissingVar. A present root with absent nested key
    // resolves to null per JSONLogic semantics — NOT a MissingVar error.
    let ctx = EvalCtx::new();
    let rule = json!({"==": [{"var": "obj.nested"}, null]});
    let data = json!({"obj": {}});
    assert_eq!(evaluate(&rule, &data, &ctx).unwrap(), true);
}

#[test]
fn missing_var_in_compound_rule() {
    let ctx = EvalCtx::new();
    let rule = json!({"and": [{"==": [1, 1]}, {"var": "ghost"}]});
    let data = json!({});
    assert!(matches!(
        evaluate(&rule, &data, &ctx),
        Err(GuardError::MissingVar(s)) if s == "ghost"
    ));
}

// ── FuelExceeded ──────────────────────────────────────────────────────────────

#[test]
fn fuel_exceeded() {
    let ctx = EvalCtx::new().with_fuel(3);
    // Nested rule that requires more than 3 node visits.
    let rule = json!({"and": [{"and": [{"and": [{"==": [1, 1]}, true]}, true]}, true]});
    let data = json!({});
    assert!(matches!(
        evaluate(&rule, &data, &ctx),
        Err(GuardError::FuelExceeded { limit: 3 })
    ));
}

// ── custom ops ────────────────────────────────────────────────────────────────

#[test]
fn custom_op_ok_composes_in_and() {
    let ctx = EvalCtx::new()
        .with_op("always_true", |_args| Ok(serde_json::Value::Bool(true)));
    let rule = json!({"and": [{"always_true": []}, {"==": [1, 1]}]});
    let data = json!({});
    assert_eq!(evaluate(&rule, &data, &ctx).unwrap(), true);
}

#[test]
fn custom_op_receives_evaluated_args() {
    // {"var": "x"} must resolve to 7 before being passed to the handler.
    let ctx = EvalCtx::new().with_op("sum_gt", |args| {
        let a = args[0].as_f64().unwrap_or(0.0);
        let b = args[1].as_f64().unwrap_or(0.0);
        let threshold = args[2].as_f64().unwrap_or(0.0);
        Ok(serde_json::Value::Bool(a + b > threshold))
    });
    let rule = json!({"sum_gt": [{"var": "x"}, {"var": "y"}, 10]});

    let data_true = json!({"x": 7, "y": 6});
    assert_eq!(evaluate(&rule, &data_true, &ctx).unwrap(), true);

    let data_false = json!({"x": 3, "y": 4});
    assert_eq!(evaluate(&rule, &data_false, &ctx).unwrap(), false);
}

#[test]
fn custom_op_error_surfaces_as_custom_op_failed() {
    let ctx = EvalCtx::new().with_op("explode", |_args| {
        Err(Box::from("intentional failure") as Box<dyn std::error::Error + Send + Sync>)
    });
    let rule = json!({"explode": []});
    let data = json!({});
    assert!(matches!(
        evaluate(&rule, &data, &ctx),
        Err(GuardError::CustomOpFailed { op, .. }) if op == "explode"
    ));
}

#[test]
fn unknown_op_surfaces_as_malformed() {
    let ctx = EvalCtx::new(); // no custom ops registered
    let rule = json!({"state_in": ["A", "B"]});
    let data = json!({});
    assert!(matches!(
        evaluate(&rule, &data, &ctx),
        Err(GuardError::Malformed(_))
    ));
}

// ── default fuel ──────────────────────────────────────────────────────────────

#[test]
fn default_fuel_not_exceeded_on_realistic_guard() {
    let ctx = EvalCtx::new(); // default: fuel = 200
    let rule = json!({
        "and": [
            {"==": [{"var": "status"}, "active"]},
            {">=": [{"var": "score"}, 80]},
            {"in": [{"var": "role"}, ["admin", "operator"]]}
        ]
    });
    let data = json!({"status": "active", "score": 95, "role": "admin"});
    assert_eq!(evaluate(&rule, &data, &ctx).unwrap(), true);
}

// ── thread safety ─────────────────────────────────────────────────────────────

#[test]
fn evalctx_shared_via_arc_across_threads() {
    let ctx = Arc::new(
        EvalCtx::new().with_op("double", |args| {
            let n = args[0].as_f64().unwrap_or(0.0);
            Ok(serde_json::json!(n * 2.0))
        }),
    );
    let ctx2 = Arc::clone(&ctx);
    let rule = json!({"double": [5]});
    let data = json!({});
    // 5 * 2 = 10, which is truthy
    let handle = std::thread::spawn(move || evaluate(&rule, &data, &ctx2).unwrap());
    assert!(handle.join().unwrap());
}

#[test]
fn non_object_data_skips_prewalk_returns_false() {
    // Non-object data: pre-walk skips (no root keys to check), eval resolves {"var":"x"} to null (falsy).
    let ctx = EvalCtx::new();
    let rule = json!({"var": "x"});
    assert_eq!(evaluate(&rule, &serde_json::Value::Null, &ctx).unwrap(), false);
}

#[test]
fn var_array_form_default_not_treated_as_missing() {
    // {"var": ["path", "default_value"]} — the two-element array form with a default.
    // When the key is missing, JSONLogic returns the default, not MissingVar.
    let ctx = EvalCtx::new();
    let rule = json!({"var": ["ghost", "fallback"]});
    let data = json!({});
    // pre-walk should not fire MissingVar for array-form vars
    // eval returns the default string "fallback" which is truthy
    assert_eq!(evaluate(&rule, &data, &ctx).unwrap(), true);
}