Skip to main content

klieo_workflow/
condition.rs

1//! Bounded condition expression over the envelope, compiled to a closure.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Comparison operators. Deliberately not Turing-complete.
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "snake_case")]
9#[non_exhaustive]
10pub enum Op {
11    /// Equal.
12    Eq,
13    /// Not equal.
14    Ne,
15    /// Greater than (numbers).
16    Gt,
17    /// Greater than or equal (numbers).
18    Gte,
19    /// Less than (numbers).
20    Lt,
21    /// Less than or equal (numbers).
22    Lte,
23    /// Field is present and non-null.
24    Exists,
25    /// String field contains substring, or array contains the value.
26    Contains,
27}
28
29impl Op {
30    /// Every operator variant, in declaration order. Drift gates and palette
31    /// enumerations iterate this instead of a hand-mirrored array.
32    ///
33    /// Kept complete by `assert_all_ops_exhaustive`, whose same-crate
34    /// exhaustive `match` makes a new `Op` variant a compile error until it is
35    /// handled — and the `[Op; N]` length then forces this array to grow to
36    /// match. (`#[non_exhaustive]` restricts only downstream crates, so the
37    /// in-crate match stays exhaustive.)
38    pub const ALL: [Op; 8] = [
39        Op::Eq,
40        Op::Ne,
41        Op::Gt,
42        Op::Gte,
43        Op::Lt,
44        Op::Lte,
45        Op::Exists,
46        Op::Contains,
47    ];
48}
49
50/// Compile-time exhaustiveness anchor for [`Op::ALL`]. It is never called — the
51/// exhaustive `match` over the `Op` type is what matters: adding a variant
52/// makes the match non-exhaustive (a compile error), forcing the variant to be
53/// added here and to [`Op::ALL`] in the same change. This is the compile-
54/// enforced anchor the drift gate relies on instead of trusting a hand-written
55/// array to stay complete.
56#[allow(dead_code)]
57fn assert_all_ops_exhaustive(op: &Op) {
58    match op {
59        Op::Eq | Op::Ne | Op::Gt | Op::Gte | Op::Lt | Op::Lte | Op::Exists | Op::Contains => {}
60    }
61}
62
63/// A single condition read off an envelope field.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct Condition {
67    /// Envelope field name.
68    pub field: String,
69    /// Operator.
70    pub op: Op,
71    /// Right-hand comparison value. Ignored by `exists`.
72    #[serde(default)]
73    pub value: Value,
74}
75
76/// Compile a condition into a predicate over the envelope. The returned
77/// closure is `Send + Sync + 'static` so it satisfies
78/// [`klieo_flows::GraphFlow::edge_branch`].
79pub fn compile_condition(c: &Condition) -> impl Fn(&Value) -> bool + Send + Sync + 'static {
80    let cond = c.clone();
81    move |env: &Value| {
82        let lhs = env.get(&cond.field);
83        match cond.op {
84            Op::Exists => lhs.is_some_and(|v| !v.is_null()),
85            Op::Eq => lhs == Some(&cond.value),
86            Op::Ne => lhs != Some(&cond.value),
87            Op::Gt | Op::Gte | Op::Lt | Op::Lte => compare_numbers(lhs, &cond.value, &cond.op),
88            Op::Contains => contains(lhs, &cond.value),
89        }
90    }
91}
92
93fn compare_numbers(lhs: Option<&Value>, rhs: &Value, op: &Op) -> bool {
94    let (Some(a), Some(b)) = (lhs.and_then(Value::as_f64), rhs.as_f64()) else {
95        return false;
96    };
97    match op {
98        Op::Gt => a > b,
99        Op::Gte => a >= b,
100        Op::Lt => a < b,
101        Op::Lte => a <= b,
102        _ => false,
103    }
104}
105
106fn contains(lhs: Option<&Value>, needle: &Value) -> bool {
107    match lhs {
108        Some(Value::String(s)) => needle.as_str().is_some_and(|n| s.contains(n)),
109        Some(Value::Array(items)) => items.contains(needle),
110        _ => false,
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use serde_json::json;
118
119    fn check(field: &str, op: Op, value: Value, env: Value) -> bool {
120        compile_condition(&Condition {
121            field: field.into(),
122            op,
123            value,
124        })(&env)
125    }
126
127    #[test]
128    fn gte_true_and_the_rejected_negatives() {
129        assert!(check("risk", Op::Gte, json!(0.8), json!({"risk": 0.8})));
130        assert!(check("risk", Op::Gte, json!(0.8), json!({"risk": 0.9})));
131        assert!(!check("risk", Op::Gte, json!(0.8), json!({"risk": 0.7})));
132        assert!(!check("risk", Op::Gte, json!(0.8), json!({"risk": "high"})));
133        assert!(!check("risk", Op::Gte, json!(0.8), json!({})));
134    }
135
136    #[test]
137    fn exists_ignores_value_and_null() {
138        assert!(check("a", Op::Exists, Value::Null, json!({"a": 1})));
139        assert!(!check("a", Op::Exists, Value::Null, json!({"a": null})));
140        assert!(!check("a", Op::Exists, Value::Null, json!({})));
141    }
142
143    #[test]
144    fn gt_is_strict_above_boundary() {
145        assert!(check("n", Op::Gt, json!(1.0), json!({"n": 1.1})));
146        assert!(!check("n", Op::Gt, json!(1.0), json!({"n": 1.0})));
147    }
148
149    #[test]
150    fn lt_is_strict_below_boundary() {
151        assert!(check("n", Op::Lt, json!(1.0), json!({"n": 0.9})));
152        assert!(!check("n", Op::Lt, json!(1.0), json!({"n": 1.0})));
153    }
154
155    #[test]
156    fn lte_includes_boundary_excludes_above() {
157        assert!(check("n", Op::Lte, json!(1.0), json!({"n": 1.0})));
158        assert!(!check("n", Op::Lte, json!(1.0), json!({"n": 1.1})));
159    }
160
161    #[test]
162    fn contains_string_and_array() {
163        assert!(check(
164            "s",
165            Op::Contains,
166            json!("ell"),
167            json!({"s": "hello"})
168        ));
169        assert!(check(
170            "xs",
171            Op::Contains,
172            json!(2),
173            json!({"xs": [1, 2, 3]})
174        ));
175        assert!(!check(
176            "s",
177            Op::Contains,
178            json!("zzz"),
179            json!({"s": "hello"})
180        ));
181    }
182
183    #[test]
184    fn contains_rejects_absent_member_and_wrong_type() {
185        assert!(!check(
186            "xs",
187            Op::Contains,
188            json!(4),
189            json!({"xs": [1, 2, 3]})
190        ));
191        assert!(!check("n", Op::Contains, json!(2), json!({"n": 42})));
192    }
193
194    #[test]
195    fn eq_and_ne() {
196        assert!(check("k", Op::Eq, json!("v"), json!({"k": "v"})));
197        assert!(check("k", Op::Ne, json!("v"), json!({"k": "x"})));
198    }
199
200    #[test]
201    fn eq_false_when_unequal() {
202        assert!(!check("k", Op::Eq, json!("v"), json!({"k": "x"})));
203    }
204
205    #[test]
206    fn ne_false_when_equal() {
207        assert!(!check("k", Op::Ne, json!("v"), json!({"k": "v"})));
208    }
209}