1const FLOW_DSL_SRC: &str = include_str!("flow_dsl.lua");
23const BP_DSL_SRC: &str = include_str!("bp_dsl.lua");
24
25const EMPTY_OBJECT_MARKER_KEY: &str = "__mse_empty_object__";
28
29fn replace_empty_object_markers(value: &mut serde_json::Value) {
35 match value {
36 serde_json::Value::Object(map) => {
37 let is_marker = map.len() == 1
38 && map.get(EMPTY_OBJECT_MARKER_KEY) == Some(&serde_json::Value::Bool(true));
39 if is_marker {
40 *value = serde_json::Value::Object(serde_json::Map::new());
41 return;
42 }
43 for v in map.values_mut() {
44 replace_empty_object_markers(v);
45 }
46 }
47 serde_json::Value::Array(arr) => {
48 for v in arr.iter_mut() {
49 replace_empty_object_markers(v);
50 }
51 }
52 _ => {}
53 }
54}
55
56pub fn preload(lua: &mlua::Lua) -> mlua::Result<()> {
61 let package: mlua::Table = lua.globals().get("package")?;
62 let preload: mlua::Table = package.get("preload")?;
63
64 preload.set(
65 "flow_dsl",
66 lua.create_function(|lua, ()| {
67 lua.load(FLOW_DSL_SRC)
68 .set_name("flow_dsl.lua")
69 .eval::<mlua::Value>()
70 })?,
71 )?;
72 preload.set(
73 "bp_dsl",
74 lua.create_function(|lua, ()| {
75 lua.load(BP_DSL_SRC)
76 .set_name("bp_dsl.lua")
77 .eval::<mlua::Value>()
78 })?,
79 )?;
80 Ok(())
81}
82
83pub fn build_bp_from_script(script: &str) -> anyhow::Result<serde_json::Value> {
100 use mlua::LuaSerdeExt;
101
102 let lua = mlua::Lua::new();
106 preload(&lua).map_err(|e| anyhow::anyhow!("dsl preload failed: {e}"))?;
107 let result: mlua::Value = lua
108 .load(script)
109 .set_name("<bp-script>")
110 .eval()
111 .map_err(|e| anyhow::anyhow!("bp-script eval failed: {e}"))?;
112 let options = mlua::serde::de::Options::new().encode_empty_tables_as_array(true);
113 let mut value: serde_json::Value = lua
114 .from_value_with(result, options)
115 .map_err(|e| anyhow::anyhow!("lua value -> json conversion failed: {e}"))?;
116 replace_empty_object_markers(&mut value);
117 Ok(value)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn preload_exposes_flow_dsl_and_bp_dsl() {
126 let lua = mlua::Lua::new();
127 preload(&lua).expect("preload must succeed");
128 let ok: bool = lua
129 .load(
130 r#"
131 local F = require("flow_dsl")
132 local B = require("bp_dsl")
133 return F ~= nil and B ~= nil
134 "#,
135 )
136 .eval()
137 .expect("require must succeed for both modules");
138 assert!(ok, "flow_dsl / bp_dsl must both resolve via require()");
139 }
140
141 #[test]
142 fn build_bp_from_script_returns_json_value() {
143 let out = build_bp_from_script(
144 r#"
145 local F = require("flow_dsl")
146 return { id = "t", flow = F.assign{ at = F.p("$.x"), value = F.lit(1) } }
147 "#,
148 )
149 .expect("script must build");
150 assert_eq!(out["id"], serde_json::json!("t"));
151 assert_eq!(out["flow"]["kind"], serde_json::json!("assign"));
152 assert_eq!(
153 out["flow"]["at"],
154 serde_json::json!({"op": "path", "at": "$.x"})
155 );
156 }
157
158 #[test]
159 fn build_bp_from_script_surfaces_lua_errors() {
160 let err = build_bp_from_script("error(\"boom\")").expect_err("must propagate the error");
161 assert!(err.to_string().contains("boom"));
162 }
163
164 #[test]
165 fn f_obj_marker_becomes_a_genuine_empty_json_object() {
166 let out = build_bp_from_script(
167 r#"
168 local F = require("flow_dsl")
169 return { spec = F.obj(), other = {} }
170 "#,
171 )
172 .expect("script must build");
173 assert_eq!(out["spec"], serde_json::json!({}));
174 assert!(
175 out["spec"].is_object(),
176 "F.obj() must become an object, not an array"
177 );
178 assert_eq!(out["other"], serde_json::json!([]));
183 }
184
185 #[test]
191 fn bp_dsl_skip_on_compiles_to_branch_in_verdict_skip_on_list() {
192 let out = build_bp_from_script(
193 r#"
194 local F = require("flow_dsl")
195 local B = require("bp_dsl")
196 return B.pipeline({
197 B.stage "gate" { agent = "mock-gate" },
198 B.stage "worker" {
199 agent = "mock-worker",
200 input = B.from "gate",
201 skip_on = { "SKIP", "NOT_APPLICABLE" },
202 },
203 halted_at = "$.halted_at",
204 })
205 "#,
206 )
207 .expect("skip_on pipeline must build");
208
209 assert_eq!(out["kind"], serde_json::json!("seq"));
211 let top_children = out["children"].as_array().expect("top seq children");
212 assert_eq!(top_children.len(), 2);
213 assert_eq!(top_children[0]["kind"], serde_json::json!("step"));
214 assert_eq!(top_children[0]["ref"], serde_json::json!("mock-gate"));
215
216 let rest = &top_children[1];
219 assert_eq!(rest["kind"], serde_json::json!("seq"));
220 let rest_children = rest["children"].as_array().expect("rest seq children");
221 let worker_guarded = &rest_children[0];
223 assert_eq!(worker_guarded["kind"], serde_json::json!("branch"));
224
225 let cond = &worker_guarded["cond"];
228 assert_eq!(cond["op"], serde_json::json!("in"));
229 assert_eq!(
230 cond["needle"],
231 serde_json::json!({"op": "path", "at": "$.gate.parts[\"verdict\"]"})
232 );
233 assert_eq!(cond["haystack"]["op"], serde_json::json!("lit"));
234 assert_eq!(
235 cond["haystack"]["value"],
236 serde_json::json!(["SKIP", "NOT_APPLICABLE"])
237 );
238
239 assert_eq!(
241 worker_guarded["then"],
242 serde_json::json!({"kind": "seq", "children": []})
243 );
244
245 let body = &worker_guarded["else"];
248 assert_eq!(body["kind"], serde_json::json!("seq"));
249 assert_eq!(body["children"][0]["ref"], serde_json::json!("mock-worker"));
250 }
251
252 #[test]
259 fn bp_dsl_skip_on_coexists_with_halt_on() {
260 let out = build_bp_from_script(
261 r#"
262 local F = require("flow_dsl")
263 local B = require("bp_dsl")
264 return B.pipeline({
265 B.stage "planner" { agent = "mock-planner" },
266 B.stage "worker" {
267 agent = "mock-worker",
268 input = B.from "planner",
269 skip_on = { "SKIP" },
270 halt_on = { "BLOCKED" },
271 },
272 B.stage "publisher" { agent = "mock-publisher" },
273 halted_at = "$.halted_at",
274 })
275 "#,
276 )
277 .expect("skip_on + halt_on pipeline must build");
278
279 let rest = &out["children"][1];
283 let worker_seq = rest;
284 assert_eq!(worker_seq["kind"], serde_json::json!("seq"));
285 let worker_children = worker_seq["children"]
286 .as_array()
287 .expect("worker seq children");
288 assert_eq!(
289 worker_children.len(),
290 2,
291 "skip guard + halt_on gate (with publisher threaded into gate else)"
292 );
293
294 let skip_branch = &worker_children[0];
296 assert_eq!(skip_branch["kind"], serde_json::json!("branch"));
297 assert_eq!(skip_branch["cond"]["op"], serde_json::json!("in"));
298
299 let halt_gate = &worker_children[1];
302 assert_eq!(halt_gate["kind"], serde_json::json!("branch"));
303 assert_eq!(halt_gate["cond"]["op"], serde_json::json!("eq"));
304 assert_eq!(
305 halt_gate["cond"]["lhs"],
306 serde_json::json!({"op": "path", "at": "$.worker.parts[\"verdict\"]"})
307 );
308 let gate_else = &halt_gate["else"];
310 assert_eq!(gate_else["kind"], serde_json::json!("seq"));
311 let contains_publisher = gate_else["children"]
313 .as_array()
314 .map(|arr| {
315 arr.iter()
316 .any(|c| c["ref"] == serde_json::json!("mock-publisher"))
317 })
318 .unwrap_or(false);
319 assert!(
320 contains_publisher,
321 "halt_on gate else must thread the publisher stage through: {gate_else}"
322 );
323 }
324
325 #[test]
329 fn bp_dsl_skip_on_empty_list_is_noop() {
330 let with_empty = build_bp_from_script(
331 r#"
332 local B = require("bp_dsl")
333 return B.pipeline({
334 B.stage "worker" { agent = "mock-worker", skip_on = {} },
335 halted_at = "$.halted_at",
336 })
337 "#,
338 )
339 .expect("skip_on={} pipeline must build");
340
341 let children = with_empty["children"].as_array().expect("seq children");
344 assert_eq!(
345 children.len(),
346 2,
347 "no skip guard emitted for empty skip_on: {with_empty}"
348 );
349 assert_eq!(children[0]["kind"], serde_json::json!("step"));
350 assert_eq!(children[0]["ref"], serde_json::json!("mock-worker"));
351
352 let baseline = build_bp_from_script(
354 r#"
355 local B = require("bp_dsl")
356 return B.pipeline({
357 B.stage "worker" { agent = "mock-worker" },
358 halted_at = "$.halted_at",
359 })
360 "#,
361 )
362 .expect("baseline pipeline must build");
363 assert_eq!(with_empty, baseline, "skip_on = {{}} must be a no-op");
364 }
365
366 #[test]
367 fn empty_object_marker_replacement_does_not_misfire_on_ordinary_data() {
368 let out = build_bp_from_script(
372 r#"
373 return {
374 a = { __mse_empty_object__ = false },
375 b = { __mse_empty_object__ = true, extra = 1 },
376 }
377 "#,
378 )
379 .expect("script must build");
380 assert_eq!(out["a"], serde_json::json!({"__mse_empty_object__": false}));
381 assert_eq!(
382 out["b"],
383 serde_json::json!({"__mse_empty_object__": true, "extra": 1})
384 );
385 }
386}