//! Workflow `when` condition evaluation (v1.2 Workflows) — pure, DB-free. //! //! A step's optional `when` is a small, safe JSON predicate over the run //! context (`input` + prior step `output`s, resolved via [`workflow_template`]). //! When it evaluates false the step is `skipped`. This keeps `manager-core` //! free of a scripting engine (the boundary rule) — it is NOT Rhai and never //! grows into one; a `when` that ever needs real scripting is a deliberate //! future ExecutorClient round-trip, not an engine dependency here. //! //! Grammar (precedence low→high): `||`, `&&`, `!`, comparison //! (`== != < > <= >=`), primary (`( … )`, `exists `, literal, ``). //! Literals: numbers, `'…'`/`"…"` strings, `true`, `false`, `null`. //! A bare reference is truthy per [`is_truthy`]; a reference that doesn't //! resolve is `null` (falsy) at run time. This is a DELIBERATE asymmetry with //! [`workflow_template`], which HARD-FAILS a missing ref: a `when` is a //! predicate (an absent value is "condition not met"), whereas an input //! template must not silently substitute null. Apply-time [`validate`] guards //! the *shape* of a ref (the root `input`/`steps..output` prefix and that //! `` is a declared step), but it cannot check a deeper JSON path against //! a runtime value — so a typo *past* the step name (e.g. `steps.a.output.typ` //! for `…typo`) is not caught at apply and silently makes the step skip. Prefer //! `exists ` to make "the value may be absent" explicit. //! //! [`workflow_template`]: crate::workflow_template use std::collections::BTreeSet; use serde_json::Value; use thiserror::Error; use crate::workflow_template::{RunContext, TemplateError}; #[derive(Debug, Error, PartialEq, Eq)] pub enum ExprError { #[error("workflow `when` parse error: {0}")] Parse(String), #[error("workflow `when` reference error: {0}")] Ref(#[from] TemplateError), } /// Parse a `when` expression (syntactic validation). Returns the AST. /// /// # Errors /// [`ExprError::Parse`] on malformed syntax. pub fn parse(src: &str) -> Result { let toks = lex(src)?; let mut p = Parser { toks: &toks, pos: 0, }; let e = p.parse_or()?; if p.pos != p.toks.len() { return Err(ExprError::Parse(format!( "trailing tokens near {:?}", p.toks.get(p.pos) ))); } Ok(e) } /// Parse + evaluate `src` against `ctx`. /// /// # Errors /// [`ExprError::Parse`] on malformed syntax. pub fn eval(src: &str, ctx: &RunContext) -> Result { Ok(eval_bool(&parse(src)?, ctx)) } /// Apply-time validation: parse, then check every referenced `steps.` /// names a declared step. /// /// # Errors /// [`ExprError`] on a parse error or a reference to an undeclared step. pub fn validate(src: &str, step_names: &BTreeSet) -> Result<(), ExprError> { let ast = parse(src)?; let mut refs = Vec::new(); ast.collect_refs(&mut refs); for r in refs { let parts: Vec<&str> = r.split('.').collect(); match parts.first().copied() { Some("input") => {} Some("steps") => { let name = parts .get(1) .filter(|s| !s.is_empty()) .ok_or_else(|| ExprError::Ref(TemplateError::MalformedRef(r.clone())))?; if parts.get(2).copied() != Some("output") { return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone()))); } if !step_names.contains(*name) { return Err(ExprError::Ref(TemplateError::UnknownStep( r.clone(), (*name).to_string(), ))); } } _ => return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone()))), } } Ok(()) } // ---- AST ------------------------------------------------------------------ #[derive(Debug, Clone, PartialEq)] pub enum Expr { Or(Box, Box), And(Box, Box), Not(Box), Cmp(Operand, CmpOp, Operand), Truthy(Operand), Exists(String), } #[derive(Debug, Clone, PartialEq)] pub enum Operand { Ref(String), Lit(Value), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CmpOp { Eq, Ne, Lt, Gt, Le, Ge, } impl Expr { fn collect_refs(&self, acc: &mut Vec) { match self { Self::Or(a, b) | Self::And(a, b) => { a.collect_refs(acc); b.collect_refs(acc); } Self::Not(a) => a.collect_refs(acc), Self::Cmp(l, _, r) => { l.collect_ref(acc); r.collect_ref(acc); } Self::Truthy(o) => o.collect_ref(acc), Self::Exists(r) => acc.push(r.clone()), } } } impl Operand { fn collect_ref(&self, acc: &mut Vec) { if let Self::Ref(r) = self { acc.push(r.clone()); } } } // ---- Evaluation ----------------------------------------------------------- fn eval_bool(e: &Expr, ctx: &RunContext) -> bool { match e { Expr::Or(a, b) => eval_bool(a, ctx) || eval_bool(b, ctx), Expr::And(a, b) => eval_bool(a, ctx) && eval_bool(b, ctx), Expr::Not(a) => !eval_bool(a, ctx), Expr::Truthy(o) => is_truthy(&operand_value(o, ctx)), Expr::Exists(r) => ctx.resolve_ref(r).is_some_and(|v| !v.is_null()), Expr::Cmp(l, op, r) => { let lv = operand_value(l, ctx); let rv = operand_value(r, ctx); eval_cmp(&lv, *op, &rv) } } } fn operand_value(o: &Operand, ctx: &RunContext) -> Value { match o { Operand::Lit(v) => v.clone(), Operand::Ref(r) => ctx.resolve_ref(r).cloned().unwrap_or(Value::Null), } } /// JSON truthiness: null/false/0/""/[]/{}` are falsy; everything else truthy. #[must_use] pub fn is_truthy(v: &Value) -> bool { match v { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), Value::String(s) => !s.is_empty(), Value::Array(a) => !a.is_empty(), Value::Object(m) => !m.is_empty(), } } fn eval_cmp(l: &Value, op: CmpOp, r: &Value) -> bool { match op { CmpOp::Eq => json_eq(l, r), CmpOp::Ne => !json_eq(l, r), CmpOp::Lt | CmpOp::Gt | CmpOp::Le | CmpOp::Ge => match order(l, r) { Some(ord) => match op { CmpOp::Lt => ord.is_lt(), CmpOp::Gt => ord.is_gt(), CmpOp::Le => ord.is_le(), CmpOp::Ge => ord.is_ge(), _ => unreachable!(), }, None => false, }, } } fn json_eq(l: &Value, r: &Value) -> bool { match (l.as_f64(), r.as_f64()) { (Some(a), Some(b)) => (a - b).abs() < f64::EPSILON, _ => l == r, } } fn order(l: &Value, r: &Value) -> Option { if let (Some(a), Some(b)) = (l.as_f64(), r.as_f64()) { return a.partial_cmp(&b); } if let (Value::String(a), Value::String(b)) = (l, r) { return Some(a.cmp(b)); } None } // ---- Lexer ---------------------------------------------------------------- #[derive(Debug, Clone, PartialEq)] enum Tok { Word(String), Num(f64), Str(String), Op(&'static str), LParen, RParen, } #[allow(clippy::too_many_lines)] fn lex(src: &str) -> Result, ExprError> { let b = src.as_bytes(); let mut i = 0; let mut out = Vec::new(); while i < b.len() { let c = b[i]; match c { _ if c.is_ascii_whitespace() => i += 1, b'(' => { out.push(Tok::LParen); i += 1; } b')' => { out.push(Tok::RParen); i += 1; } b'\'' | b'"' => { let quote = c; let start = i + 1; let mut j = start; while j < b.len() && b[j] != quote { j += 1; } if j >= b.len() { return Err(ExprError::Parse("unterminated string".to_string())); } out.push(Tok::Str(src[start..j].to_string())); i = j + 1; } b'0'..=b'9' | b'-' if c != b'-' || next_is_digit(b, i) => { let start = i; i += 1; while i < b.len() && (b[i].is_ascii_digit() || b[i] == b'.') { i += 1; } let f: f64 = src[start..i] .parse() .map_err(|_| ExprError::Parse(format!("bad number `{}`", &src[start..i])))?; out.push(Tok::Num(f)); } b'=' | b'!' | b'<' | b'>' | b'&' | b'|' => { let two = if i + 1 < b.len() { &src[i..=i + 1] } else { "" }; match two { "==" => two_op(&mut out, &mut i, "=="), "!=" => two_op(&mut out, &mut i, "!="), "<=" => two_op(&mut out, &mut i, "<="), ">=" => two_op(&mut out, &mut i, ">="), "&&" => two_op(&mut out, &mut i, "&&"), "||" => two_op(&mut out, &mut i, "||"), _ => match c { b'<' => one_op(&mut out, &mut i, "<"), b'>' => one_op(&mut out, &mut i, ">"), b'!' => one_op(&mut out, &mut i, "!"), _ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))), }, } } _ if c.is_ascii_alphabetic() || c == b'_' => { let start = i; while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') { i += 1; } out.push(Tok::Word(src[start..i].to_string())); } _ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))), } } Ok(out) } fn next_is_digit(b: &[u8], i: usize) -> bool { b.get(i + 1).is_some_and(u8::is_ascii_digit) } fn two_op(out: &mut Vec, i: &mut usize, op: &'static str) { out.push(Tok::Op(op)); *i += 2; } fn one_op(out: &mut Vec, i: &mut usize, op: &'static str) { out.push(Tok::Op(op)); *i += 1; } // ---- Parser --------------------------------------------------------------- struct Parser<'a> { toks: &'a [Tok], pos: usize, } impl Parser<'_> { fn peek(&self) -> Option<&Tok> { self.toks.get(self.pos) } fn parse_or(&mut self) -> Result { let mut left = self.parse_and()?; while matches!(self.peek(), Some(Tok::Op("||"))) { self.pos += 1; let right = self.parse_and()?; left = Expr::Or(Box::new(left), Box::new(right)); } Ok(left) } fn parse_and(&mut self) -> Result { let mut left = self.parse_not()?; while matches!(self.peek(), Some(Tok::Op("&&"))) { self.pos += 1; let right = self.parse_not()?; left = Expr::And(Box::new(left), Box::new(right)); } Ok(left) } fn parse_not(&mut self) -> Result { if matches!(self.peek(), Some(Tok::Op("!"))) { self.pos += 1; return Ok(Expr::Not(Box::new(self.parse_not()?))); } self.parse_cmp() } fn parse_cmp(&mut self) -> Result { // `( … )` group is a full boolean sub-expression. if matches!(self.peek(), Some(Tok::LParen)) { self.pos += 1; let inner = self.parse_or()?; if !matches!(self.peek(), Some(Tok::RParen)) { return Err(ExprError::Parse("expected `)`".to_string())); } self.pos += 1; return Ok(inner); } // `exists ` if matches!(self.peek(), Some(Tok::Word(w)) if w == "exists") { self.pos += 1; let Some(Tok::Word(r)) = self.peek().cloned() else { return Err(ExprError::Parse( "expected reference after `exists`".to_string(), )); }; self.pos += 1; return Ok(Expr::Exists(r)); } let left = self.parse_operand()?; if let Some(op) = self.peek().and_then(cmp_op) { self.pos += 1; let right = self.parse_operand()?; return Ok(Expr::Cmp(left, op, right)); } Ok(Expr::Truthy(left)) } fn parse_operand(&mut self) -> Result { match self.peek().cloned() { Some(Tok::Num(n)) => { self.pos += 1; Ok(Operand::Lit(serde_json::json!(n))) } Some(Tok::Str(s)) => { self.pos += 1; Ok(Operand::Lit(Value::String(s))) } Some(Tok::Word(w)) => { self.pos += 1; Ok(match w.as_str() { "true" => Operand::Lit(Value::Bool(true)), "false" => Operand::Lit(Value::Bool(false)), "null" => Operand::Lit(Value::Null), _ => Operand::Ref(w), }) } other => Err(ExprError::Parse(format!( "expected operand, found {other:?}" ))), } } } fn cmp_op(t: &Tok) -> Option { match t { Tok::Op("==") => Some(CmpOp::Eq), Tok::Op("!=") => Some(CmpOp::Ne), Tok::Op("<") => Some(CmpOp::Lt), Tok::Op(">") => Some(CmpOp::Gt), Tok::Op("<=") => Some(CmpOp::Le), Tok::Op(">=") => Some(CmpOp::Ge), _ => None, } } #[cfg(test)] mod tests { use super::*; use serde_json::json; use std::collections::BTreeMap; fn ctx() -> RunContext { let mut steps = BTreeMap::new(); steps.insert("check".to_string(), json!({ "ok": true, "score": 7 })); RunContext { input: json!({ "amount": 100, "mode": "prod", "flag": false }), steps, } } #[test] fn comparisons() { let c = ctx(); assert!(eval("input.amount > 50", &c).unwrap()); assert!(!eval("input.amount > 500", &c).unwrap()); assert!(eval("input.amount == 100", &c).unwrap()); assert!(eval("input.mode == 'prod'", &c).unwrap()); assert!(eval("steps.check.output.score >= 7", &c).unwrap()); assert!(eval("steps.check.output.ok == true", &c).unwrap()); } #[test] fn boolean_ops_and_precedence() { let c = ctx(); assert!(eval("input.amount > 50 && input.mode == 'prod'", &c).unwrap()); assert!(!eval("input.amount > 50 && input.mode == 'dev'", &c).unwrap()); assert!(eval("input.amount > 500 || input.mode == 'prod'", &c).unwrap()); assert!(eval("!(input.mode == 'dev')", &c).unwrap()); // && binds tighter than || assert!(eval("false && false || input.amount == 100", &c).unwrap()); } #[test] fn truthiness_and_exists() { let c = ctx(); assert!(eval("steps.check.output.ok", &c).unwrap()); assert!(!eval("input.flag", &c).unwrap()); assert!(eval("exists input.amount", &c).unwrap()); assert!(!eval("exists input.nope", &c).unwrap()); // a missing ref is falsy, not an error, in a condition assert!(!eval("input.nope == 1", &c).unwrap()); } #[test] fn parse_errors() { assert!(parse("input.a >").is_err()); assert!(parse("&& input.a").is_err()); assert!(parse("(input.a == 1").is_err()); assert!(parse("'unterminated").is_err()); } #[test] fn validate_checks_declared_steps() { let steps: BTreeSet = ["check".to_string()].into_iter().collect(); assert!(validate("steps.check.output.ok && input.x > 1", &steps).is_ok()); assert!(validate("steps.ghost.output.ok", &steps).is_err()); assert!(validate("steps.check.result", &steps).is_err()); } }