klieo-workflow 3.4.0

Declarative no-code workflow substrate for the klieo agent framework.
Documentation
//! Bounded condition expression over the envelope, compiled to a closure.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Comparison operators. Deliberately not Turing-complete.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Op {
    /// Equal.
    Eq,
    /// Not equal.
    Ne,
    /// Greater than (numbers).
    Gt,
    /// Greater than or equal (numbers).
    Gte,
    /// Less than (numbers).
    Lt,
    /// Less than or equal (numbers).
    Lte,
    /// Field is present and non-null.
    Exists,
    /// String field contains substring, or array contains the value.
    Contains,
}

impl Op {
    /// Every operator variant, in declaration order. Drift gates and palette
    /// enumerations iterate this instead of a hand-mirrored array.
    ///
    /// Kept complete by `assert_all_ops_exhaustive`, whose same-crate
    /// exhaustive `match` makes a new `Op` variant a compile error until it is
    /// handled — and the `[Op; N]` length then forces this array to grow to
    /// match. (`#[non_exhaustive]` restricts only downstream crates, so the
    /// in-crate match stays exhaustive.)
    pub const ALL: [Op; 8] = [
        Op::Eq,
        Op::Ne,
        Op::Gt,
        Op::Gte,
        Op::Lt,
        Op::Lte,
        Op::Exists,
        Op::Contains,
    ];
}

/// Compile-time exhaustiveness anchor for [`Op::ALL`]. It is never called — the
/// exhaustive `match` over the `Op` type is what matters: adding a variant
/// makes the match non-exhaustive (a compile error), forcing the variant to be
/// added here and to [`Op::ALL`] in the same change. This is the compile-
/// enforced anchor the drift gate relies on instead of trusting a hand-written
/// array to stay complete.
#[allow(dead_code)]
fn assert_all_ops_exhaustive(op: &Op) {
    match op {
        Op::Eq | Op::Ne | Op::Gt | Op::Gte | Op::Lt | Op::Lte | Op::Exists | Op::Contains => {}
    }
}

/// A single condition read off an envelope field.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Condition {
    /// Envelope field name.
    pub field: String,
    /// Operator.
    pub op: Op,
    /// Right-hand comparison value. Ignored by `exists`.
    #[serde(default)]
    pub value: Value,
}

/// Compile a condition into a predicate over the envelope. The returned
/// closure is `Send + Sync + 'static` so it satisfies
/// [`klieo_flows::GraphFlow::edge_branch`].
pub fn compile_condition(c: &Condition) -> impl Fn(&Value) -> bool + Send + Sync + 'static {
    let cond = c.clone();
    move |env: &Value| {
        let lhs = env.get(&cond.field);
        match cond.op {
            Op::Exists => lhs.is_some_and(|v| !v.is_null()),
            Op::Eq => lhs == Some(&cond.value),
            Op::Ne => lhs != Some(&cond.value),
            Op::Gt | Op::Gte | Op::Lt | Op::Lte => compare_numbers(lhs, &cond.value, &cond.op),
            Op::Contains => contains(lhs, &cond.value),
        }
    }
}

fn compare_numbers(lhs: Option<&Value>, rhs: &Value, op: &Op) -> bool {
    let (Some(a), Some(b)) = (lhs.and_then(Value::as_f64), rhs.as_f64()) else {
        return false;
    };
    match op {
        Op::Gt => a > b,
        Op::Gte => a >= b,
        Op::Lt => a < b,
        Op::Lte => a <= b,
        _ => false,
    }
}

fn contains(lhs: Option<&Value>, needle: &Value) -> bool {
    match lhs {
        Some(Value::String(s)) => needle.as_str().is_some_and(|n| s.contains(n)),
        Some(Value::Array(items)) => items.contains(needle),
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn check(field: &str, op: Op, value: Value, env: Value) -> bool {
        compile_condition(&Condition {
            field: field.into(),
            op,
            value,
        })(&env)
    }

    #[test]
    fn gte_true_and_the_rejected_negatives() {
        assert!(check("risk", Op::Gte, json!(0.8), json!({"risk": 0.8})));
        assert!(check("risk", Op::Gte, json!(0.8), json!({"risk": 0.9})));
        assert!(!check("risk", Op::Gte, json!(0.8), json!({"risk": 0.7})));
        assert!(!check("risk", Op::Gte, json!(0.8), json!({"risk": "high"})));
        assert!(!check("risk", Op::Gte, json!(0.8), json!({})));
    }

    #[test]
    fn exists_ignores_value_and_null() {
        assert!(check("a", Op::Exists, Value::Null, json!({"a": 1})));
        assert!(!check("a", Op::Exists, Value::Null, json!({"a": null})));
        assert!(!check("a", Op::Exists, Value::Null, json!({})));
    }

    #[test]
    fn gt_is_strict_above_boundary() {
        assert!(check("n", Op::Gt, json!(1.0), json!({"n": 1.1})));
        assert!(!check("n", Op::Gt, json!(1.0), json!({"n": 1.0})));
    }

    #[test]
    fn lt_is_strict_below_boundary() {
        assert!(check("n", Op::Lt, json!(1.0), json!({"n": 0.9})));
        assert!(!check("n", Op::Lt, json!(1.0), json!({"n": 1.0})));
    }

    #[test]
    fn lte_includes_boundary_excludes_above() {
        assert!(check("n", Op::Lte, json!(1.0), json!({"n": 1.0})));
        assert!(!check("n", Op::Lte, json!(1.0), json!({"n": 1.1})));
    }

    #[test]
    fn contains_string_and_array() {
        assert!(check(
            "s",
            Op::Contains,
            json!("ell"),
            json!({"s": "hello"})
        ));
        assert!(check(
            "xs",
            Op::Contains,
            json!(2),
            json!({"xs": [1, 2, 3]})
        ));
        assert!(!check(
            "s",
            Op::Contains,
            json!("zzz"),
            json!({"s": "hello"})
        ));
    }

    #[test]
    fn contains_rejects_absent_member_and_wrong_type() {
        assert!(!check(
            "xs",
            Op::Contains,
            json!(4),
            json!({"xs": [1, 2, 3]})
        ));
        assert!(!check("n", Op::Contains, json!(2), json!({"n": 42})));
    }

    #[test]
    fn eq_and_ne() {
        assert!(check("k", Op::Eq, json!("v"), json!({"k": "v"})));
        assert!(check("k", Op::Ne, json!("v"), json!({"k": "x"})));
    }

    #[test]
    fn eq_false_when_unequal() {
        assert!(!check("k", Op::Eq, json!("v"), json!({"k": "x"})));
    }

    #[test]
    fn ne_false_when_equal() {
        assert!(!check("k", Op::Ne, json!("v"), json!({"k": "v"})));
    }
}