1use etdl_parser::ast::{BackoffStrategy, Condition, EtlDocument, EventTree, Node};
2use etdl_parser::asyncapi::AsyncApiRegistry;
3use etdl_parser::ecel;
4use std::collections::BTreeMap;
5
6use crate::validate::Diagnostic;
7
8use super::{CodeGenerator, GeneratedFile};
9
10pub struct RustCodeGenerator {
11 pub version: String,
12}
13
14struct CodegenCtx<'a> {
24 doc: &'a EtlDocument,
25 fault_tree_probs: &'a BTreeMap<String, f64>,
26 registry: &'a AsyncApiRegistry,
27 message_ref: &'a etdl_parser::ast::MessageRef,
28}
29
30impl Default for RustCodeGenerator {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl RustCodeGenerator {
37 pub fn new() -> Self {
38 RustCodeGenerator {
39 version: env!("CARGO_PKG_VERSION").to_string(),
40 }
41 }
42}
43
44impl CodeGenerator for RustCodeGenerator {
45 fn target_name(&self) -> &'static str {
46 "rust"
47 }
48
49 fn generate_all(
50 &self,
51 doc: &EtlDocument,
52 fault_tree_probs: &BTreeMap<String, f64>,
53 registry: &AsyncApiRegistry,
54 stem: &str,
55 _diagnostics: &mut Vec<Diagnostic>,
56 ) -> Result<Vec<GeneratedFile>, String> {
57 let mut output = String::new();
58
59 output.push_str(&format!(
60 "// AUTOGENERATED BY ETDL COMPILER v{} - DO NOT EDIT DIRECTLY\n\n",
61 self.version
62 ));
63
64 let imports = collect_imports(doc);
65 output.push_str(&imports);
66 output.push('\n');
67
68 let inline_types = generate_inline_message_types(doc);
69 if !inline_types.is_empty() {
70 output.push_str(&inline_types);
71 }
72
73 let constants = generate_fault_tree_constants(doc, fault_tree_probs);
74 output.push_str(&constants);
75
76 for tree in doc.event_trees.values() {
77 let ctx = CodegenCtx {
78 doc,
79 fault_tree_probs,
80 registry,
81 message_ref: &tree.initiating_event.message,
82 };
83 let handler_code = generate_event_tree_handler(&ctx, tree)?;
84 output.push_str(&handler_code);
85 output.push('\n');
86 }
87
88 Ok(vec![GeneratedFile::new(format!("{stem}.rs"), output)])
89 }
90}
91
92fn collect_imports(doc: &EtlDocument) -> String {
93 let mut imports = String::from(
94 "use std::time::Duration;\n\
95 use etdl_core::BranchMonitor;\n\
96 use etdl_core::condition::{contains, matches};\n\
97 use etdl_core::publisher::Publisher;\n\
98 use etdl_core::retry::{RetryPolicy, BackoffStrategy};\n\
99 use etdl_core::WorkflowError;\n\
100 use serde::Serialize;\n",
101 );
102
103 let mut alias_set: BTreeMap<String, bool> = BTreeMap::new();
104
105 for tree in doc.event_trees.values() {
106 if let etdl_parser::ast::MessageRef::External(ref ext_ref) = tree.initiating_event.message
107 {
108 alias_set.insert(ext_ref.alias.clone(), true);
109 }
110
111 for node in tree.nodes.values() {
112 match node {
113 Node::Operation(op) => {
114 if let Some(etdl_parser::ast::MessageRef::External(ref ext_ref)) = op.emits {
115 alias_set.insert(ext_ref.alias.clone(), true);
116 }
117 }
118 Node::Consequence(cons) => {
119 if let Some(etdl_parser::ast::ChannelRef::External(ref ext_ref)) =
120 cons.channel
121 {
122 alias_set.insert(ext_ref.alias.clone(), true);
123 }
124 if let Some(etdl_parser::ast::MessageRef::External(ref ext_ref)) =
125 cons.message
126 {
127 alias_set.insert(ext_ref.alias.clone(), true);
128 }
129 }
130 _ => {}
131 }
132 }
133 }
134
135 for alias in alias_set.keys() {
136 let mod_name = alias.replace('-', "_");
137 imports.push_str(&format!("use {}::messages::*;\n", mod_name));
138 }
139
140 imports
141}
142
143fn generate_fault_tree_constants(
144 doc: &EtlDocument,
145 fault_tree_probs: &BTreeMap<String, f64>,
146) -> String {
147 let mut output = String::new();
148
149 for tree in doc.event_trees.values() {
150 for (node_id, node) in &tree.nodes {
151 if let Node::Operation(op) = node {
152 if let Some(ref ps) = op.on_failure_probability_source {
153 if let Some((ft_id, prob)) = find_fault_tree_prob(ps, fault_tree_probs) {
154 output.push_str(&format!(
155 "// Computed from faultTrees.{}.topEvent at build time (Section 5.16)\n",
156 ft_id
157 ));
158 let const_name =
159 to_upper_snake(&format!("{}_failure_probability", node_id));
160 output.push_str(&format!("const {}: f64 = {:.6};\n\n", const_name, prob));
161 }
162 }
163 }
164 }
165 }
166
167 output
168}
169
170fn find_fault_tree_prob(
174 ps: &etdl_parser::ast::InternalRef,
175 fault_tree_probs: &BTreeMap<String, f64>,
176) -> Option<(String, f64)> {
177 let ft_id = extract_ft_id(&ps.pointer);
178 fault_tree_probs.get(&ft_id).map(|&v| (ft_id.clone(), v))
179}
180
181fn generate_event_tree_handler(ctx: &CodegenCtx, tree: &EventTree) -> Result<String, String> {
182 let mut output = String::new();
183
184 let fn_name = format!("handle_{}", to_snake_case(&tree.initiating_event.id));
185 let message_type = ref_to_rust_type(&tree.initiating_event.message);
186
187 output.push_str(&format!(
188 "pub async fn {}(message: {}, publisher: &dyn Publisher) -> Result<(), WorkflowError> {{\n",
189 fn_name, message_type
190 ));
191
192 let first_barrier = find_first_barrier(tree);
193 let monitor_name = first_barrier
194 .map(to_snake_case)
195 .unwrap_or_else(|| "monitor".to_string());
196
197 output.push_str(&format!(
198 " let mut {} = BranchMonitor::new(\"{}\");\n\n",
199 monitor_name,
200 first_barrier.unwrap_or("root")
201 ));
202
203 let start_node_id = &tree.initiating_event.next;
204 let body = generate_node_code(ctx, tree, start_node_id, 1, &monitor_name)?;
205 output.push_str(&body);
206
207 output.push_str(" Ok(())\n");
208 output.push_str("}\n");
209
210 Ok(output)
211}
212
213fn find_first_barrier(tree: &EventTree) -> Option<&str> {
214 let mut current = &tree.initiating_event.next;
215 loop {
216 match tree.nodes.get(current.as_str()) {
217 Some(Node::Barrier(_)) => return Some(current.as_str()),
218 Some(Node::Operation(op)) => {
219 current = &op.next;
220 }
221 Some(Node::Consequence(_)) => return None,
222 None => return None,
223 }
224 }
225}
226
227fn generate_node_code(
228 ctx: &CodegenCtx,
229 tree: &EventTree,
230 node_id: &str,
231 depth: usize,
232 monitor_name: &str,
233) -> Result<String, String> {
234 let node = tree
235 .nodes
236 .get(node_id)
237 .ok_or_else(|| format!("node '{}' not found", node_id))?;
238
239 match node {
240 Node::Barrier(barrier) => {
241 generate_barrier_code(ctx, tree, node_id, barrier, depth, monitor_name)
242 }
243 Node::Operation(op) => generate_operation_code(ctx, tree, node_id, op, depth, monitor_name),
244 Node::Consequence(cons) => generate_consequence_code(cons, depth),
245 }
246}
247
248fn generate_barrier_code(
249 ctx: &CodegenCtx,
250 tree: &EventTree,
251 node_id: &str,
252 barrier: &etdl_parser::ast::Barrier,
253 depth: usize,
254 monitor_name: &str,
255) -> Result<String, String> {
256 let indent = " ".repeat(depth);
257 let mut output = String::new();
258
259 for (i, branch) in barrier.branches.iter().enumerate() {
260 if i == 0 {
261 if branch.condition == Condition::Default {
262 let prob = get_branch_prob(branch, node_id, ctx.fault_tree_probs);
263 if let Some(p) = prob {
264 output.push_str(&format!(
265 "{} {}.record_branch(\"{}\", {:.6});\n",
266 indent, monitor_name, branch.outcome, p
267 ));
268 }
269 let body = generate_node_code(ctx, tree, &branch.next, depth + 1, monitor_name)?;
270 output.push_str(&body);
271 } else {
272 let cond = condition_to_rust_code(ctx, &branch.condition);
273 output.push_str(&format!("{}if {} {{\n", indent, cond));
274
275 let prob = get_branch_prob(branch, node_id, ctx.fault_tree_probs);
276 if let Some(p) = prob {
277 output.push_str(&format!(
278 "{} {}.record_branch(\"{}\", {:.6});\n",
279 indent, monitor_name, branch.outcome, p
280 ));
281 }
282
283 let body = generate_node_code(ctx, tree, &branch.next, depth + 1, monitor_name)?;
284 output.push_str(&body);
285 output.push_str(&format!("{}}}", indent));
286 }
287 } else if branch.condition == Condition::Default {
288 output.push_str(" else {\n");
289
290 let prob = get_branch_prob(branch, node_id, ctx.fault_tree_probs);
291 if let Some(p) = prob {
292 output.push_str(&format!(
293 "{} {}.record_branch(\"{}\", {:.6});\n",
294 indent, monitor_name, branch.outcome, p
295 ));
296 }
297
298 let body = generate_node_code(ctx, tree, &branch.next, depth + 1, monitor_name)?;
299 output.push_str(&body);
300 output.push_str(&format!("{}}}\n", indent));
301 } else {
302 let cond = condition_to_rust_code(ctx, &branch.condition);
303 output.push_str(&format!(" else if {} {{\n", cond));
304
305 let prob = get_branch_prob(branch, node_id, ctx.fault_tree_probs);
306 if let Some(p) = prob {
307 output.push_str(&format!(
308 "{} {}.record_branch(\"{}\", {:.6});\n",
309 indent, monitor_name, branch.outcome, p
310 ));
311 }
312
313 let body = generate_node_code(ctx, tree, &branch.next, depth + 1, monitor_name)?;
314 output.push_str(&body);
315 output.push_str(&format!("{}}}", indent));
316 }
317 }
318
319 Ok(output)
320}
321
322fn generate_operation_code(
323 ctx: &CodegenCtx,
324 tree: &EventTree,
325 node_id: &str,
326 op: &etdl_parser::ast::Operation,
327 depth: usize,
328 monitor_name: &str,
329) -> Result<String, String> {
330 let indent = " ".repeat(depth);
331 let mut output = String::new();
332
333 let handler_name = to_snake_case(&op.handler);
334 let timeout = op.timeout_ms.unwrap_or(5000);
335
336 if let Some(ref retry) = op.retry_policy {
337 let strategy = match retry
338 .backoff_strategy
339 .as_ref()
340 .unwrap_or(&BackoffStrategy::Fixed)
341 {
342 BackoffStrategy::Exponential => "BackoffStrategy::Exponential",
343 BackoffStrategy::Fixed => "BackoffStrategy::Fixed",
344 };
345 output.push_str(&format!(
346 "{}let retry = RetryPolicy {{\n\
347 {} max_attempts: {},\n\
348 {} backoff_ms: {},\n\
349 {} strategy: {},\n\
350 {}}};\n",
351 indent, indent, retry.max_attempts, indent, retry.backoff_ms, indent, strategy, indent
352 ));
353 output.push_str(&format!(
354 "{}match retry.execute(|| {}(&message), Duration::from_millis({})).await {{\n",
355 indent, handler_name, timeout
356 ));
357 } else {
358 output.push_str(&format!(
359 "{}match {}(&message).await {{\n",
360 indent, handler_name
361 ));
362 }
363
364 output.push_str(&format!("{} Ok(_result) => {{\n", indent));
365
366 if op.on_failure.is_some() {
374 if let Some(ref ps) = op.on_failure_probability_source {
375 let const_name = to_upper_snake(&format!("{}_failure_probability", node_id));
376 let prob_exists = find_fault_tree_prob(ps, ctx.fault_tree_probs).is_some();
377 if prob_exists {
378 output.push_str(&format!(
379 "{} {}.record_success(\"{}\", Some({}));\n",
380 indent, monitor_name, node_id, const_name
381 ));
382 } else {
383 output.push_str(&format!(
384 "{} {}.record_success(\"{}\", None);\n",
385 indent, monitor_name, node_id
386 ));
387 }
388 }
389 }
390
391 let next_node = tree.nodes.get(&op.next);
392 match next_node {
393 Some(Node::Consequence(cons)) => {
394 emit_send(cons, "_result", &indent, &mut output);
395 }
396 Some(_) => {
397 generate_node_code(ctx, tree, &op.next, depth + 2, monitor_name)
398 .map(|body| output.push_str(&body))?;
399 }
400 None => {}
401 }
402
403 output.push_str(&format!("{} }}\n", indent));
404
405 if let Some(ref _on_failure_id) = op.on_failure {
406 output.push_str(&format!("{} Err(err) => {{\n", indent));
407
408 if let Some(ref ps) = op.on_failure_probability_source {
409 let const_name = to_upper_snake(&format!("{}_failure_probability", node_id));
410 let prob_exists = find_fault_tree_prob(ps, ctx.fault_tree_probs).is_some();
411 if prob_exists {
412 output.push_str(&format!(
413 "{} {}.record_failure(\"{}\", &err, Some({}));\n",
414 indent, monitor_name, node_id, const_name
415 ));
416 } else {
417 output.push_str(&format!(
418 "{} {}.record_failure(\"{}\", &err, None);\n",
419 indent, monitor_name, node_id
420 ));
421 }
422 } else {
423 output.push_str(&format!(
424 "{} {}.record_failure(\"{}\", &err, None);\n",
425 indent, monitor_name, node_id
426 ));
427 }
428
429 let on_failure_id = op.on_failure.as_ref().unwrap();
430 match tree.nodes.get(on_failure_id) {
431 Some(Node::Consequence(cons)) => {
432 emit_send(cons, "message", &indent, &mut output);
433 }
434 _ => {
435 generate_node_code(ctx, tree, on_failure_id, depth + 2, monitor_name)
436 .map(|body| output.push_str(&body))?;
437 }
438 }
439
440 output.push_str(&format!("{} }}\n", indent));
441 } else {
442 output.push_str(&format!(
443 "{} Err(err) => return Err(WorkflowError::new(format!(\"{{}}\", err))),\n",
444 indent
445 ));
446 }
447
448 output.push_str(&format!("{}}}\n", indent));
449
450 Ok(output)
451}
452
453fn emit_send(
455 cons: &etdl_parser::ast::Consequence,
456 payload_expr: &str,
457 indent: &str,
458 output: &mut String,
459) {
460 match cons.consequence_operation {
461 etdl_parser::ast::ConsequenceOperation::Send => {
462 if let Some(ref channel_ref) = cons.channel {
463 let channel_name = channel_ref_name(channel_ref);
464 output.push_str(&format!(
465 "{} publisher.publish(\"{}\", &etdl_core::serde_json::to_value({}).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
466 indent, channel_name, payload_expr
467 ));
468 }
469 }
470 etdl_parser::ast::ConsequenceOperation::Terminate => {}
471 }
472}
473
474fn generate_consequence_code(
475 cons: &etdl_parser::ast::Consequence,
476 depth: usize,
477) -> Result<String, String> {
478 let indent = " ".repeat(depth);
479 let mut output = String::new();
480
481 match cons.consequence_operation {
482 etdl_parser::ast::ConsequenceOperation::Send => {
483 if let Some(ref channel_ref) = cons.channel {
484 let channel_name = channel_ref_name(channel_ref);
485 output.push_str(&format!(
486 "{}publisher.publish(\"{}\", &etdl_core::serde_json::to_value(message).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
487 indent, channel_name
488 ));
489 }
490 }
491 etdl_parser::ast::ConsequenceOperation::Terminate => {}
492 }
493
494 if depth == 1 {
495 output.push_str(&format!("{}Ok(())\n", indent));
496 }
497
498 Ok(output)
499}
500
501fn condition_to_rust_code(ctx: &CodegenCtx, condition: &Condition) -> String {
502 match condition {
503 Condition::Default => "true".to_string(),
504 Condition::Expr(expr) => render_bool_expr(ctx, expr),
505 }
506}
507
508fn render_bool_expr(ctx: &CodegenCtx, expr: &ecel::BoolExpr) -> String {
509 use ecel::BoolExpr as B;
510 match expr {
511 B::And(a, b) => format!(
512 "({}) && ({})",
513 render_bool_expr(ctx, a),
514 render_bool_expr(ctx, b)
515 ),
516 B::Or(a, b) => format!(
517 "({}) || ({})",
518 render_bool_expr(ctx, a),
519 render_bool_expr(ctx, b)
520 ),
521 B::Not(a) => format!("!({})", render_bool_expr(ctx, a)),
522 B::Comparison(cmp) => render_comparison(ctx, cmp),
523 B::Quantifier(q) => render_quantifier(ctx, q),
524 B::Defined(path) => render_defined(path),
525 }
526}
527
528enum PathRoot {
532 Payload,
533 Headers,
534}
535
536fn path_root(path: &ecel::PathExpr) -> PathRoot {
537 match path.segments.get(1) {
538 Some(ecel::PathSegment::Field(name)) if name == "headers" => PathRoot::Headers,
539 _ => PathRoot::Payload,
540 }
541}
542
543fn path_has_wildcard(path: &ecel::PathExpr) -> bool {
544 path.segments
545 .iter()
546 .any(|s| matches!(s, ecel::PathSegment::Wildcard))
547}
548
549#[derive(Clone, Copy)]
555enum ScalarHint {
556 Number,
561 NumberF64,
567 String,
568 Bool,
569 Unknown,
570}
571
572fn operand_scalar_hint(operand: &ecel::Operand) -> ScalarHint {
573 use ecel::{Literal as L, ValueExpr as V};
574 match operand {
575 ecel::Operand::Literal(L::Number(_)) => ScalarHint::Number,
576 ecel::Operand::Literal(L::String(_)) => ScalarHint::String,
577 ecel::Operand::Literal(L::Bool(_)) => ScalarHint::Bool,
578 ecel::Operand::Value(V::Number(_)) => ScalarHint::Number,
579 ecel::Operand::Value(V::Add(_, _))
580 | ecel::Operand::Value(V::Sub(_, _))
581 | ecel::Operand::Value(V::Mul(_, _))
582 | ecel::Operand::Value(V::Div(_, _)) => ScalarHint::NumberF64,
583 ecel::Operand::Value(V::Call(func, _)) => match func {
584 ecel::FuncName::Length | ecel::FuncName::Abs => ScalarHint::NumberF64,
585 ecel::FuncName::Lower | ecel::FuncName::Upper => ScalarHint::String,
586 },
587 ecel::Operand::Value(V::Path(path)) if matches!(path_root(path), PathRoot::Headers) => {
588 ScalarHint::Unknown
589 }
590 _ => ScalarHint::Unknown,
591 }
592}
593
594fn render_payload_path(path: &ecel::PathExpr) -> String {
602 let mut out = String::from("message");
603 for seg in path.segments.iter().skip(1) {
604 match seg {
605 ecel::PathSegment::Field(name) => {
606 out.push('.');
607 out.push_str(&to_snake_case(name));
608 }
609 ecel::PathSegment::Wildcard => {}
610 ecel::PathSegment::Index(idx) => out.push_str(&format!("[{}]", idx)),
611 ecel::PathSegment::QuotedKey(name) => out.push_str(&format!("[\"{}\"]", name)),
612 }
613 }
614 out
615}
616
617struct PathParts {
618 path_prefix: String,
619 remaining_path: String,
620}
621
622fn build_payload_path_parts(path: &ecel::PathExpr) -> PathParts {
625 let segments = &path.segments;
626 let mut pre_wildcard = Vec::new();
627 let mut post_wildcard = Vec::new();
628 let mut has_wildcard = false;
629
630 for (i, seg) in segments.iter().enumerate() {
631 if i == 0 {
632 continue;
633 }
634 if has_wildcard {
635 post_wildcard.push(seg.clone());
636 } else if matches!(seg, ecel::PathSegment::Wildcard) {
637 has_wildcard = true;
638 } else {
639 pre_wildcard.push(seg.clone());
640 }
641 }
642
643 let mut prefix = String::from("message");
644 for seg in &pre_wildcard {
645 match seg {
646 ecel::PathSegment::Field(name) => {
647 prefix.push('.');
648 prefix.push_str(&to_snake_case(name));
649 }
650 ecel::PathSegment::Index(idx) => prefix.push_str(&format!("[{}]", idx)),
651 ecel::PathSegment::QuotedKey(name) => prefix.push_str(&format!("[\"{}\"]", name)),
652 _ => {}
653 }
654 }
655
656 let mut suffix = String::new();
657 for seg in &post_wildcard {
658 match seg {
659 ecel::PathSegment::Field(name) => {
660 suffix.push('.');
661 suffix.push_str(&to_snake_case(name));
662 }
663 ecel::PathSegment::Index(idx) => suffix.push_str(&format!("[{}]", idx)),
664 ecel::PathSegment::QuotedKey(name) => suffix.push_str(&format!("[\"{}\"]", name)),
665 _ => {}
666 }
667 }
668
669 PathParts {
670 path_prefix: prefix,
671 remaining_path: suffix,
672 }
673}
674
675fn render_headers_chain(path: &ecel::PathExpr) -> String {
680 let mut expr = String::from("message.headers.as_ref()");
681 for seg in path.segments.iter().skip(2) {
682 let key = match seg {
683 ecel::PathSegment::Field(name) | ecel::PathSegment::QuotedKey(name) => {
684 format!("\"{}\"", name)
685 }
686 ecel::PathSegment::Index(idx) => idx.to_string(),
687 ecel::PathSegment::Wildcard => continue,
688 };
689 expr = format!("{}.and_then(|v| v.get({}).cloned())", expr, key);
690 }
691 expr
692}
693
694fn render_headers_scalar(path: &ecel::PathExpr, hint: ScalarHint) -> String {
699 let chain = render_headers_chain(path);
700 match hint {
701 ScalarHint::Number | ScalarHint::NumberF64 => {
702 format!("({}.and_then(|v| v.as_f64())).unwrap_or(0.0)", chain)
703 }
704 ScalarHint::Bool => format!("({}.and_then(|v| v.as_bool())).unwrap_or(false)", chain),
705 ScalarHint::String | ScalarHint::Unknown => format!(
712 "({}.and_then(|v| v.as_str().map(|s| s.to_string()))).unwrap_or_default()",
713 chain
714 ),
715 }
716}
717
718fn render_headers_null_test(path: &ecel::PathExpr, negate: bool) -> String {
724 let is_null = format!(
725 "({}).map(|v| v.is_null()).unwrap_or(true)",
726 render_headers_chain(path)
727 );
728 if negate {
729 format!("!({})", is_null)
730 } else {
731 is_null
732 }
733}
734
735fn render_defined(path: &ecel::PathExpr) -> String {
744 match path_root(path) {
745 PathRoot::Headers => format!("({}).is_some()", render_headers_chain(path)),
746 PathRoot::Payload => {
747 let mut expr = String::from("serde_json::to_value(&message.payload).ok()");
748 for seg in path.segments.iter().skip(2) {
749 let key = match seg {
750 ecel::PathSegment::Field(name) | ecel::PathSegment::QuotedKey(name) => {
751 format!("\"{}\"", name)
752 }
753 ecel::PathSegment::Index(idx) => idx.to_string(),
754 ecel::PathSegment::Wildcard => continue,
755 };
756 expr = format!("{}.and_then(|v| v.get({}).cloned())", expr, key);
757 }
758 format!("({}).is_some_and(|v| !v.is_null())", expr)
759 }
760 }
761}
762
763fn render_quantifier(ctx: &CodegenCtx, q: &ecel::QuantifierExpr) -> String {
764 if let ecel::Operand::Value(ecel::ValueExpr::Path(path_expr)) = &q.comparison.left {
768 if path_has_wildcard(path_expr) && matches!(path_root(path_expr), PathRoot::Payload) {
769 let parts = build_payload_path_parts(path_expr);
770 let right = render_operand(ctx, &q.comparison.right, ScalarHint::Number);
771 let op = comparator_str(&q.comparison.op);
772 let method = match q.kind {
773 ecel::QuantifierKind::Any => "any",
774 ecel::QuantifierKind::All => "all",
775 };
776 return format!(
777 "{}.iter().{}(|item| item{} {} {})",
778 parts.path_prefix, method, parts.remaining_path, op, right
779 );
780 }
781 }
782 render_comparison(ctx, &q.comparison)
786}
787
788fn render_comparison(ctx: &CodegenCtx, cmp: &ecel::Comparison) -> String {
789 use ecel::Comparator as C;
790
791 if matches!(cmp.op, C::Eq | C::Neq) {
792 if let (ecel::Operand::Value(ecel::ValueExpr::Path(path)), ecel::Operand::Literal(ecel::Literal::Null)) =
793 (&cmp.left, &cmp.right)
794 {
795 if matches!(path_root(path), PathRoot::Headers) {
796 return render_headers_null_test(path, matches!(cmp.op, C::Neq));
797 }
798 }
799 }
800
801 match cmp.op {
802 C::In => {
804 let left = render_operand(ctx, &cmp.left, ScalarHint::Unknown);
805 let right = render_operand(ctx, &cmp.right, ScalarHint::Unknown);
806 format!("etdl_core::condition::contains(&{}, &{})", right, left)
807 }
808 C::Matches => {
809 let left = match &cmp.left {
820 ecel::Operand::Value(ecel::ValueExpr::Path(path)) => {
821 let rendered = match path_root(path) {
822 PathRoot::Payload => render_payload_path(path),
823 PathRoot::Headers => render_headers_scalar(path, ScalarHint::String),
824 };
825 format!("&{}", rendered)
826 }
827 _ => render_operand(ctx, &cmp.left, ScalarHint::String),
828 };
829 let right = render_operand(ctx, &cmp.right, ScalarHint::String);
830 format!("etdl_core::condition::matches({}, {})", left, right)
831 }
832 _ => {
833 if let ecel::Operand::Value(ecel::ValueExpr::Path(path_expr)) = &cmp.left {
834 if path_has_wildcard(path_expr) && matches!(path_root(path_expr), PathRoot::Payload)
835 {
836 let parts = build_payload_path_parts(path_expr);
839 let right = render_operand(ctx, &cmp.right, ScalarHint::Number);
840 let op = comparator_str(&cmp.op);
841 return format!(
842 "{}.iter().all(|item| item{} {} {})",
843 parts.path_prefix, parts.remaining_path, op, right
844 );
845 }
846 }
847 let left_hint = operand_scalar_hint(&cmp.right);
848 let right_hint = operand_scalar_hint(&cmp.left);
849 let l = render_operand(ctx, &cmp.left, left_hint);
850 let r = render_operand(ctx, &cmp.right, right_hint);
851 let op = comparator_str(&cmp.op);
852 format!("{} {} {}", l, op, r)
853 }
854 }
855}
856
857fn render_operand(ctx: &CodegenCtx, operand: &ecel::Operand, hint: ScalarHint) -> String {
858 match operand {
859 ecel::Operand::Literal(ecel::Literal::Number(n)) if matches!(hint, ScalarHint::NumberF64) => {
866 format!("{}f64", n)
867 }
868 ecel::Operand::Value(v) => render_value_expr(ctx, v, hint),
869 ecel::Operand::Literal(lit) => literal_to_val_str(lit),
870 }
871}
872
873fn render_value_expr(ctx: &CodegenCtx, expr: &ecel::ValueExpr, hint: ScalarHint) -> String {
874 use ecel::{FuncName as F, ValueExpr as V};
875 match expr {
876 V::Path(path) => match path_root(path) {
877 PathRoot::Payload => render_payload_path(path),
878 PathRoot::Headers => render_headers_scalar(path, hint),
879 },
880 V::Number(n) if matches!(hint, ScalarHint::NumberF64) => format!("{}f64", n),
881 V::Number(n) => n.to_string(),
882 V::Call(func, arg) => {
883 let arg_hint = match func {
884 F::Abs => ScalarHint::NumberF64,
885 F::Lower | F::Upper => ScalarHint::String,
886 F::Length => ScalarHint::Unknown,
887 };
888 let arg_code = match arg.as_ref() {
894 V::Path(path) if matches!(path_root(path), PathRoot::Payload) => {
895 render_payload_scalar(ctx, path)
896 }
897 _ => render_value_expr(ctx, arg, arg_hint),
898 };
899 match func {
900 F::Length => render_length_call(ctx, arg, &arg_code),
901 F::Abs => format!("({}).abs()", arg_code),
902 F::Lower => format!("({}).to_ascii_lowercase()", arg_code),
903 F::Upper => format!("({}).to_ascii_uppercase()", arg_code),
904 }
905 }
906 V::Add(a, b) => format!("(({}) + ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
907 V::Sub(a, b) => format!("(({}) - ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
908 V::Mul(a, b) => format!("(({}) * ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
909 V::Div(a, b) => format!("(({}) / ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
910 }
911}
912
913fn render_payload_scalar(ctx: &CodegenCtx, path: &ecel::PathExpr) -> String {
926 let base = render_payload_path(path);
927 let is_optional = ctx
928 .registry
929 .is_path_required(ctx.doc, ctx.message_ref, &path.segments)
930 .ok()
931 .flatten()
932 == Some(false);
933 if is_optional {
934 format!("{}.clone().unwrap_or_default()", base)
935 } else {
936 base
937 }
938}
939
940fn as_f64_expr(ctx: &CodegenCtx, expr: &ecel::ValueExpr) -> String {
951 use ecel::ValueExpr as V;
952 match expr {
953 V::Number(n) => format!("{}f64", n),
954 V::Path(path) if matches!(path_root(path), PathRoot::Payload) => {
955 format!("({} as f64)", render_payload_scalar(ctx, path))
956 }
957 V::Path(_) => render_value_expr(ctx, expr, ScalarHint::Number),
958 V::Call(_, _) => render_value_expr(ctx, expr, ScalarHint::Number),
959 V::Add(a, b) => format!("(({}) + ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
960 V::Sub(a, b) => format!("(({}) - ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
961 V::Mul(a, b) => format!("(({}) * ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
962 V::Div(a, b) => format!("(({}) / ({}))", as_f64_expr(ctx, a), as_f64_expr(ctx, b)),
963 }
964}
965
966fn render_length_call(ctx: &CodegenCtx, arg: &ecel::ValueExpr, arg_code: &str) -> String {
972 let is_array = match arg {
973 ecel::ValueExpr::Path(path) if matches!(path_root(path), PathRoot::Payload) => ctx
974 .registry
975 .get_schema_for_message_ref(ctx.doc, ctx.message_ref, &path.segments)
976 .ok()
977 .flatten()
978 .and_then(|schema| schema.get("type").and_then(|t| t.as_str().map(String::from)))
979 .as_deref()
980 == Some("array"),
981 _ => false,
982 };
983 if is_array {
984 format!("({}.len() as f64)", arg_code)
985 } else {
986 format!("({}.chars().count() as f64)", arg_code)
987 }
988}
989
990fn literal_to_val_str(lit: &ecel::Literal) -> String {
991 match lit {
992 ecel::Literal::Number(n) => n.to_string(),
993 ecel::Literal::String(s) => format!("\"{}\"", s),
994 ecel::Literal::Bool(b) => b.to_string(),
995 ecel::Literal::Null => "None".to_string(),
996 ecel::Literal::Array(items) => {
997 let inner: Vec<String> = items.iter().map(literal_to_val_str).collect();
998 format!("vec![{}]", inner.join(", "))
999 }
1000 }
1001}
1002
1003fn comparator_str(op: &ecel::Comparator) -> &str {
1009 match op {
1010 ecel::Comparator::Eq => "==",
1011 ecel::Comparator::Neq => "!=",
1012 ecel::Comparator::Gte => ">=",
1013 ecel::Comparator::Lte => "<=",
1014 ecel::Comparator::Gt => ">",
1015 ecel::Comparator::Lt => "<",
1016 ecel::Comparator::In => "in",
1017 ecel::Comparator::Matches => "matches",
1018 }
1019}
1020
1021fn ref_to_rust_type(msg_ref: &etdl_parser::ast::MessageRef) -> String {
1022 let pointer = match msg_ref {
1023 etdl_parser::ast::MessageRef::External(r) => &r.pointer,
1024 etdl_parser::ast::MessageRef::Internal(r) => &r.pointer,
1025 };
1026 extract_last_segment_str(pointer)
1027}
1028
1029fn channel_ref_name(channel_ref: &etdl_parser::ast::ChannelRef) -> String {
1036 match channel_ref {
1037 etdl_parser::ast::ChannelRef::External(ext_ref) => {
1038 extract_last_segment_str(&ext_ref.pointer)
1039 }
1040 etdl_parser::ast::ChannelRef::Bare(name) => name.clone(),
1041 }
1042}
1043
1044fn extract_last_segment_str(pointer: &str) -> String {
1045 let parts: Vec<&str> = pointer.split('/').collect();
1046 let last = parts.last().unwrap_or(&"Unknown");
1047 to_pascal_case(last)
1048}
1049
1050fn collect_internal_message_ids(doc: &EtlDocument) -> std::collections::BTreeSet<String> {
1056 use etdl_parser::ast::MessageRef;
1057
1058 fn note(msg_ref: &MessageRef, ids: &mut std::collections::BTreeSet<String>) {
1059 if let MessageRef::Internal(int_ref) = msg_ref {
1060 if let Some(id) = int_ref.pointer.strip_prefix("#/components/messages/") {
1061 ids.insert(id.to_string());
1062 }
1063 }
1064 }
1065
1066 let mut ids = std::collections::BTreeSet::new();
1067
1068 for tree in doc.event_trees.values() {
1069 note(&tree.initiating_event.message, &mut ids);
1070
1071 for node in tree.nodes.values() {
1072 match node {
1073 Node::Operation(op) => {
1074 if let Some(ref m) = op.emits {
1075 note(m, &mut ids);
1076 }
1077 }
1078 Node::Consequence(cons) => {
1079 if let Some(ref m) = cons.message {
1080 note(m, &mut ids);
1081 }
1082 }
1083 _ => {}
1084 }
1085 }
1086 }
1087
1088 if let Some(ref fault_trees) = doc.fault_trees {
1089 for ft in fault_trees.values() {
1090 if let Some(ref m) = ft.top_event.message {
1091 note(m, &mut ids);
1092 }
1093 for be in ft.basic_events.values() {
1094 if let Some(ref m) = be.message {
1095 note(m, &mut ids);
1096 }
1097 }
1098 }
1099 }
1100
1101 ids
1102}
1103
1104fn generate_inline_message_types(doc: &EtlDocument) -> String {
1110 let ids = collect_internal_message_ids(doc);
1111 if ids.is_empty() {
1112 return String::new();
1113 }
1114
1115 let messages = doc.components.as_ref().and_then(|c| c.messages.as_ref());
1116
1117 let mut output = String::new();
1118 for id in &ids {
1119 let Some(message) = messages.and_then(|m| m.get(id)) else {
1120 continue;
1121 };
1122 generate_message_envelope(id, message, &mut output);
1123 }
1124 output
1125}
1126
1127fn generate_message_envelope(id: &str, message: &etdl_parser::ast::Message, output: &mut String) {
1136 let type_name = to_pascal_case(id);
1137 let payload_type_name = format!("{}Payload", type_name);
1138 let payload: serde_json::Value =
1139 serde_json::to_value(&message.payload).unwrap_or(serde_json::Value::Null);
1140
1141 let mut nested = String::new();
1142 let payload_rust_type = schema_to_rust_type(&payload_type_name, &payload, &mut nested);
1143 output.push_str(&nested);
1144
1145 output.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
1146 output.push_str(&format!("pub struct {} {{\n", type_name));
1147 output.push_str(&format!(" pub payload: {},\n", payload_rust_type));
1148 output.push_str(" #[serde(default)]\n");
1149 output.push_str(" pub headers: Option<serde_json::Value>,\n");
1150 output.push_str("}\n\n");
1151}
1152
1153fn generate_struct_from_schema(name: &str, schema: &serde_json::Value, output: &mut String) {
1161 let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
1162 output.push_str(&format!(
1163 "#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\npub struct {}(pub serde_json::Value);\n\n",
1164 name
1165 ));
1166 return;
1167 };
1168
1169 let required: std::collections::BTreeSet<&str> = schema
1170 .get("required")
1171 .and_then(|r| r.as_array())
1172 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1173 .unwrap_or_default();
1174
1175 let mut nested = String::new();
1176 let mut fields = String::new();
1177
1178 for (field_name, field_schema) in properties {
1179 let snake = to_snake_case(field_name);
1180 let nested_type_name = format!("{}{}", name, to_pascal_case(field_name));
1181 let rust_type = schema_to_rust_type(&nested_type_name, field_schema, &mut nested);
1182 let is_required = required.contains(field_name.as_str());
1183
1184 if snake != *field_name {
1185 fields.push_str(&format!(" #[serde(rename = \"{}\")]\n", field_name));
1186 }
1187
1188 if is_required {
1189 fields.push_str(&format!(" pub {}: {},\n", snake, rust_type));
1190 } else {
1191 fields.push_str(" #[serde(default)]\n");
1192 fields.push_str(&format!(" pub {}: Option<{}>,\n", snake, rust_type));
1193 }
1194 }
1195
1196 output.push_str(&nested);
1197 output.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
1198 output.push_str(&format!("pub struct {} {{\n", name));
1199 output.push_str(&fields);
1200 output.push_str("}\n\n");
1201}
1202
1203fn schema_to_rust_type(candidate_name: &str, schema: &serde_json::Value, nested: &mut String) -> String {
1208 match schema.get("type").and_then(|t| t.as_str()) {
1209 Some("string") => "String".to_string(),
1210 Some("integer") => "i64".to_string(),
1211 Some("number") => "f64".to_string(),
1212 Some("boolean") => "bool".to_string(),
1213 Some("array") => {
1214 let item_type = match schema.get("items") {
1215 Some(items_schema) => {
1216 schema_to_rust_type(&format!("{}Item", candidate_name), items_schema, nested)
1217 }
1218 None => "serde_json::Value".to_string(),
1219 };
1220 format!("Vec<{}>", item_type)
1221 }
1222 Some("object") => {
1223 if schema.get("properties").is_some() {
1224 generate_struct_from_schema(candidate_name, schema, nested);
1225 candidate_name.to_string()
1226 } else {
1227 "serde_json::Value".to_string()
1228 }
1229 }
1230 _ => "serde_json::Value".to_string(),
1231 }
1232}
1233
1234fn get_branch_prob(
1235 branch: &etdl_parser::ast::Branch,
1236 _node_id: &str,
1237 fault_tree_probs: &BTreeMap<String, f64>,
1238) -> Option<f64> {
1239 if let Some(ref ps) = branch.probability_source {
1240 let ft_id = extract_ft_id(&ps.pointer);
1241 return fault_tree_probs.get(&ft_id).copied();
1242 }
1243 branch.effective_probability()
1244}
1245
1246fn extract_ft_id(pointer: &str) -> String {
1247 pointer
1248 .trim_start_matches("#/faultTrees/")
1249 .trim_end_matches("/topEvent")
1250 .to_string()
1251}
1252
1253fn to_snake_case(s: &str) -> String {
1254 let mut result = String::new();
1255 for (i, c) in s.chars().enumerate() {
1256 if c.is_uppercase() {
1257 if i > 0 {
1258 result.push('_');
1259 }
1260 result.push(c.to_lowercase().next().unwrap());
1261 } else {
1262 result.push(c);
1263 }
1264 }
1265 result
1266}
1267
1268fn to_upper_snake(s: &str) -> String {
1269 let snake = to_snake_case(s);
1270 snake.to_uppercase()
1271}
1272
1273fn to_pascal_case(s: &str) -> String {
1274 let mut result = String::new();
1275 let mut capitalize = true;
1276 for c in s.chars() {
1277 if c == '_' || c == '-' || c == ' ' {
1278 capitalize = true;
1279 } else if capitalize {
1280 result.extend(c.to_uppercase());
1281 capitalize = false;
1282 } else {
1283 result.push(c);
1284 }
1285 }
1286 result
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use etdl_parser::ast::{EtlDocument, InternalRef};
1293
1294 fn parse(yaml: &str) -> EtlDocument {
1295 serde_yaml::from_str(yaml).expect("valid yaml")
1296 }
1297
1298 fn multi_ft_doc() -> EtlDocument {
1299 parse(
1300 r##"
1301etdl: "1.0.0"
1302info:
1303 title: "MultiFT"
1304 version: "1.0.0"
1305 domain: "D"
1306asyncapi_imports: {}
1307eventTrees:
1308 T:
1309 initiatingEvent:
1310 id: I
1311 message: "a#/m"
1312 next: O
1313 nodes:
1314 O:
1315 type: operation
1316 action: execute
1317 handler: "h"
1318 next: C
1319 onFailure: FC
1320 onFailureProbabilitySource: "#/faultTrees/B/topEvent"
1321 C:
1322 type: consequence
1323 operation: terminate
1324 FC:
1325 type: consequence
1326 operation: terminate
1327faultTrees:
1328 A:
1329 topEvent:
1330 id: A1
1331 description: "a"
1332 rootCause: AE
1333 basicEvents:
1334 AE:
1335 description: "ae"
1336 probability: 0.9
1337 B:
1338 topEvent:
1339 id: B1
1340 description: "b"
1341 rootCause: BE
1342 basicEvents:
1343 BE:
1344 description: "be"
1345 probability: 0.01
1346"##,
1347 )
1348 }
1349
1350 #[test]
1351 fn find_fault_tree_prob_selects_by_pointer() {
1352 let doc = multi_ft_doc();
1353 let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
1354 assert_eq!(probs["A"], 0.9);
1355 assert_eq!(probs["B"], 0.01);
1356
1357 let ps = InternalRef {
1359 pointer: "#/faultTrees/B/topEvent".to_string(),
1360 };
1361 let (id, prob) = find_fault_tree_prob(&ps, &probs).expect("resolves");
1362 assert_eq!(id, "B");
1363 assert!((prob - 0.01).abs() < 1e-9);
1364 }
1365
1366 #[test]
1367 fn generated_constants_use_correct_tree() {
1368 let doc = multi_ft_doc();
1369 let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
1370 let constants = generate_fault_tree_constants(&doc, &probs);
1371 assert!(constants.contains("faultTrees.B.topEvent"));
1372 assert!(constants.contains("= 0.010000"));
1373 assert!(!constants.contains("faultTrees.A.topEvent"));
1374 }
1375
1376 fn test_fixture() -> (EtlDocument, BTreeMap<String, f64>, AsyncApiRegistry, etdl_parser::ast::MessageRef)
1382 {
1383 let doc = multi_ft_doc();
1384 let probs = BTreeMap::new();
1385 let registry = AsyncApiRegistry::new();
1386 let message_ref = etdl_parser::ast::MessageRef::Internal(etdl_parser::ast::InternalRef {
1387 pointer: "#/components/messages/Test".to_string(),
1388 });
1389 (doc, probs, registry, message_ref)
1390 }
1391
1392 fn render(condition_src: &str) -> String {
1393 let cond = etdl_parser::ecel::parse_condition(condition_src).unwrap();
1394 let (doc, probs, registry, message_ref) = test_fixture();
1395 let ctx = CodegenCtx {
1396 doc: &doc,
1397 fault_tree_probs: &probs,
1398 registry: ®istry,
1399 message_ref: &message_ref,
1400 };
1401 condition_to_rust_code(&ctx, &cond)
1402 }
1403
1404 #[test]
1405 fn in_operator_lowers_to_contains() {
1406 let code = render("message.payload.status in [\"PAID\", \"AUTHORIZED\"]");
1407 assert!(
1408 code.contains("etdl_core::condition::contains"),
1409 "got: {}",
1410 code
1411 );
1412 assert!(code.contains("\"PAID\""));
1413 }
1414
1415 #[test]
1416 fn matches_operator_lowers_to_regex() {
1417 let code = render("message.payload.reference matches \"^ORD-[0-9]{8}$\"");
1418 assert!(
1419 code.contains("etdl_core::condition::matches"),
1420 "got: {}",
1421 code
1422 );
1423 }
1424
1425 #[test]
1426 fn comparison_emits_valid_rust() {
1427 let code = render("message.payload.amount >= 10000");
1428 assert_eq!(code, "message.payload.amount >= 10000");
1429 }
1430
1431 #[test]
1434 fn and_lowers_to_rust_ampersand() {
1435 let code = render("message.payload.a > 0 && message.payload.b > 0");
1436 assert_eq!(
1437 code,
1438 "(message.payload.a > 0) && (message.payload.b > 0)"
1439 );
1440 }
1441
1442 #[test]
1443 fn or_lowers_to_rust_pipe() {
1444 let code = render("message.payload.a > 0 || message.payload.b > 0");
1445 assert_eq!(
1446 code,
1447 "(message.payload.a > 0) || (message.payload.b > 0)"
1448 );
1449 }
1450
1451 #[test]
1452 fn not_lowers_to_rust_bang() {
1453 let code = render("!(message.payload.ok == true)");
1454 assert_eq!(code, "!(message.payload.ok == true)");
1455 }
1456
1457 #[test]
1460 fn arithmetic_casts_to_f64() {
1461 let code = render("message.payload.subtotal - message.payload.discount > 0");
1462 assert_eq!(
1463 code,
1464 "(((message.payload.subtotal as f64)) - ((message.payload.discount as f64))) > 0f64"
1465 );
1466 }
1467
1468 #[test]
1471 fn abs_lowers_to_method_call() {
1472 let code = render("abs(message.payload.delta) < 1");
1473 assert_eq!(code, "(message.payload.delta).abs() < 1f64");
1474 }
1475
1476 #[test]
1477 fn lower_lowers_to_ascii_method() {
1478 let code = render("lower(message.payload.status) == \"paid\"");
1479 assert_eq!(
1480 code,
1481 "(message.payload.status).to_ascii_lowercase() == \"paid\""
1482 );
1483 }
1484
1485 #[test]
1488 fn defined_on_payload_round_trips_through_json() {
1489 let code = render("defined(message.payload.discountCode)");
1490 assert!(code.contains("serde_json::to_value(&message.payload)"));
1491 assert!(code.contains("\"discountCode\""));
1492 assert!(code.contains("is_some_and"));
1493 }
1494
1495 #[test]
1496 fn defined_on_headers_checks_the_get_chain() {
1497 let code = render("defined(message.headers.traceId)");
1498 assert_eq!(
1499 code,
1500 "(message.headers.as_ref().and_then(|v| v.get(\"traceId\").cloned())).is_some()"
1501 );
1502 }
1503
1504 #[test]
1509 fn headers_comparison_does_not_emit_direct_field_access() {
1510 let code = render("message.headers.traceId != null");
1511 assert!(
1512 !code.contains("message.headers.trace_id"),
1513 "regressed to direct-field-access codegen: {}",
1514 code
1515 );
1516 assert!(code.contains("message.headers.as_ref()"), "got: {}", code);
1517 }
1518
1519 #[test]
1520 fn headers_string_comparison_coerces_via_as_str() {
1521 let code = render("message.headers.apiVersion == \"2\"");
1522 assert!(code.contains(".as_str()"), "got: {}", code);
1523 assert!(code.contains("\"apiVersion\""), "got: {}", code);
1524 }
1525
1526 #[test]
1527 fn headers_null_check_treats_absent_as_null() {
1528 let code = render("message.headers.traceId != null");
1529 assert!(code.contains("is_null()"), "got: {}", code);
1530 assert!(code.starts_with("!("), "expected negated null-test, got: {}", code);
1531 }
1532
1533 #[test]
1536 fn explicit_any_lowers_to_iter_any() {
1537 let code = render("any(message.payload.items, message.payload.items[*].qty > 0)");
1538 assert!(code.contains(".iter().any(|item|"), "got: {}", code);
1539 }
1540
1541 #[test]
1542 fn explicit_all_lowers_to_iter_all() {
1543 let code = render("all(message.payload.items, message.payload.items[*].qty > 0)");
1544 assert!(code.contains(".iter().all(|item|"), "got: {}", code);
1545 }
1546}