From ab1dbea32933dc69370aab6b375eadf64888a288 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Thu, 26 Mar 2026 17:10:05 +0000 Subject: [PATCH] feat(wfe-core): add condition evaluator with field path resolution and cascade skip --- wfe-core/src/executor/condition.rs | 745 +++++++++++++++++++++++++++++ wfe-core/src/executor/mod.rs | 1 + 2 files changed, 746 insertions(+) create mode 100644 wfe-core/src/executor/condition.rs diff --git a/wfe-core/src/executor/condition.rs b/wfe-core/src/executor/condition.rs new file mode 100644 index 0000000..3071622 --- /dev/null +++ b/wfe-core/src/executor/condition.rs @@ -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 { + 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 for EvalError { + fn from(e: WfeError) -> Self { + EvalError::Wfe(e) + } +} + +fn evaluate_inner( + condition: &StepCondition, + data: &serde_json::Value, +) -> Result { + 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::() { + // 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 { + 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 { + 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 { + 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) -> 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()); + } +} diff --git a/wfe-core/src/executor/mod.rs b/wfe-core/src/executor/mod.rs index 99a886f..b4376af 100644 --- a/wfe-core/src/executor/mod.rs +++ b/wfe-core/src/executor/mod.rs @@ -1,3 +1,4 @@ +pub mod condition; mod error_handler; mod result_processor; mod step_registry;