use async_trait::async_trait;
use mlua_flow_ir::{eval, eval_async, AsyncDispatcher, Dispatcher, EvalError, Node};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct Fixture;
impl Dispatcher for Fixture {
fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
fixture_logic(ref_, input)
}
}
#[async_trait]
impl AsyncDispatcher for Fixture {
async fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
fixture_logic(ref_, input)
}
}
fn fixture_logic(ref_: &str, input: Value) -> Result<Value, EvalError> {
match ref_ {
"upper" => match input {
Value::String(s) => Ok(Value::String(s.to_uppercase())),
other => Ok(other),
},
"echo" => Ok(input),
"fail" => Err(EvalError::DispatcherError {
ref_: "fail".into(),
msg: "intentional fixture failure".into(),
}),
other => Err(EvalError::DispatcherError {
ref_: other.into(),
msg: "unknown ref in fixture".into(),
}),
}
}
fn node_from(v: Value) -> Node {
serde_json::from_value(v).expect("valid wire-format Node")
}
enum Expect {
Ok(Option<Value>),
Err,
}
struct Case {
name: &'static str,
node: Node,
ctx: Value,
expect: Expect,
}
fn branch_wire() -> Value {
json!({
"kind": "branch",
"cond": {"op": "eq",
"lhs": {"op": "path", "at": "$.flag"},
"rhs": {"op": "lit", "value": true}},
"then": {"kind": "step", "ref": "upper",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.result"}},
"else": {"kind": "step", "ref": "echo",
"in": {"op": "lit", "value": 1},
"out": {"op": "path", "at": "$.result"}},
})
}
fn loop_wire(cond: Value, max: u32) -> Value {
json!({
"kind": "loop",
"counter": {"op": "path", "at": "$.n"},
"cond": cond,
"body": {"kind": "assign",
"at": {"op": "path", "at": "$.sum"},
"value": {"op": "add",
"lhs": {"op": "path", "at": "$.sum"},
"rhs": {"op": "lit", "value": 1}}},
"max": max,
})
}
fn fanout_wire(join: &str, items: Value) -> Value {
json!({
"kind": "fanout",
"items": {"op": "lit", "value": items},
"bind": {"op": "path", "at": "$.item"},
"body": {"kind": "step", "ref": "upper",
"in": {"op": "path", "at": "$.item"},
"out": {"op": "path", "at": "$.item"}},
"join": join,
"out": {"op": "path", "at": "$.results"},
})
}
fn build_corpus() -> Vec<Case> {
vec![
Case {
name: "step_wiring",
node: node_from(json!({
"kind": "step", "ref": "upper",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.output"},
})),
ctx: json!({"input": "hi"}),
expect: Expect::Ok(Some(json!({"input": "hi", "output": "HI"}))),
},
Case {
name: "step_dispatcher_error",
node: node_from(json!({
"kind": "step", "ref": "fail",
"in": {"op": "lit", "value": null},
"out": {"op": "path", "at": "$.x"},
})),
ctx: json!({}),
expect: Expect::Err,
},
Case {
name: "seq_ordered_writes",
node: node_from(json!({
"kind": "seq",
"children": [
{"kind": "step", "ref": "upper",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.up"}},
{"kind": "assign",
"at": {"op": "path", "at": "$.count"},
"value": {"op": "lit", "value": 1}},
],
})),
ctx: json!({"input": "w"}),
expect: Expect::Ok(Some(json!({"input": "w", "up": "W", "count": 1}))),
},
Case {
name: "branch_then",
node: node_from(branch_wire()),
ctx: json!({"flag": true, "input": "y"}),
expect: Expect::Ok(Some(json!({"flag": true, "input": "y", "result": "Y"}))),
},
Case {
name: "branch_else",
node: node_from(branch_wire()),
ctx: json!({"flag": false}),
expect: Expect::Ok(Some(json!({"flag": false, "result": 1}))),
},
Case {
name: "branch_non_bool_cond",
node: node_from(json!({
"kind": "branch",
"cond": {"op": "lit", "value": "not bool"},
"then": {"kind": "seq", "children": []},
"else": {"kind": "seq", "children": []},
})),
ctx: json!({}),
expect: Expect::Err,
},
Case {
name: "loop_max_cutoff",
node: node_from(loop_wire(json!({"op": "lit", "value": true}), 3)),
ctx: json!({"sum": 0}),
expect: Expect::Ok(Some(json!({"sum": 3.0, "n": 3}))),
},
Case {
name: "loop_cond_false_first",
node: node_from(loop_wire(json!({"op": "lit", "value": false}), 5)),
ctx: json!({"sum": 0}),
expect: Expect::Ok(Some(json!({"sum": 0, "n": 0}))),
},
Case {
name: "try_success_path",
node: node_from(json!({
"kind": "try",
"body": {"kind": "assign",
"at": {"op": "path", "at": "$.x"},
"value": {"op": "lit", "value": 1}},
"catch": {"kind": "assign",
"at": {"op": "path", "at": "$.caught"},
"value": {"op": "lit", "value": true}},
})),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"x": 1}))),
},
Case {
name: "try_rollback_err_at",
node: node_from(json!({
"kind": "try",
"body": {"kind": "seq", "children": [
{"kind": "assign",
"at": {"op": "path", "at": "$.partial"},
"value": {"op": "lit", "value": "should-be-rolled-back"}},
{"kind": "step", "ref": "fail",
"in": {"op": "lit", "value": null},
"out": {"op": "path", "at": "$.never"}},
]},
"catch": {"kind": "assign",
"at": {"op": "path", "at": "$.recovered"},
"value": {"op": "lit", "value": true}},
"err_at": {"op": "path", "at": "$.err"},
})),
ctx: json!({}),
expect: Expect::Ok(None),
},
Case {
name: "assign_basic",
node: node_from(json!({
"kind": "assign",
"at": {"op": "path", "at": "$.x"},
"value": {"op": "lit", "value": 42},
})),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"x": 42}))),
},
Case {
name: "fanout_all_mode",
node: node_from(fanout_wire("all", json!(["a", "b", "c"]))),
ctx: json!({}),
expect: Expect::Ok(Some(json!({
"results": [{"item": "A"}, {"item": "B"}, {"item": "C"}],
}))),
},
Case {
name: "fanout_empty_items_all",
node: node_from(fanout_wire("all", json!([]))),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"results": []}))),
},
Case {
name: "fanout_empty_items_any",
node: node_from(fanout_wire("any", json!([]))),
ctx: json!({}),
expect: Expect::Err,
},
Case {
name: "fanout_empty_items_race",
node: node_from(fanout_wire("race", json!([]))),
ctx: json!({}),
expect: Expect::Err,
},
Case {
name: "fanout_empty_items_all_settled",
node: node_from(fanout_wire("all_settled", json!([]))),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"results": []}))),
},
Case {
name: "race_single_item",
node: node_from(fanout_wire("race", json!(["only"]))),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"results": {"item": "ONLY"}}))),
},
Case {
name: "any_single_item_first_succeeds",
node: node_from(fanout_wire("any", json!(["solo"]))),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"results": {"item": "SOLO"}}))),
},
Case {
name: "nested_composite",
node: node_from(json!({
"kind": "seq",
"children": [
{"kind": "branch",
"cond": {"op": "eq",
"lhs": {"op": "path", "at": "$.flag"},
"rhs": {"op": "lit", "value": true}},
"then": {"kind": "step", "ref": "upper",
"in": {"op": "path", "at": "$.input"},
"out": {"op": "path", "at": "$.branched"}},
"else": {"kind": "assign",
"at": {"op": "path", "at": "$.branched"},
"value": {"op": "lit", "value": "no"}}},
{"kind": "try",
"body": loop_wire(
json!({"op": "lt",
"lhs": {"op": "path", "at": "$.n"},
"rhs": {"op": "lit", "value": 3}}),
5,
),
"catch": {"kind": "assign",
"at": {"op": "path", "at": "$.caught"},
"value": {"op": "lit", "value": true}}},
],
})),
ctx: json!({"flag": true, "input": "go", "sum": 0}),
expect: Expect::Ok(Some(json!({
"flag": true, "input": "go", "sum": 3.0, "branched": "GO", "n": 3,
}))),
},
Case {
name: "expr_parity_eq_add",
node: node_from(json!({
"kind": "seq",
"children": [
{"kind": "step", "ref": "echo",
"in": {"op": "add",
"lhs": {"op": "lit", "value": 2},
"rhs": {"op": "lit", "value": 3}},
"out": {"op": "path", "at": "$.sum_via_step"}},
{"kind": "branch",
"cond": {"op": "eq",
"lhs": {"op": "path", "at": "$.sum_via_step"},
"rhs": {"op": "lit", "value": 5}},
"then": {"kind": "assign",
"at": {"op": "path", "at": "$.route"},
"value": {"op": "lit", "value": "via-eq-add"}},
"else": {"kind": "assign",
"at": {"op": "path", "at": "$.route"},
"value": {"op": "lit", "value": "wrong"}}},
],
})),
ctx: json!({}),
expect: Expect::Ok(Some(json!({"sum_via_step": 5.0, "route": "via-eq-add"}))),
},
]
}
#[tokio::test]
async fn sync_async_parity_corpus() {
let cases = build_corpus();
let mut sync_results: HashMap<&'static str, Value> = HashMap::new();
for case in &cases {
let sync_result = eval(&case.node, case.ctx.clone(), &Fixture);
let async_result = eval_async(&case.node, case.ctx.clone(), &Fixture).await;
match &case.expect {
Expect::Err => {
let sync_err = sync_result.unwrap_err_named(case.name, "sync expected Err, got Ok");
let async_err =
async_result.unwrap_err_named(case.name, "async expected Err, got Ok");
assert_eq!(
sync_err.to_string(),
async_err.to_string(),
"[{}] sync/async error message diverged",
case.name,
);
}
Expect::Ok(expected) => {
let sync_val =
sync_result.unwrap_or_else(|e| panic!("[{}] sync eval failed: {e}", case.name));
let async_val = async_result
.unwrap_or_else(|e| panic!("[{}] async eval failed: {e}", case.name));
assert_eq!(
sync_val, async_val,
"[{}] sync/async result diverged",
case.name,
);
if let Some(expected) = expected {
assert_eq!(
&sync_val, expected,
"[{}] result did not match expected value",
case.name,
);
}
sync_results.insert(case.name, sync_val);
}
}
}
let rollback = &sync_results["try_rollback_err_at"];
assert!(
rollback.get("partial").is_none(),
"Try must roll back writes made before the failing Step: {rollback:?}"
);
assert!(
rollback.get("never").is_none(),
"the failing Step's `out` must never be written: {rollback:?}"
);
assert_eq!(rollback["recovered"], json!(true));
assert!(
rollback["err"].as_str().is_some_and(|s| !s.is_empty()),
"err_at target must hold a non-empty error message: {rollback:?}"
);
}
trait UnwrapErrNamed<E> {
fn unwrap_err_named(self, name: &str, msg: &str) -> E;
}
impl<T: std::fmt::Debug, E> UnwrapErrNamed<E> for Result<T, E> {
fn unwrap_err_named(self, name: &str, msg: &str) -> E {
match self {
Ok(v) => panic!("[{name}] {msg}: got Ok({v:?})"),
Err(e) => e,
}
}
}
struct CountingDispatcher {
counter: Arc<AtomicUsize>,
}
impl Dispatcher for CountingDispatcher {
fn dispatch(&self, _ref_: &str, input: Value) -> Result<Value, EvalError> {
self.counter.fetch_add(1, Ordering::SeqCst);
Ok(input)
}
}
#[async_trait]
impl AsyncDispatcher for CountingDispatcher {
async fn dispatch(&self, _ref_: &str, input: Value) -> Result<Value, EvalError> {
self.counter.fetch_add(1, Ordering::SeqCst);
tokio::task::yield_now().await;
Ok(input)
}
}
#[tokio::test]
async fn fanout_any_multi_item_divergence_sync_short_circuits_async_dispatches_all() {
let node = node_from(fanout_wire("any", json!([1, 2, 3])));
let sync_counter = Arc::new(AtomicUsize::new(0));
let sync_result = eval(
&node,
json!({}),
&CountingDispatcher {
counter: sync_counter.clone(),
},
)
.unwrap();
let sync_count = sync_counter.load(Ordering::SeqCst);
let async_counter = Arc::new(AtomicUsize::new(0));
let async_result = eval_async(
&node,
json!({}),
&CountingDispatcher {
counter: async_counter.clone(),
},
)
.await
.unwrap();
let async_count = async_counter.load(Ordering::SeqCst);
assert!(sync_result["results"].is_object());
assert!(async_result["results"].is_object());
assert!(
sync_count <= async_count,
"sync ({sync_count}) must dispatch no more than async ({async_count})"
);
assert_eq!(
sync_count, 1,
"sync Any short-circuits after the first success"
);
assert_eq!(
async_count, 3,
"async Any dispatches every branch concurrently before selecting a winner"
);
}