use crate::flow_dispatcher::{dispatch_node, DispatchCtx, DispatchError, NodeOutcome};
use crate::ir_nodes::{
IRBreakStep, IRConditional, IRContinueStep, IRForIn, IRLetBinding, IRReturnStep,
};
pub async fn run_let(
binding: &IRLetBinding,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let resolved = if let Some(expr) = &binding.value_ast {
eval_expr(expr, ctx)
.map(|v| eval_to_str(&v))
.unwrap_or_default()
} else {
match binding.value_kind.as_str() {
"reference" => ctx
.let_bindings
.get(&binding.value)
.cloned()
.unwrap_or_default(),
_ => binding.value.clone(),
}
};
ctx.let_bindings.insert(binding.target.clone(), resolved.clone());
Ok(NodeOutcome::Completed {
output: resolved,
tokens_emitted: 0,
step_index: ctx.step_counter,
})
}
pub async fn run_conditional(
cond: &IRConditional,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let branch_taken = evaluate_condition(cond, ctx);
let body = if branch_taken {
&cond.then_body
} else {
&cond.else_body
};
let branch_tag = if branch_taken {
"conditional.then"
} else {
"conditional.else"
};
ctx.branch_path.push(branch_tag.to_string());
let result = dispatch_body(body, ctx).await;
ctx.branch_path.pop();
result
}
fn evaluate_condition(cond: &IRConditional, ctx: &DispatchCtx) -> bool {
if let Some(expr) = &cond.cond {
return eval_expr(expr, ctx).map(|v| eval_truthy(&v)).unwrap_or(false);
}
let primary = eval_triple(
&cond.condition,
&cond.comparison_op,
&cond.comparison_value,
ctx,
);
match cond.conjunctor.as_str() {
"or" => {
if primary {
return true;
}
for (lhs, op, rhs) in &cond.conditions {
if eval_triple(lhs, op, rhs, ctx) {
return true;
}
}
false
}
_ => primary,
}
}
fn eval_triple(lhs_raw: &str, op: &str, rhs: &str, ctx: &DispatchCtx) -> bool {
let lhs = resolve_lhs(lhs_raw, ctx);
match op {
"==" | "=" => lhs == rhs,
"!=" => lhs != rhs,
">" => numeric_cmp(&lhs, rhs).map_or(lhs.as_str() > rhs, |c| c.is_gt()),
">=" => numeric_cmp(&lhs, rhs).map_or(lhs.as_str() >= rhs, |c| c != std::cmp::Ordering::Less),
"<" => numeric_cmp(&lhs, rhs).map_or(lhs.as_str() < rhs, |c| c.is_lt()),
"<=" => numeric_cmp(&lhs, rhs).map_or(lhs.as_str() <= rhs, |c| c != std::cmp::Ordering::Greater),
"" => !lhs.is_empty() && lhs != "false" && lhs != "0",
_ => false,
}
}
fn resolve_lhs(name: &str, ctx: &DispatchCtx) -> String {
ctx.let_bindings
.get(name)
.cloned()
.unwrap_or_else(|| name.to_string())
}
fn numeric_cmp(a: &str, b: &str) -> Option<std::cmp::Ordering> {
let a = a.parse::<f64>().ok()?;
let b = b.parse::<f64>().ok()?;
a.partial_cmp(&b)
}
#[derive(Debug, Clone)]
enum EVal {
Int(i64),
Float(f64),
Bool(bool),
Str(String),
Json(serde_json::Value),
}
fn eval_expr(e: &crate::ir_nodes::IRExpr, ctx: &DispatchCtx) -> Option<EVal> {
use crate::ir_nodes::{IRExpr, IRExprLit};
match e {
IRExpr::Lit { lit } => Some(match lit {
IRExprLit::Int { value } => EVal::Int(*value),
IRExprLit::Float { value } => EVal::Float(*value),
IRExprLit::Bool { value } => EVal::Bool(*value),
IRExprLit::Str { value } => EVal::Str(value.clone()),
}),
IRExpr::Ref { path } => Some(eval_coerce_str(
crate::exec_context::resolve_dotted_var(&ctx.let_bindings, path)
.unwrap_or_else(|| path.clone()),
)),
IRExpr::Unary { op, operand } => {
let v = eval_expr(operand, ctx)?;
match op.as_str() {
"not" => Some(EVal::Bool(!eval_truthy(&v))),
"neg" => match v {
EVal::Int(i) => i.checked_neg().map(EVal::Int),
other => Some(EVal::Float(-eval_as_num(&other)?)),
},
_ => None,
}
}
IRExpr::Binary { op, lhs, rhs } => match op.as_str() {
"and" => {
let l = eval_expr(lhs, ctx)?;
if !eval_truthy(&l) {
return Some(EVal::Bool(false));
}
Some(EVal::Bool(eval_truthy(&eval_expr(rhs, ctx)?)))
}
"or" => {
let l = eval_expr(lhs, ctx)?;
if eval_truthy(&l) {
return Some(EVal::Bool(true));
}
Some(EVal::Bool(eval_truthy(&eval_expr(rhs, ctx)?)))
}
_ => {
let l = eval_expr(lhs, ctx)?;
let r = eval_expr(rhs, ctx)?;
eval_binop(op, &l, &r)
}
},
IRExpr::Call { builtin, args } => eval_builtin(builtin, args, ctx),
IRExpr::Field { base, field } => Some(eval_json_field(&eval_expr(base, ctx)?, field)),
IRExpr::Index { base, index } => {
let b = eval_expr(base, ctx)?;
let idx = eval_expr(index, ctx)?;
Some(match eval_as_int(&idx) {
Some(i) => eval_json_index(&b, i),
None => EVal::Json(serde_json::Value::Null),
})
}
}
}
fn json_to_eval(v: &serde_json::Value) -> EVal {
match v {
serde_json::Value::String(s) => EVal::Str(s.clone()),
serde_json::Value::Bool(b) => EVal::Bool(*b),
serde_json::Value::Number(n) => n
.as_i64()
.map(EVal::Int)
.unwrap_or_else(|| EVal::Float(n.as_f64().unwrap_or(0.0))),
other => EVal::Json(other.clone()),
}
}
fn as_json(v: &EVal) -> Option<serde_json::Value> {
match v {
EVal::Json(j) => Some(j.clone()),
EVal::Str(s) => serde_json::from_str(s).ok(),
_ => None,
}
}
fn eval_json_field(base: &EVal, field: &str) -> EVal {
match as_json(base) {
Some(serde_json::Value::Object(m)) => m
.get(field)
.map(json_to_eval)
.unwrap_or(EVal::Json(serde_json::Value::Null)),
_ => EVal::Json(serde_json::Value::Null),
}
}
fn eval_json_index(base: &EVal, i: i64) -> EVal {
let null = || EVal::Json(serde_json::Value::Null);
if i < 0 {
return null();
}
match as_json(base) {
Some(serde_json::Value::Array(a)) => {
a.get(i as usize).map(json_to_eval).unwrap_or_else(null)
}
_ => eval_to_str(base)
.chars()
.nth(i as usize)
.map(|c| EVal::Str(c.to_string()))
.unwrap_or_else(null),
}
}
fn eval_builtin(name: &str, args: &[crate::ir_nodes::IRExpr], ctx: &DispatchCtx) -> Option<EVal> {
let rv = eval_expr(args.first()?, ctx)?;
match name {
"as_int" => return Some(coerce_as_int(&rv)),
"as_float" => return Some(coerce_as_float(&rv)),
"as_string" => return Some(coerce_as_string(&rv)),
"as_bool" => return Some(coerce_as_bool(&rv)),
_ => {}
}
let recv = eval_to_str(&rv);
match name {
"length" | "count" => Some(EVal::Int(builtin_length(&recv))),
"is_empty" => Some(EVal::Bool(builtin_length(&recv) == 0)),
"is_null" => {
if matches!(rv, EVal::Json(serde_json::Value::Null)) {
return Some(EVal::Bool(true));
}
let t = recv.trim();
Some(EVal::Bool(t.is_empty() || t == "null"))
}
"contains" => {
let needle = eval_to_str(&eval_expr(args.get(1)?, ctx)?);
Some(EVal::Bool(builtin_contains(&recv, &needle)))
}
"starts_with" => {
let p = eval_to_str(&eval_expr(args.get(1)?, ctx)?);
Some(EVal::Bool(recv.starts_with(&p)))
}
"ends_with" => {
let s = eval_to_str(&eval_expr(args.get(1)?, ctx)?);
Some(EVal::Bool(recv.ends_with(&s)))
}
_ => None,
}
}
fn json_null() -> EVal {
EVal::Json(serde_json::Value::Null)
}
fn coerce_as_int(v: &EVal) -> EVal {
match v {
EVal::Int(i) => EVal::Int(*i),
EVal::Json(serde_json::Value::Number(n)) => n.as_i64().map(EVal::Int).unwrap_or_else(json_null),
_ => json_null(),
}
}
fn coerce_as_float(v: &EVal) -> EVal {
match v {
EVal::Float(f) => EVal::Float(*f),
EVal::Int(i) => EVal::Float(*i as f64),
EVal::Json(serde_json::Value::Number(n)) => n.as_f64().map(EVal::Float).unwrap_or_else(json_null),
_ => json_null(),
}
}
fn coerce_as_string(v: &EVal) -> EVal {
match v {
EVal::Str(s) => EVal::Str(s.clone()),
EVal::Json(serde_json::Value::String(s)) => EVal::Str(s.clone()),
_ => json_null(),
}
}
fn coerce_as_bool(v: &EVal) -> EVal {
match v {
EVal::Bool(b) => EVal::Bool(*b),
EVal::Json(serde_json::Value::Bool(b)) => EVal::Bool(*b),
_ => json_null(),
}
}
fn builtin_length(s: &str) -> i64 {
match serde_json::from_str::<serde_json::Value>(s) {
Ok(serde_json::Value::Array(a)) => a.len() as i64,
Ok(serde_json::Value::Object(m))
if m.get("taint").map(|t| t.is_string()).unwrap_or(false) =>
{
match m.get("rows") {
Some(serde_json::Value::Array(rows)) => rows.len() as i64,
_ => 0,
}
}
Ok(serde_json::Value::Object(m)) => m.len() as i64,
_ => s.chars().count() as i64,
}
}
fn builtin_contains(recv: &str, needle: &str) -> bool {
match serde_json::from_str::<serde_json::Value>(recv) {
Ok(serde_json::Value::Array(a)) => {
return a.iter().any(|e| match e {
serde_json::Value::String(s) => s == needle,
other => other.to_string() == needle,
})
}
Ok(serde_json::Value::Object(m)) => return m.contains_key(needle),
_ => {}
}
recv.contains(needle)
}
fn eval_binop(op: &str, l: &EVal, r: &EVal) -> Option<EVal> {
match op {
"add" | "sub" | "mul" | "div" | "mod" => {
if let (Some(li), Some(ri)) = (eval_as_int(l), eval_as_int(r)) {
let res = match op {
"add" => li.checked_add(ri)?,
"sub" => li.checked_sub(ri)?,
"mul" => li.checked_mul(ri)?,
"div" => li.checked_div(ri)?, "mod" => li.checked_rem(ri)?,
_ => unreachable!(),
};
return Some(EVal::Int(res));
}
let (lf, rf) = (eval_as_num(l)?, eval_as_num(r)?);
let res = match op {
"add" => lf + rf,
"sub" => lf - rf,
"mul" => lf * rf,
"div" => {
if rf == 0.0 {
return None;
}
lf / rf
}
"mod" => {
if rf == 0.0 {
return None;
}
lf % rf
}
_ => unreachable!(),
};
Some(EVal::Float(res))
}
"eq" => Some(EVal::Bool(eval_eq(l, r))),
"ne" => Some(EVal::Bool(!eval_eq(l, r))),
"lt" | "le" | "gt" | "ge" => {
let ord = eval_cmp(l, r)?;
use std::cmp::Ordering;
Some(EVal::Bool(match op {
"lt" => ord == Ordering::Less,
"le" => ord != Ordering::Greater,
"gt" => ord == Ordering::Greater,
"ge" => ord != Ordering::Less,
_ => unreachable!(),
}))
}
_ => None,
}
}
fn eval_coerce_str(s: String) -> EVal {
if let Ok(i) = s.parse::<i64>() {
return EVal::Int(i);
}
if let Ok(f) = s.parse::<f64>() {
return EVal::Float(f);
}
match s.as_str() {
"true" => EVal::Bool(true),
"false" => EVal::Bool(false),
_ => EVal::Str(s),
}
}
fn eval_as_int(v: &EVal) -> Option<i64> {
match v {
EVal::Int(i) => Some(*i),
EVal::Json(j) => j.as_i64(),
_ => None,
}
}
fn eval_as_num(v: &EVal) -> Option<f64> {
match v {
EVal::Int(i) => Some(*i as f64),
EVal::Float(f) => Some(*f),
EVal::Str(s) => s.parse::<f64>().ok(),
EVal::Bool(_) => None,
EVal::Json(j) => j.as_f64(),
}
}
fn eval_to_str(v: &EVal) -> String {
match v {
EVal::Int(i) => i.to_string(),
EVal::Float(f) => f.to_string(),
EVal::Bool(b) => b.to_string(),
EVal::Str(s) => s.clone(),
EVal::Json(serde_json::Value::Null) => String::new(),
EVal::Json(j) => j.to_string(),
}
}
fn eval_eq(l: &EVal, r: &EVal) -> bool {
if let (Some(a), Some(b)) = (eval_as_num(l), eval_as_num(r)) {
return a == b;
}
if let (EVal::Bool(a), EVal::Bool(b)) = (l, r) {
return a == b;
}
eval_to_str(l) == eval_to_str(r)
}
fn eval_cmp(l: &EVal, r: &EVal) -> Option<std::cmp::Ordering> {
if let (Some(a), Some(b)) = (eval_as_num(l), eval_as_num(r)) {
return a.partial_cmp(&b);
}
Some(eval_to_str(l).cmp(&eval_to_str(r)))
}
fn eval_truthy(v: &EVal) -> bool {
match v {
EVal::Bool(b) => *b,
EVal::Int(i) => *i != 0,
EVal::Float(f) => *f != 0.0,
EVal::Str(s) => !s.is_empty() && s != "false" && s != "0",
EVal::Json(j) => match j {
serde_json::Value::Null => false,
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().map_or(false, |f| f != 0.0),
serde_json::Value::String(s) => !s.is_empty(),
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Object(o) => !o.is_empty(),
},
}
}
pub async fn run_for_in(
for_in: &IRForIn,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let items = resolve_iterable(&for_in.iterable, ctx);
let mut aggregate_output = String::new();
let mut aggregate_tokens: u64 = 0;
let entry_step_index = ctx.step_counter;
for (idx, item) in items.iter().enumerate() {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
ctx.let_bindings.insert(for_in.variable.clone(), item.clone());
ctx.branch_path.push(format!("for_in[{idx}]"));
let iter_outcome = dispatch_body(&for_in.body, ctx).await;
ctx.branch_path.pop();
match iter_outcome {
Ok(NodeOutcome::Completed {
output,
tokens_emitted,
..
}) => {
if !output.is_empty() {
if !aggregate_output.is_empty() {
aggregate_output.push('\n');
}
aggregate_output.push_str(&output);
}
aggregate_tokens += tokens_emitted;
}
Ok(NodeOutcome::Break) => break,
Ok(NodeOutcome::LoopContinue) => continue,
Ok(NodeOutcome::Return { value }) => {
return Ok(NodeOutcome::Return { value });
}
Err(e) => return Err(e),
}
}
Ok(NodeOutcome::Completed {
output: aggregate_output,
tokens_emitted: aggregate_tokens,
step_index: entry_step_index,
})
}
fn resolve_iterable(iterable: &str, ctx: &DispatchCtx) -> Vec<String> {
let raw = crate::exec_context::resolve_value_reference(iterable, &ctx.let_bindings);
collection_elements_of(&raw)
}
fn collection_elements_of(raw: &str) -> Vec<String> {
if raw.trim().is_empty() {
return Vec::new();
}
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(elems)) => iterable_elements(elems),
Ok(serde_json::Value::Object(map))
if map.get("taint").map(|t| t.is_string()).unwrap_or(false) =>
{
match map.get("rows") {
Some(serde_json::Value::Array(rows)) => iterable_elements(rows.clone()),
_ => Vec::new(),
}
}
_ => raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
}
}
fn iterable_elements(elems: Vec<serde_json::Value>) -> Vec<String> {
elems
.into_iter()
.map(|v| match v {
serde_json::Value::String(s) => s,
serde_json::Value::Object(_) | serde_json::Value::Array(_) => v.to_string(),
other => other.to_string(),
})
.collect()
}
pub async fn run_break(
_node: &IRBreakStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
Ok(NodeOutcome::Break)
}
pub async fn run_continue(
_node: &IRContinueStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
Ok(NodeOutcome::LoopContinue)
}
pub async fn run_return(
node: &IRReturnStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let value = crate::exec_context::resolve_value_reference(&node.value_expr, &ctx.let_bindings);
Ok(NodeOutcome::Return { value })
}
async fn dispatch_body(
body: &[crate::ir_nodes::IRFlowNode],
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
let mut last_output = String::new();
let mut total_tokens: u64 = 0;
let entry_step_index = ctx.step_counter;
for (i, child) in body.iter().enumerate() {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
ctx.branch_path.push(format!("step[{i}]"));
let outcome = Box::pin(dispatch_node(child, ctx)).await;
ctx.branch_path.pop();
match outcome? {
NodeOutcome::Completed {
output,
tokens_emitted,
..
} => {
if !output.is_empty() {
last_output = output;
}
total_tokens += tokens_emitted;
}
NodeOutcome::Break => return Ok(NodeOutcome::Break),
NodeOutcome::LoopContinue => return Ok(NodeOutcome::LoopContinue),
NodeOutcome::Return { value } => return Ok(NodeOutcome::Return { value }),
}
}
Ok(NodeOutcome::Completed {
output: last_output,
tokens_emitted: total_tokens,
step_index: entry_step_index,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cancel_token::CancellationFlag;
use crate::ir_nodes::*;
use tokio::sync::mpsc;
fn fresh_ctx() -> (
DispatchCtx,
mpsc::UnboundedReceiver<crate::flow_execution_event::FlowExecutionEvent>,
) {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = DispatchCtx::new(
"TestFlow",
"stub",
"",
CancellationFlag::new(),
tx,
);
(ctx, rx)
}
#[tokio::test]
async fn run_let_literal_binds_value() {
let (mut ctx, _rx) = fresh_ctx();
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "region".into(),
value: "us-east-1".into(),
value_kind: "literal".into(),
value_ast: None,
};
let outcome = run_let(&binding, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Completed {
output,
tokens_emitted,
..
} => {
assert_eq!(output, "us-east-1");
assert_eq!(tokens_emitted, 0);
}
other => panic!("expected Completed, got {other:?}"),
}
assert_eq!(ctx.let_bindings.get("region").unwrap(), "us-east-1");
}
#[tokio::test]
async fn run_let_reference_resolves_from_bindings() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("upstream".into(), "value-A".into());
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "downstream".into(),
value: "upstream".into(),
value_kind: "reference".into(),
value_ast: None,
};
let outcome = run_let(&binding, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Completed { output, .. } => {
assert_eq!(output, "value-A");
}
other => panic!("expected Completed, got {other:?}"),
}
assert_eq!(ctx.let_bindings.get("downstream").unwrap(), "value-A");
}
#[tokio::test]
async fn run_let_reference_missing_binding_yields_empty_string() {
let (mut ctx, _rx) = fresh_ctx();
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "x".into(),
value: "nonexistent".into(),
value_kind: "reference".into(),
value_ast: None,
};
let outcome = run_let(&binding, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Completed { output, .. } => assert_eq!(output, ""),
other => panic!("expected Completed, got {other:?}"),
}
assert_eq!(ctx.let_bindings.get("x").unwrap(), "");
}
#[tokio::test]
async fn run_let_does_not_advance_step_counter() {
let (mut ctx, _rx) = fresh_ctx();
assert_eq!(ctx.step_counter, 0);
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "k".into(),
value: "v".into(),
value_kind: "literal".into(),
value_ast: None,
};
run_let(&binding, &mut ctx).await.unwrap();
assert_eq!(
ctx.step_counter, 0,
"Let MUST NOT advance the step counter (not a step from \
the wire's perspective)"
);
}
#[test]
fn eval_triple_string_equality() {
let ctx = fresh_ctx_no_rx().0;
assert!(eval_triple("us", "==", "us", &ctx));
assert!(!eval_triple("us", "==", "eu", &ctx));
assert!(eval_triple("us", "!=", "eu", &ctx));
}
#[test]
fn eval_triple_numeric_comparison() {
let ctx = fresh_ctx_no_rx().0;
assert!(eval_triple("5", ">", "3", &ctx));
assert!(eval_triple("5", ">=", "5", &ctx));
assert!(eval_triple("3", "<", "5", &ctx));
assert!(eval_triple("5", "<=", "5", &ctx));
assert!(!eval_triple("3", ">", "5", &ctx));
}
#[test]
fn eval_triple_resolves_lhs_through_bindings() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("region".into(), "us".into());
assert!(eval_triple("region", "==", "us", &ctx));
assert!(!eval_triple("region", "==", "eu", &ctx));
}
#[test]
fn eval_triple_truthy_empty_op() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("flag".into(), "yes".into());
assert!(eval_triple("flag", "", "", &ctx));
ctx.let_bindings.insert("falsy".into(), "false".into());
assert!(!eval_triple("falsy", "", "", &ctx));
ctx.let_bindings.insert("zero".into(), "0".into());
assert!(!eval_triple("zero", "", "", &ctx));
ctx.let_bindings.insert("empty".into(), "".into());
assert!(!eval_triple("empty", "", "", &ctx));
}
fn lit_int(v: i64) -> Box<IRExpr> {
Box::new(IRExpr::Lit {
lit: IRExprLit::Int { value: v },
})
}
fn eref(p: &str) -> Box<IRExpr> {
Box::new(IRExpr::Ref { path: p.into() })
}
fn bin(op: &str, l: Box<IRExpr>, r: Box<IRExpr>) -> IRExpr {
IRExpr::Binary {
op: op.into(),
lhs: l,
rhs: r,
}
}
#[test]
fn eval_expr_integer_arithmetic_is_exact() {
let ctx = fresh_ctx_no_rx().0;
let e = bin("add", lit_int(2), Box::new(bin("mul", lit_int(3), lit_int(4))));
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(14))));
}
#[test]
fn eval_expr_division_by_zero_fails_closed() {
let ctx = fresh_ctx_no_rx().0;
let e = bin("div", lit_int(5), lit_int(0));
assert!(eval_expr(&e, &ctx).is_none(), "div by zero → None (fail-closed)");
}
#[test]
fn eval_expr_modulo() {
let ctx = fresh_ctx_no_rx().0;
let e = bin("mod", lit_int(17), lit_int(5));
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(2))));
}
#[test]
fn eval_expr_count_ge_limit_over_bindings() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("recent".into(), "8".into());
ctx.let_bindings.insert("limit".into(), "5".into());
let e = bin("ge", eref("recent"), eref("limit"));
assert!(eval_truthy(&eval_expr(&e, &ctx).unwrap()));
ctx.let_bindings.insert("recent".into(), "3".into());
assert!(!eval_truthy(&eval_expr(&e, &ctx).unwrap()));
}
#[test]
fn eval_expr_boolean_and_or_short_circuit() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("a".into(), "true".into());
ctx.let_bindings.insert("b".into(), "false".into());
let and = bin("and", eref("a"), eref("b"));
assert!(!eval_truthy(&eval_expr(&and, &ctx).unwrap()));
let or = bin("or", eref("a"), eref("b"));
assert!(eval_truthy(&eval_expr(&or, &ctx).unwrap()));
}
#[test]
fn eval_expr_not_negates_truthiness() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("ready".into(), "false".into());
let e = IRExpr::Unary {
op: "not".into(),
operand: eref("ready"),
};
assert!(eval_truthy(&eval_expr(&e, &ctx).unwrap()));
}
#[test]
fn evaluate_condition_routes_rich_cond_through_expr() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("recent".into(), "9".into());
ctx.let_bindings.insert("cap".into(), "10".into());
let cond = IRConditional {
node_type: "conditional",
source_line: 0,
source_column: 0,
condition: String::new(),
comparison_op: String::new(),
comparison_value: String::new(),
then_body: Vec::new(),
else_body: Vec::new(),
conditions: Vec::new(),
conjunctor: String::new(),
cond: Some(bin("lt", eref("recent"), eref("cap"))),
};
assert!(evaluate_condition(&cond, &ctx), "9 < 10 → then branch");
}
fn estr(s: &str) -> Box<IRExpr> {
Box::new(IRExpr::Lit {
lit: IRExprLit::Str { value: s.into() },
})
}
fn call(name: &str, args: Vec<Box<IRExpr>>) -> IRExpr {
IRExpr::Call {
builtin: name.into(),
args: args.into_iter().map(|b| *b).collect(),
}
}
#[test]
fn builtin_length_counts_json_array_elements() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("xs".into(), "[1,2,3]".into());
let e = call("length", vec![eref("xs")]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(3))));
}
#[test]
fn builtin_length_of_a_string_is_char_count() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("s".into(), "hello".into());
let e = call("length", vec![eref("s")]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(5))));
}
#[test]
fn builtin_length_unwraps_a_retrieve_envelope() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert(
"rows".into(),
r#"{"taint":"trusted","rows":[{"id":1},{"id":2}]}"#.into(),
);
let e = call("length", vec![eref("rows")]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(2))));
}
#[test]
fn builtin_contains_array_membership_and_substring() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("xs".into(), r#"["a","b","c"]"#.into());
let in_arr = call("contains", vec![eref("xs"), estr("b")]);
assert!(eval_truthy(&eval_expr(&in_arr, &ctx).unwrap()));
ctx.let_bindings.insert("name".into(), "Dr. Smith".into());
let sub = call("contains", vec![eref("name"), estr("Smith")]);
assert!(eval_truthy(&eval_expr(&sub, &ctx).unwrap()));
}
#[test]
fn builtin_starts_with_and_ends_with() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("n".into(), "Dr. House".into());
assert!(eval_truthy(&eval_expr(&call("starts_with", vec![eref("n"), estr("Dr")]), &ctx).unwrap()));
assert!(eval_truthy(&eval_expr(&call("ends_with", vec![eref("n"), estr("House")]), &ctx).unwrap()));
assert!(!eval_truthy(&eval_expr(&call("starts_with", vec![eref("n"), estr("Mr")]), &ctx).unwrap()));
}
#[test]
fn throttle_headline_recent_length_ge_limit() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("recent".into(), "[1,2,3,4,5,6,7,8]".into());
ctx.let_bindings.insert("limit".into(), "5".into());
let cond = IRConditional {
node_type: "conditional",
source_line: 0,
source_column: 0,
condition: String::new(),
comparison_op: String::new(),
comparison_value: String::new(),
then_body: Vec::new(),
else_body: Vec::new(),
conditions: Vec::new(),
conjunctor: String::new(),
cond: Some(bin("ge", Box::new(call("length", vec![eref("recent")])), eref("limit"))),
};
assert!(evaluate_condition(&cond, &ctx), "8 recent >= limit 5 → then");
}
#[test]
fn index_into_a_json_array() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("items".into(), "[10,20,30]".into());
let e = IRExpr::Index {
base: eref("items"),
index: lit_int(1),
};
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(20))));
}
#[test]
fn index_out_of_bounds_resolves_to_null() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("items".into(), "[10,20]".into());
let e = IRExpr::Index {
base: eref("items"),
index: lit_int(9),
};
let got = eval_expr(&e, &ctx).expect("navigation is total — never None");
assert!(matches!(got, EVal::Json(serde_json::Value::Null)));
assert!(!eval_truthy(&got), "a null miss is falsy in a guard");
}
#[test]
fn missing_field_resolves_to_null_not_failure() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings
.insert("doc".into(), r#"{"name":"axon"}"#.into());
let e = IRExpr::Field {
base: eref("doc"),
field: "absent".into(),
};
let got = eval_expr(&e, &ctx).expect("total — never None");
assert!(matches!(got, EVal::Json(serde_json::Value::Null)));
assert!(!eval_truthy(&got));
}
#[test]
fn navigation_chains_through_a_null_totally() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"a":1}"#.into());
let e = IRExpr::Field {
base: Box::new(IRExpr::Field {
base: Box::new(IRExpr::Field {
base: eref("doc"),
field: "missing".into(),
}),
field: "deeper".into(),
}),
field: "deepest".into(),
};
let got = eval_expr(&e, &ctx).expect("total");
assert!(matches!(got, EVal::Json(serde_json::Value::Null)));
}
#[test]
fn field_on_a_scalar_is_null_not_a_panic() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("n".into(), "42".into());
let e = IRExpr::Field {
base: eref("n"),
field: "whatever".into(),
};
assert!(matches!(
eval_expr(&e, &ctx).expect("total"),
EVal::Json(serde_json::Value::Null)
));
}
#[test]
fn nested_object_navigation_returns_a_live_value() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert(
"doc".into(),
r#"{"address":{"city":"Bogotá","zip":"110111"}}"#.into(),
);
let addr = eval_expr(
&IRExpr::Field {
base: eref("doc"),
field: "address".into(),
},
&ctx,
)
.expect("total");
assert!(matches!(addr, EVal::Json(serde_json::Value::Object(_))));
let city = IRExpr::Field {
base: Box::new(IRExpr::Field {
base: eref("doc"),
field: "address".into(),
}),
field: "city".into(),
};
assert!(matches!(eval_expr(&city, &ctx), Some(EVal::Str(s)) if s == "Bogotá"));
}
#[test]
fn negative_index_is_null() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("xs".into(), "[1,2,3]".into());
let e = IRExpr::Index {
base: eref("xs"),
index: lit_int(-1),
};
assert!(matches!(
eval_expr(&e, &ctx).expect("total"),
EVal::Json(serde_json::Value::Null)
));
}
#[test]
fn as_int_succeeds_on_a_json_integer() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"age":42}"#.into());
let e = call("as_int", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "age".into() })]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(42))));
}
#[test]
fn as_int_fail_closes_to_null_on_a_string() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"age":"old"}"#.into());
let e = call("as_int", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "age".into() })]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Json(serde_json::Value::Null))));
}
#[test]
fn as_int_on_a_missing_field_is_null() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"name":"x"}"#.into());
let e = call("as_int", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "age".into() })]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Json(serde_json::Value::Null))));
}
#[test]
fn as_float_widens_an_integer_but_rejects_a_string() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"n":7,"s":"x"}"#.into());
let widen = call("as_float", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "n".into() })]);
assert!(matches!(eval_expr(&widen, &ctx), Some(EVal::Float(f)) if (f - 7.0).abs() < 1e-9));
let reject = call("as_float", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "s".into() })]);
assert!(matches!(eval_expr(&reject, &ctx), Some(EVal::Json(serde_json::Value::Null))));
}
#[test]
fn as_string_succeeds_on_a_string_and_rejects_a_number() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"name":"axon","n":5}"#.into());
let ok = call("as_string", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "name".into() })]);
assert!(matches!(eval_expr(&ok, &ctx), Some(EVal::Str(s)) if s == "axon"));
let reject = call("as_string", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "n".into() })]);
assert!(matches!(eval_expr(&reject, &ctx), Some(EVal::Json(serde_json::Value::Null))));
}
#[test]
fn as_bool_succeeds_on_a_bool_and_rejects_otherwise() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"active":true,"n":1}"#.into());
let ok = call("as_bool", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "active".into() })]);
assert!(matches!(eval_expr(&ok, &ctx), Some(EVal::Bool(true))));
let reject = call("as_bool", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "n".into() })]);
assert!(matches!(eval_expr(&reject, &ctx), Some(EVal::Json(serde_json::Value::Null))));
}
#[test]
fn is_null_is_true_for_a_missing_field() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"a":1}"#.into());
let e = call("is_null", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "missing".into() })]);
assert!(eval_truthy(&eval_expr(&e, &ctx).unwrap()));
let present = call("is_null", vec![Box::new(IRExpr::Field { base: eref("doc"), field: "a".into() })]);
assert!(!eval_truthy(&eval_expr(&present, &ctx).unwrap()));
}
#[test]
fn length_of_a_json_object_is_its_key_count() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("o".into(), r#"{"a":1,"b":2,"c":3}"#.into());
let e = call("length", vec![eref("o")]);
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(3))));
}
#[test]
fn contains_tests_object_keys() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("o".into(), r#"{"name":"x","age":1}"#.into());
assert!(eval_truthy(&eval_expr(&call("contains", vec![eref("o"), estr("name")]), &ctx).unwrap()));
assert!(!eval_truthy(&eval_expr(&call("contains", vec![eref("o"), estr("missing")]), &ctx).unwrap()));
}
#[test]
fn is_empty_of_an_empty_object_is_true() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("o".into(), "{}".into());
assert!(eval_truthy(&eval_expr(&call("is_empty", vec![eref("o")]), &ctx).unwrap()));
}
#[test]
fn null_miss_compares_equal_to_empty_and_unequal_to_a_value() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("doc".into(), r#"{"n":1}"#.into());
let guard = bin(
"eq",
Box::new(IRExpr::Field {
base: eref("doc"),
field: "tier".into(),
}),
estr("gold"),
);
assert!(!eval_truthy(&eval_expr(&guard, &ctx).expect("total")));
}
#[test]
fn field_of_an_indexed_object() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings
.insert("items".into(), r#"[{"name":"axon"},{"name":"kivi"}]"#.into());
let e = IRExpr::Field {
base: Box::new(IRExpr::Index {
base: eref("items"),
index: lit_int(0),
}),
field: "name".into(),
};
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Str(s)) if s == "axon"));
}
#[test]
fn ref_walks_nested_json_object_fields() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings
.insert("s".into(), r#"{"config":{"outbound":{"level":3}}}"#.into());
let e = IRExpr::Ref {
path: "s.config.outbound.level".into(),
};
assert!(matches!(eval_expr(&e, &ctx), Some(EVal::Int(3))));
}
#[tokio::test]
async fn run_let_evaluates_an_expression_value() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("price".into(), "4".into());
ctx.let_bindings.insert("qty".into(), "3".into());
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "total".into(),
value: "(price * qty)".into(), value_kind: "expression".into(),
value_ast: Some(bin("mul", eref("price"), eref("qty"))),
};
let _ = run_let(&binding, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("total").unwrap(), "12");
}
#[test]
fn expr_parity_corpus() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("xs".into(), "[1,2,3,4]".into());
ctx.let_bindings.insert("name".into(), "Dr. House".into());
ctx.let_bindings
.insert("rec".into(), r#"{"tier":"gold","n":7}"#.into());
let cases: Vec<(IRExpr, bool)> = vec![
(bin("eq", Box::new(bin("add", lit_int(2), Box::new(bin("mul", lit_int(3), lit_int(4))))), lit_int(14)), true),
(bin("eq", Box::new(bin("div", lit_int(17), lit_int(5))), lit_int(3)), true),
(bin("eq", Box::new(bin("mod", lit_int(17), lit_int(5))), lit_int(2)), true),
(bin("ge", lit_int(5), lit_int(5)), true),
(bin("lt", lit_int(3), lit_int(5)), true),
(bin("eq", estr("a"), estr("a")), true),
(bin("lt", estr("a"), estr("b")), true),
(bin("and", Box::new(bin("gt", lit_int(2), lit_int(1))), Box::new(bin("lt", lit_int(1), lit_int(2)))), true),
(bin("or", Box::new(bin("gt", lit_int(1), lit_int(2))), estr("x")), true),
(IRExpr::Unary { op: "not".into(), operand: Box::new(bin("eq", lit_int(1), lit_int(2))) }, true),
(bin("eq", Box::new(IRExpr::Unary { op: "neg".into(), operand: lit_int(5) }), lit_int(-5)), true),
(bin("eq", Box::new(call("length", vec![eref("xs")])), lit_int(4)), true),
(call("contains", vec![eref("name"), estr("House")]), true),
(call("starts_with", vec![eref("name"), estr("Dr")]), true),
(bin("eq", Box::new(IRExpr::Field { base: eref("rec"), field: "n".into() }), lit_int(7)), true),
(bin("eq", Box::new(IRExpr::Index { base: eref("xs"), index: lit_int(0) }), lit_int(1)), true),
];
for (i, (e, expected)) in cases.iter().enumerate() {
let got = eval_expr(e, &ctx).map(|v| eval_truthy(&v));
assert_eq!(
got,
Some(*expected),
"parity corpus case {i} mismatch (expr {e:?})"
);
}
assert!(eval_expr(&bin("div", lit_int(1), lit_int(0)), &ctx).is_none());
}
fn fresh_ctx_no_rx() -> (DispatchCtx, mpsc::UnboundedReceiver<crate::flow_execution_event::FlowExecutionEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = DispatchCtx::new("F", "stub", "", CancellationFlag::new(), tx);
(ctx, rx)
}
#[test]
fn resolve_iterable_splits_comma_list_from_binding() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("regions".into(), "us,eu,asia".into());
let items = resolve_iterable("regions", &ctx);
assert_eq!(items, vec!["us", "eu", "asia"]);
}
#[test]
fn resolve_iterable_trims_whitespace() {
let mut ctx = fresh_ctx_no_rx().0;
ctx.let_bindings.insert("xs".into(), " a , b , c ".into());
assert_eq!(resolve_iterable("xs", &ctx), vec!["a", "b", "c"]);
}
#[test]
fn resolve_iterable_falls_back_to_literal_string() {
let ctx = fresh_ctx_no_rx().0;
assert_eq!(resolve_iterable("a,b", &ctx), vec!["a", "b"]);
}
#[test]
fn resolve_iterable_empty_yields_zero_items() {
let ctx = fresh_ctx_no_rx().0;
assert!(resolve_iterable("", &ctx).is_empty());
}
#[tokio::test]
async fn run_break_returns_break_sentinel() {
let (mut ctx, _rx) = fresh_ctx();
let outcome = run_break(
&IRBreakStep {
node_type: "break",
source_line: 0,
source_column: 0,
},
&mut ctx,
)
.await
.unwrap();
assert!(matches!(outcome, NodeOutcome::Break));
}
#[tokio::test]
async fn run_continue_returns_loop_continue_sentinel() {
let (mut ctx, _rx) = fresh_ctx();
let outcome = run_continue(
&IRContinueStep {
node_type: "continue",
source_line: 0,
source_column: 0,
},
&mut ctx,
)
.await
.unwrap();
assert!(matches!(outcome, NodeOutcome::LoopContinue));
}
#[tokio::test]
async fn run_return_with_literal_value() {
let (mut ctx, _rx) = fresh_ctx();
let outcome = run_return(
&IRReturnStep {
node_type: "return",
source_line: 0,
source_column: 0,
value_expr: "ok".into(),
},
&mut ctx,
)
.await
.unwrap();
match outcome {
NodeOutcome::Return { value } => assert_eq!(value, "ok"),
other => panic!("expected Return, got {other:?}"),
}
}
#[tokio::test]
async fn run_return_resolves_through_let_bindings() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("result".into(), "computed".into());
let outcome = run_return(
&IRReturnStep {
node_type: "return",
source_line: 0,
source_column: 0,
value_expr: "result".into(),
},
&mut ctx,
)
.await
.unwrap();
match outcome {
NodeOutcome::Return { value } => assert_eq!(value, "computed"),
other => panic!("expected Return, got {other:?}"),
}
}
#[tokio::test]
async fn every_orchestration_handler_short_circuits_on_cancel() {
let cancel = CancellationFlag::new();
cancel.cancel();
let (tx, _rx) = mpsc::unbounded_channel();
let mut ctx = DispatchCtx::new("F", "stub", "", cancel, tx);
let binding = IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "x".into(),
value: "y".into(),
value_kind: "literal".into(),
value_ast: None,
};
assert!(matches!(
run_let(&binding, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
let cond = IRConditional {
node_type: "conditional",
source_line: 0,
source_column: 0,
condition: String::new(),
comparison_op: String::new(),
comparison_value: String::new(),
then_body: Vec::new(),
else_body: Vec::new(),
conditions: Vec::new(),
conjunctor: String::new(),
cond: None,
};
assert!(matches!(
run_conditional(&cond, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
let for_in = IRForIn {
node_type: "for_in",
source_line: 0,
source_column: 0,
variable: "i".into(),
iterable: String::new(),
body: Vec::new(),
};
assert!(matches!(
run_for_in(&for_in, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
assert!(matches!(
run_break(
&IRBreakStep {
node_type: "break",
source_line: 0,
source_column: 0,
},
&mut ctx,
)
.await,
Err(DispatchError::UpstreamCancelled)
));
assert!(matches!(
run_continue(
&IRContinueStep {
node_type: "continue",
source_line: 0,
source_column: 0,
},
&mut ctx,
)
.await,
Err(DispatchError::UpstreamCancelled)
));
assert!(matches!(
run_return(
&IRReturnStep {
node_type: "return",
source_line: 0,
source_column: 0,
value_expr: String::new(),
},
&mut ctx,
)
.await,
Err(DispatchError::UpstreamCancelled)
));
}
#[tokio::test]
async fn conditional_then_branch_dispatched_when_eq() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("region".into(), "us".into());
let cond = IRConditional {
node_type: "conditional",
source_line: 0,
source_column: 0,
condition: "region".into(),
comparison_op: "==".into(),
comparison_value: "us".into(),
then_body: vec![IRFlowNode::Let(IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "took".into(),
value: "then-branch".into(),
value_kind: "literal".into(),
value_ast: None,
})],
else_body: Vec::new(),
conditions: Vec::new(),
conjunctor: String::new(),
cond: None,
};
run_conditional(&cond, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("took").unwrap(), "then-branch");
}
#[tokio::test]
async fn conditional_else_branch_dispatched_when_ne() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("region".into(), "us".into());
let cond = IRConditional {
node_type: "conditional",
source_line: 0,
source_column: 0,
condition: "region".into(),
comparison_op: "==".into(),
comparison_value: "eu".into(),
then_body: Vec::new(),
else_body: vec![IRFlowNode::Let(IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "took".into(),
value: "else-branch".into(),
value_kind: "literal".into(),
value_ast: None,
})],
conditions: Vec::new(),
conjunctor: String::new(),
cond: None,
};
run_conditional(&cond, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("took").unwrap(), "else-branch");
}
#[tokio::test]
async fn for_in_iterates_each_element() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("xs".into(), "a,b,c".into());
let for_in = IRForIn {
node_type: "for_in",
source_line: 0,
source_column: 0,
variable: "x".into(),
iterable: "xs".into(),
body: vec![IRFlowNode::Let(IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "last".into(),
value: "x".into(),
value_kind: "reference".into(),
value_ast: None,
})],
};
run_for_in(&for_in, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("last").unwrap(), "c");
assert_eq!(ctx.let_bindings.get("x").unwrap(), "c");
}
#[tokio::test]
async fn for_in_break_terminates_loop() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("xs".into(), "a,b,c".into());
let for_in = IRForIn {
node_type: "for_in",
source_line: 0,
source_column: 0,
variable: "x".into(),
iterable: "xs".into(),
body: vec![IRFlowNode::Break(IRBreakStep {
node_type: "break",
source_line: 0,
source_column: 0,
})],
};
run_for_in(&for_in, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("x").unwrap(), "a");
}
#[tokio::test]
async fn for_in_zero_iterations_when_iterable_empty() {
let (mut ctx, _rx) = fresh_ctx();
let for_in = IRForIn {
node_type: "for_in",
source_line: 0,
source_column: 0,
variable: "x".into(),
iterable: "".into(),
body: vec![IRFlowNode::Let(IRLetBinding {
node_type: "let",
source_line: 0,
source_column: 0,
target: "marker".into(),
value: "ran".into(),
value_kind: "literal".into(),
value_ast: None,
})],
};
run_for_in(&for_in, &mut ctx).await.unwrap();
assert!(ctx.let_bindings.get("marker").is_none());
}
#[tokio::test]
async fn for_in_return_propagates_through_loop() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert("xs".into(), "a,b,c".into());
let for_in = IRForIn {
node_type: "for_in",
source_line: 0,
source_column: 0,
variable: "x".into(),
iterable: "xs".into(),
body: vec![IRFlowNode::Return(IRReturnStep {
node_type: "return",
source_line: 0,
source_column: 0,
value_expr: "early".into(),
})],
};
let outcome = run_for_in(&for_in, &mut ctx).await.unwrap();
match outcome {
NodeOutcome::Return { value } => assert_eq!(value, "early"),
other => panic!("expected Return propagation, got {other:?}"),
}
}
#[test]
fn resolve_iterable_iterates_json_array_elements_as_structured_records() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert(
"edges".to_string(),
r#"[{"to_id":"a","etype":"cite"},{"to_id":"b","etype":"elaborate"}]"#.to_string(),
);
let items = resolve_iterable("edges", &ctx);
assert_eq!(items.len(), 2, "two array elements, not comma-split shards");
let first: serde_json::Value = serde_json::from_str(&items[0]).expect("element is JSON");
assert_eq!(first["to_id"], "a");
assert_eq!(first["etype"], "cite");
ctx.let_bindings.insert("e".to_string(), items[1].clone());
assert_eq!(
crate::exec_context::interpolate_vars("${e.to_id}", &ctx.let_bindings),
"b"
);
}
#[test]
fn resolve_iterable_unwraps_a_retrieve_envelope_into_its_rows() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert(
"to_hibernate".to_string(),
r#"{"taint":"untrusted","confidence_floor":null,"trusted_rows":2,"below_floor_filtered":0,"rows":[{"tenant_id":"t-1","session_id_generic":"s-1","conversation_id":"c-1"},{"tenant_id":"t-2","session_id_generic":"s-2","conversation_id":"c-2"}]}"#.to_string(),
);
let items = resolve_iterable("to_hibernate", &ctx);
assert_eq!(items.len(), 2, "two rows, not envelope comma-shards");
ctx.let_bindings.insert("s".to_string(), items[0].clone());
assert_eq!(
crate::exec_context::interpolate_vars("${s.tenant_id}", &ctx.let_bindings),
"t-1",
"the brief #35 repro: `${{s.tenant_id}}` must resolve, not stay literal"
);
assert_eq!(
crate::exec_context::interpolate_vars(
"session_id == '${s.session_id_generic}'",
&ctx.let_bindings
),
"session_id == 's-1'",
"and inside a sub-`where:` clause string too"
);
}
#[test]
fn resolve_iterable_empty_retrieve_envelope_yields_zero_iterations() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert(
"empty".to_string(),
r#"{"taint":"untrusted","confidence_floor":null,"trusted_rows":0,"below_floor_filtered":0,"rows":[]}"#.to_string(),
);
assert!(resolve_iterable("empty", &ctx).is_empty());
}
#[test]
fn resolve_iterable_non_json_falls_back_to_comma_split() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings
.insert("xs".to_string(), "a, b, c".to_string());
assert_eq!(resolve_iterable("xs", &ctx), vec!["a", "b", "c"]);
ctx.let_bindings
.insert("ys".to_string(), r#"["x","y"]"#.to_string());
assert_eq!(resolve_iterable("ys", &ctx), vec!["x", "y"]);
}
#[test]
fn resolve_iterable_resolves_a_step_output_reference_to_its_array() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert(
"ClassifyEdges".to_string(),
r#"[{"to_id":"11111111-1111-1111-1111-111111111111","etype":"supersede"}]"#.to_string(),
);
let items = resolve_iterable("ClassifyEdges.output", &ctx);
assert_eq!(items.len(), 1, "the step-output array iterates as ONE record");
ctx.let_bindings.insert("e".to_string(), items[0].clone());
assert_eq!(
crate::exec_context::interpolate_vars("${e.to_id}", &ctx.let_bindings),
"11111111-1111-1111-1111-111111111111"
);
}
#[tokio::test]
async fn run_return_resolves_interpolation_and_step_output() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings
.insert("Summarize".to_string(), "the real summary".to_string());
for expr in ["${Summarize}", "Summarize.output", "Summarize"] {
let node = IRReturnStep {
node_type: "return",
source_line: 0,
source_column: 0,
value_expr: expr.to_string(),
};
match run_return(&node, &mut ctx).await.unwrap() {
NodeOutcome::Return { value } => {
assert_eq!(value, "the real summary", "`return {expr}` must resolve")
}
other => panic!("expected Return, got {other:?}"),
}
}
}
}