use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Op {
Eq,
Ne,
Gt,
Gte,
Lt,
Lte,
Exists,
Contains,
}
impl Op {
pub const ALL: [Op; 8] = [
Op::Eq,
Op::Ne,
Op::Gt,
Op::Gte,
Op::Lt,
Op::Lte,
Op::Exists,
Op::Contains,
];
}
#[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 => {}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Condition {
pub field: String,
pub op: Op,
#[serde(default)]
pub value: Value,
}
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"})));
}
}