feat(wfe-core): add condition evaluator with field path resolution and cascade skip
This commit is contained in:
745
wfe-core/src/executor/condition.rs
Normal file
745
wfe-core/src/executor/condition.rs
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
use crate::models::condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
|
use crate::WfeError;
|
||||||
|
|
||||||
|
/// Evaluate a step condition against workflow data.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if the step should run, `Ok(false)` if it should be skipped.
|
||||||
|
/// Missing field paths return `Ok(false)` (cascade skip behavior).
|
||||||
|
pub fn evaluate(
|
||||||
|
condition: &StepCondition,
|
||||||
|
workflow_data: &serde_json::Value,
|
||||||
|
) -> Result<bool, WfeError> {
|
||||||
|
match evaluate_inner(condition, workflow_data) {
|
||||||
|
Ok(result) => Ok(result),
|
||||||
|
Err(EvalError::FieldNotPresent) => Ok(false), // cascade skip
|
||||||
|
Err(EvalError::Wfe(e)) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal error type that distinguishes missing-field from real errors.
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum EvalError {
|
||||||
|
FieldNotPresent,
|
||||||
|
Wfe(WfeError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<WfeError> for EvalError {
|
||||||
|
fn from(e: WfeError) -> Self {
|
||||||
|
EvalError::Wfe(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_inner(
|
||||||
|
condition: &StepCondition,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
match condition {
|
||||||
|
StepCondition::All(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if !evaluate_inner(c, data)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
StepCondition::Any(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
StepCondition::None(conditions) => {
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
StepCondition::OneOf(conditions) => {
|
||||||
|
let mut count = 0;
|
||||||
|
for c in conditions {
|
||||||
|
if evaluate_inner(c, data)? {
|
||||||
|
count += 1;
|
||||||
|
if count > 1 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(count == 1)
|
||||||
|
}
|
||||||
|
StepCondition::Not(inner) => {
|
||||||
|
let result = evaluate_inner(inner, data)?;
|
||||||
|
Ok(!result)
|
||||||
|
}
|
||||||
|
StepCondition::Comparison(comp) => evaluate_comparison(comp, data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a dot-separated field path against JSON data.
|
||||||
|
///
|
||||||
|
/// Path starts with `.` which is stripped, then split by `.`.
|
||||||
|
/// Segments that parse as `usize` are treated as array indices.
|
||||||
|
fn resolve_field_path<'a>(
|
||||||
|
path: &str,
|
||||||
|
data: &'a serde_json::Value,
|
||||||
|
) -> Result<&'a serde_json::Value, EvalError> {
|
||||||
|
let path = path.strip_prefix('.').unwrap_or(path);
|
||||||
|
if path.is_empty() {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments: Vec<&str> = path.split('.').collect();
|
||||||
|
let mut current = data;
|
||||||
|
|
||||||
|
for segment in &segments {
|
||||||
|
if let Ok(idx) = segment.parse::<usize>() {
|
||||||
|
// Try array index access.
|
||||||
|
match current.as_array() {
|
||||||
|
Some(arr) => {
|
||||||
|
current = arr.get(idx).ok_or(EvalError::FieldNotPresent)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(EvalError::FieldNotPresent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Object field access.
|
||||||
|
match current.as_object() {
|
||||||
|
Some(obj) => {
|
||||||
|
current = obj.get(*segment).ok_or(EvalError::FieldNotPresent)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Err(EvalError::FieldNotPresent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_comparison(
|
||||||
|
comp: &FieldComparison,
|
||||||
|
data: &serde_json::Value,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let resolved = resolve_field_path(&comp.field, data)?;
|
||||||
|
|
||||||
|
match &comp.operator {
|
||||||
|
ComparisonOp::IsNull => Ok(resolved.is_null()),
|
||||||
|
ComparisonOp::IsNotNull => Ok(!resolved.is_null()),
|
||||||
|
ComparisonOp::Equals => {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"Equals operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(resolved == expected)
|
||||||
|
}
|
||||||
|
ComparisonOp::NotEquals => {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"NotEquals operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(resolved != expected)
|
||||||
|
}
|
||||||
|
ComparisonOp::Gt => compare_numeric(resolved, comp, |a, b| a > b),
|
||||||
|
ComparisonOp::Gte => compare_numeric(resolved, comp, |a, b| a >= b),
|
||||||
|
ComparisonOp::Lt => compare_numeric(resolved, comp, |a, b| a < b),
|
||||||
|
ComparisonOp::Lte => compare_numeric(resolved, comp, |a, b| a <= b),
|
||||||
|
ComparisonOp::Contains => evaluate_contains(resolved, comp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_numeric(
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
comp: &FieldComparison,
|
||||||
|
cmp_fn: fn(f64, f64) -> bool,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"{:?} operator requires a value",
|
||||||
|
comp.operator
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let a = resolved.as_f64().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"cannot compare non-numeric field value: {}",
|
||||||
|
resolved
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let b = expected.as_f64().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"cannot compare with non-numeric value: {}",
|
||||||
|
expected
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(cmp_fn(a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_contains(
|
||||||
|
resolved: &serde_json::Value,
|
||||||
|
comp: &FieldComparison,
|
||||||
|
) -> Result<bool, EvalError> {
|
||||||
|
let expected = comp.value.as_ref().ok_or_else(|| {
|
||||||
|
EvalError::Wfe(WfeError::StepExecution(
|
||||||
|
"Contains operator requires a value".into(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// String contains substring.
|
||||||
|
if let Some(s) = resolved.as_str()
|
||||||
|
&& let Some(substr) = expected.as_str()
|
||||||
|
{
|
||||||
|
return Ok(s.contains(substr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array contains element.
|
||||||
|
if let Some(arr) = resolved.as_array() {
|
||||||
|
return Ok(arr.contains(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(EvalError::Wfe(WfeError::StepExecution(format!(
|
||||||
|
"Contains requires a string or array field, got {}",
|
||||||
|
value_type_name(resolved)
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_type_name(value: &serde_json::Value) -> &'static str {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Null => "null",
|
||||||
|
serde_json::Value::Bool(_) => "bool",
|
||||||
|
serde_json::Value::Number(_) => "number",
|
||||||
|
serde_json::Value::String(_) => "string",
|
||||||
|
serde_json::Value::Array(_) => "array",
|
||||||
|
serde_json::Value::Object(_) => "object",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::condition::{ComparisonOp, FieldComparison, StepCondition};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
// -- resolve_field_path tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_field() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let result = resolve_field_path(".name", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_nested_field() {
|
||||||
|
let data = json!({"outputs": {"status": "success"}});
|
||||||
|
let result = resolve_field_path(".outputs.status", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!("success"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_missing_field() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let result = resolve_field_path(".missing", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_array_index() {
|
||||||
|
let data = json!({"items": [10, 20, 30]});
|
||||||
|
let result = resolve_field_path(".items.1", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_array_index_out_of_bounds() {
|
||||||
|
let data = json!({"items": [10, 20]});
|
||||||
|
let result = resolve_field_path(".items.5", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_deeply_nested() {
|
||||||
|
let data = json!({"a": {"b": {"c": {"d": 42}}}});
|
||||||
|
let result = resolve_field_path(".a.b.c.d", &data).unwrap();
|
||||||
|
assert_eq!(result, &json!(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_empty_path_returns_root() {
|
||||||
|
let data = json!({"x": 1});
|
||||||
|
let result = resolve_field_path(".", &data).unwrap();
|
||||||
|
assert_eq!(result, &data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_field_on_non_object() {
|
||||||
|
let data = json!({"x": 42});
|
||||||
|
let result = resolve_field_path(".x.y", &data);
|
||||||
|
assert!(matches!(result, Err(EvalError::FieldNotPresent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Comparison operator tests --
|
||||||
|
|
||||||
|
fn comp(field: &str, op: ComparisonOp, value: Option<serde_json::Value>) -> StepCondition {
|
||||||
|
StepCondition::Comparison(FieldComparison {
|
||||||
|
field: field.to_string(),
|
||||||
|
operator: op,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_match() {
|
||||||
|
let data = json!({"status": "ok"});
|
||||||
|
let cond = comp(".status", ComparisonOp::Equals, Some(json!("ok")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_mismatch() {
|
||||||
|
let data = json!({"status": "fail"});
|
||||||
|
let cond = comp(".status", ComparisonOp::Equals, Some(json!("ok")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_numeric() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Equals, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_match() {
|
||||||
|
let data = json!({"status": "fail"});
|
||||||
|
let cond = comp(".status", ComparisonOp::NotEquals, Some(json!("ok")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_mismatch() {
|
||||||
|
let data = json!({"status": "ok"});
|
||||||
|
let cond = comp(".status", ComparisonOp::NotEquals, Some(json!("ok")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_match() {
|
||||||
|
let data = json!({"count": 10});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_mismatch() {
|
||||||
|
let data = json!({"count": 3});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_equal_is_false() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gte_match() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gte, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gte_mismatch() {
|
||||||
|
let data = json!({"count": 4});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gte, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lt_match() {
|
||||||
|
let data = json!({"count": 3});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lt, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lt_mismatch() {
|
||||||
|
let data = json!({"count": 7});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lt, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lte_match() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lte, Some(json!(5)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lte_mismatch() {
|
||||||
|
let data = json!({"count": 6});
|
||||||
|
let cond = comp(".count", ComparisonOp::Lte, Some(json!(5)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_string_match() {
|
||||||
|
let data = json!({"msg": "hello world"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, Some(json!("world")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_string_mismatch() {
|
||||||
|
let data = json!({"msg": "hello world"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, Some(json!("xyz")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_match() {
|
||||||
|
let data = json!({"tags": ["a", "b", "c"]});
|
||||||
|
let cond = comp(".tags", ComparisonOp::Contains, Some(json!("b")));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_mismatch() {
|
||||||
|
let data = json!({"tags": ["a", "b", "c"]});
|
||||||
|
let cond = comp(".tags", ComparisonOp::Contains, Some(json!("z")));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_null_true() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNull, None);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_null_false() {
|
||||||
|
let data = json!({"val": 42});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNull, None);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_not_null_true() {
|
||||||
|
let data = json!({"val": 42});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNotNull, None);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_not_null_false() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::IsNotNull, None);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Combinator tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_both_true() {
|
||||||
|
let data = json!({"a": 1, "b": 2});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_one_false() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_empty_is_true() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::All(vec![]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_none_true() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_empty_is_false() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::Any(vec![]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_all_false() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::None(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::None(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_empty_is_true() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::None(vec![]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_exactly_one_true() {
|
||||||
|
let data = json!({"a": 1, "b": 99});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_both_true() {
|
||||||
|
let data = json!({"a": 1, "b": 2});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_none_true() {
|
||||||
|
let data = json!({"a": 99, "b": 99});
|
||||||
|
let cond = StepCondition::OneOf(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".b", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_true_becomes_false() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = StepCondition::Not(Box::new(comp(
|
||||||
|
".a",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(1)),
|
||||||
|
)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_false_becomes_true() {
|
||||||
|
let data = json!({"a": 99});
|
||||||
|
let cond = StepCondition::Not(Box::new(comp(
|
||||||
|
".a",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(1)),
|
||||||
|
)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Cascade skip tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_field_returns_false_cascade_skip() {
|
||||||
|
let data = json!({"other": 1});
|
||||||
|
let cond = comp(".missing", ComparisonOp::Equals, Some(json!(1)));
|
||||||
|
// Missing field -> cascade skip -> Ok(false)
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_nested_field_returns_false() {
|
||||||
|
let data = json!({"a": {"b": 1}});
|
||||||
|
let cond = comp(".a.c", ComparisonOp::Equals, Some(json!(1)));
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_field_in_all_returns_false() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".missing", ComparisonOp::Equals, Some(json!(2))),
|
||||||
|
]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Nested combinator tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_all_any_not() {
|
||||||
|
let data = json!({"a": 1, "b": 2, "c": 3});
|
||||||
|
// All(Any(a==1, a==99), Not(c==99))
|
||||||
|
let cond = StepCondition::All(vec![
|
||||||
|
StepCondition::Any(vec![
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(1))),
|
||||||
|
comp(".a", ComparisonOp::Equals, Some(json!(99))),
|
||||||
|
]),
|
||||||
|
StepCondition::Not(Box::new(comp(
|
||||||
|
".c",
|
||||||
|
ComparisonOp::Equals,
|
||||||
|
Some(json!(99)),
|
||||||
|
))),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_any_of_alls() {
|
||||||
|
let data = json!({"x": 10, "y": 20});
|
||||||
|
// Any(All(x>5, y>25), All(x>5, y>15))
|
||||||
|
let cond = StepCondition::Any(vec![
|
||||||
|
StepCondition::All(vec![
|
||||||
|
comp(".x", ComparisonOp::Gt, Some(json!(5))),
|
||||||
|
comp(".y", ComparisonOp::Gt, Some(json!(25))),
|
||||||
|
]),
|
||||||
|
StepCondition::All(vec![
|
||||||
|
comp(".x", ComparisonOp::Gt, Some(json!(5))),
|
||||||
|
comp(".y", ComparisonOp::Gt, Some(json!(15))),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Edge cases / error cases --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_on_string_errors() {
|
||||||
|
let data = json!({"name": "alice"});
|
||||||
|
let cond = comp(".name", ComparisonOp::Gt, Some(json!(5)));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_with_string_value_errors() {
|
||||||
|
let data = json!({"count": 5});
|
||||||
|
let cond = comp(".count", ComparisonOp::Gt, Some(json!("not a number")));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_on_number_errors() {
|
||||||
|
let data = json!({"count": 42});
|
||||||
|
let cond = comp(".count", ComparisonOp::Contains, Some(json!("4")));
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::Equals, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_equals_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::NotEquals, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gt_without_value_errors() {
|
||||||
|
let data = json!({"a": 1});
|
||||||
|
let cond = comp(".a", ComparisonOp::Gt, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_without_value_errors() {
|
||||||
|
let data = json!({"msg": "hello"});
|
||||||
|
let cond = comp(".msg", ComparisonOp::Contains, None);
|
||||||
|
let result = evaluate(&cond, &data);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_bool_values() {
|
||||||
|
let data = json!({"active": true});
|
||||||
|
let cond = comp(".active", ComparisonOp::Equals, Some(json!(true)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_null_value() {
|
||||||
|
let data = json!({"val": null});
|
||||||
|
let cond = comp(".val", ComparisonOp::Equals, Some(json!(null)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn float_comparison() {
|
||||||
|
let data = json!({"score": 3.14});
|
||||||
|
assert!(evaluate(&comp(".score", ComparisonOp::Gt, Some(json!(3.0))), &data).unwrap());
|
||||||
|
assert!(evaluate(&comp(".score", ComparisonOp::Lt, Some(json!(4.0))), &data).unwrap());
|
||||||
|
assert!(!evaluate(&comp(".score", ComparisonOp::Equals, Some(json!(3.0))), &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contains_array_numeric_element() {
|
||||||
|
let data = json!({"nums": [1, 2, 3]});
|
||||||
|
let cond = comp(".nums", ComparisonOp::Contains, Some(json!(2)));
|
||||||
|
assert!(evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_of_empty_is_false() {
|
||||||
|
let data = json!({});
|
||||||
|
let cond = StepCondition::OneOf(vec![]);
|
||||||
|
assert!(!evaluate(&cond, &data).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod condition;
|
||||||
mod error_handler;
|
mod error_handler;
|
||||||
mod result_processor;
|
mod result_processor;
|
||||||
mod step_registry;
|
mod step_registry;
|
||||||
|
|||||||
Reference in New Issue
Block a user