Skip to main content

etdl_compiler/codegen/
rust.rs

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
14impl Default for RustCodeGenerator {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl RustCodeGenerator {
21    pub fn new() -> Self {
22        RustCodeGenerator {
23            version: env!("CARGO_PKG_VERSION").to_string(),
24        }
25    }
26}
27
28impl CodeGenerator for RustCodeGenerator {
29    fn target_name(&self) -> &'static str {
30        "rust"
31    }
32
33    fn generate_all(
34        &self,
35        doc: &EtlDocument,
36        fault_tree_probs: &BTreeMap<String, f64>,
37        _registry: &AsyncApiRegistry,
38        stem: &str,
39        _diagnostics: &mut Vec<Diagnostic>,
40    ) -> Result<Vec<GeneratedFile>, String> {
41        let mut output = String::new();
42
43        output.push_str(&format!(
44            "// AUTOGENERATED BY ETDL COMPILER v{} - DO NOT EDIT DIRECTLY\n\n",
45            self.version
46        ));
47
48        let imports = collect_imports(doc);
49        output.push_str(&imports);
50        output.push('\n');
51
52        let inline_types = generate_inline_message_types(doc);
53        if !inline_types.is_empty() {
54            output.push_str(&inline_types);
55        }
56
57        let constants = generate_fault_tree_constants(doc, fault_tree_probs);
58        output.push_str(&constants);
59
60        for tree in doc.event_trees.values() {
61            let handler_code = generate_event_tree_handler(doc, tree, fault_tree_probs)?;
62            output.push_str(&handler_code);
63            output.push('\n');
64        }
65
66        Ok(vec![GeneratedFile::new(format!("{stem}.rs"), output)])
67    }
68}
69
70fn collect_imports(doc: &EtlDocument) -> String {
71    let mut imports = String::from(
72        "use std::time::Duration;\n\
73         use etdl_core::BranchMonitor;\n\
74         use etdl_core::condition::{contains, matches};\n\
75         use etdl_core::publisher::Publisher;\n\
76         use etdl_core::retry::{RetryPolicy, BackoffStrategy};\n\
77         use etdl_core::WorkflowError;\n\
78         use serde::Serialize;\n",
79    );
80
81    let mut alias_set: BTreeMap<String, bool> = BTreeMap::new();
82
83    for tree in doc.event_trees.values() {
84        if let etdl_parser::ast::MessageRef::External(ref ext_ref) = tree.initiating_event.message
85        {
86            alias_set.insert(ext_ref.alias.clone(), true);
87        }
88
89        for node in tree.nodes.values() {
90            match node {
91                Node::Operation(op) => {
92                    if let Some(etdl_parser::ast::MessageRef::External(ref ext_ref)) = op.emits {
93                        alias_set.insert(ext_ref.alias.clone(), true);
94                    }
95                }
96                Node::Consequence(cons) => {
97                    if let Some(etdl_parser::ast::ChannelRef::External(ref ext_ref)) =
98                        cons.channel
99                    {
100                        alias_set.insert(ext_ref.alias.clone(), true);
101                    }
102                    if let Some(etdl_parser::ast::MessageRef::External(ref ext_ref)) =
103                        cons.message
104                    {
105                        alias_set.insert(ext_ref.alias.clone(), true);
106                    }
107                }
108                _ => {}
109            }
110        }
111    }
112
113    for alias in alias_set.keys() {
114        let mod_name = alias.replace('-', "_");
115        imports.push_str(&format!("use {}::messages::*;\n", mod_name));
116    }
117
118    imports
119}
120
121fn generate_fault_tree_constants(
122    doc: &EtlDocument,
123    fault_tree_probs: &BTreeMap<String, f64>,
124) -> String {
125    let mut output = String::new();
126
127    for tree in doc.event_trees.values() {
128        for (node_id, node) in &tree.nodes {
129            if let Node::Operation(op) = node {
130                if let Some(ref ps) = op.on_failure_probability_source {
131                    if let Some((ft_id, prob)) = find_fault_tree_prob(ps, fault_tree_probs) {
132                        output.push_str(&format!(
133                            "// Computed from faultTrees.{}.topEvent at build time (Section 5.16)\n",
134                            ft_id
135                        ));
136                        let const_name =
137                            to_upper_snake(&format!("{}_failure_probability", node_id));
138                        output.push_str(&format!("const {}: f64 = {:.6};\n\n", const_name, prob));
139                    }
140                }
141            }
142        }
143    }
144
145    output
146}
147
148/// Resolve an `onFailureProbabilitySource` internal reference to its fault-tree
149/// top-event probability. The pointer is `#/faultTrees/<id>/topEvent`; the
150/// referenced fault-tree id selects the probability from the resolver output.
151fn find_fault_tree_prob(
152    ps: &etdl_parser::ast::InternalRef,
153    fault_tree_probs: &BTreeMap<String, f64>,
154) -> Option<(String, f64)> {
155    let ft_id = extract_ft_id(&ps.pointer);
156    fault_tree_probs.get(&ft_id).map(|&v| (ft_id.clone(), v))
157}
158
159fn generate_event_tree_handler(
160    doc: &EtlDocument,
161    tree: &EventTree,
162    fault_tree_probs: &BTreeMap<String, f64>,
163) -> Result<String, String> {
164    let mut output = String::new();
165
166    let fn_name = format!("handle_{}", to_snake_case(&tree.initiating_event.id));
167    let message_type = ref_to_rust_type(&tree.initiating_event.message);
168
169    output.push_str(&format!(
170        "pub async fn {}(message: {}, publisher: &dyn Publisher) -> Result<(), WorkflowError> {{\n",
171        fn_name, message_type
172    ));
173
174    let first_barrier = find_first_barrier(tree);
175    let monitor_name = first_barrier
176        .map(to_snake_case)
177        .unwrap_or_else(|| "monitor".to_string());
178
179    output.push_str(&format!(
180        "    let mut {} = BranchMonitor::new(\"{}\");\n\n",
181        monitor_name,
182        first_barrier.unwrap_or("root")
183    ));
184
185    let start_node_id = &tree.initiating_event.next;
186    let body = generate_node_code(doc, tree, start_node_id, 1, fault_tree_probs, &monitor_name)?;
187    output.push_str(&body);
188
189    output.push_str("    Ok(())\n");
190    output.push_str("}\n");
191
192    Ok(output)
193}
194
195fn find_first_barrier(tree: &EventTree) -> Option<&str> {
196    let mut current = &tree.initiating_event.next;
197    loop {
198        match tree.nodes.get(current.as_str()) {
199            Some(Node::Barrier(_)) => return Some(current.as_str()),
200            Some(Node::Operation(op)) => {
201                current = &op.next;
202            }
203            Some(Node::Consequence(_)) => return None,
204            None => return None,
205        }
206    }
207}
208
209fn generate_node_code(
210    doc: &EtlDocument,
211    tree: &EventTree,
212    node_id: &str,
213    depth: usize,
214    fault_tree_probs: &BTreeMap<String, f64>,
215    monitor_name: &str,
216) -> Result<String, String> {
217    let node = tree
218        .nodes
219        .get(node_id)
220        .ok_or_else(|| format!("node '{}' not found", node_id))?;
221
222    match node {
223        Node::Barrier(barrier) => generate_barrier_code(
224            tree,
225            node_id,
226            barrier,
227            depth,
228            doc,
229            fault_tree_probs,
230            monitor_name,
231        ),
232        Node::Operation(op) => generate_operation_code(
233            tree,
234            node_id,
235            op,
236            depth,
237            doc,
238            fault_tree_probs,
239            monitor_name,
240        ),
241        Node::Consequence(cons) => generate_consequence_code(cons, depth),
242    }
243}
244
245fn generate_barrier_code(
246    tree: &EventTree,
247    node_id: &str,
248    barrier: &etdl_parser::ast::Barrier,
249    depth: usize,
250    doc: &EtlDocument,
251    fault_tree_probs: &BTreeMap<String, f64>,
252    monitor_name: &str,
253) -> Result<String, String> {
254    let indent = "    ".repeat(depth);
255    let mut output = String::new();
256
257    for (i, branch) in barrier.branches.iter().enumerate() {
258        if i == 0 {
259            if branch.condition == Condition::Default {
260                let prob = get_branch_prob(branch, node_id, fault_tree_probs);
261                if let Some(p) = prob {
262                    output.push_str(&format!(
263                        "{}    {}.record_branch(\"{}\", {:.6});\n",
264                        indent, monitor_name, branch.outcome, p
265                    ));
266                }
267                let body = generate_node_code(
268                    doc,
269                    tree,
270                    &branch.next,
271                    depth + 1,
272                    fault_tree_probs,
273                    monitor_name,
274                )?;
275                output.push_str(&body);
276            } else {
277                let cond = condition_to_rust_code(&branch.condition);
278                output.push_str(&format!("{}if {} {{\n", indent, cond));
279
280                let prob = get_branch_prob(branch, node_id, fault_tree_probs);
281                if let Some(p) = prob {
282                    output.push_str(&format!(
283                        "{}    {}.record_branch(\"{}\", {:.6});\n",
284                        indent, monitor_name, branch.outcome, p
285                    ));
286                }
287
288                let body = generate_node_code(
289                    doc,
290                    tree,
291                    &branch.next,
292                    depth + 1,
293                    fault_tree_probs,
294                    monitor_name,
295                )?;
296                output.push_str(&body);
297                output.push_str(&format!("{}}}", indent));
298            }
299        } else if branch.condition == Condition::Default {
300            output.push_str(" else {\n");
301
302            let prob = get_branch_prob(branch, node_id, fault_tree_probs);
303            if let Some(p) = prob {
304                output.push_str(&format!(
305                    "{}    {}.record_branch(\"{}\", {:.6});\n",
306                    indent, monitor_name, branch.outcome, p
307                ));
308            }
309
310            let body = generate_node_code(
311                doc,
312                tree,
313                &branch.next,
314                depth + 1,
315                fault_tree_probs,
316                monitor_name,
317            )?;
318            output.push_str(&body);
319            output.push_str(&format!("{}}}\n", indent));
320        } else {
321            let cond = condition_to_rust_code(&branch.condition);
322            output.push_str(&format!(" else if {} {{\n", cond));
323
324            let prob = get_branch_prob(branch, node_id, fault_tree_probs);
325            if let Some(p) = prob {
326                output.push_str(&format!(
327                    "{}    {}.record_branch(\"{}\", {:.6});\n",
328                    indent, monitor_name, branch.outcome, p
329                ));
330            }
331
332            let body = generate_node_code(
333                doc,
334                tree,
335                &branch.next,
336                depth + 1,
337                fault_tree_probs,
338                monitor_name,
339            )?;
340            output.push_str(&body);
341            output.push_str(&format!("{}}}", indent));
342        }
343    }
344
345    Ok(output)
346}
347
348fn generate_operation_code(
349    tree: &EventTree,
350    node_id: &str,
351    op: &etdl_parser::ast::Operation,
352    depth: usize,
353    doc: &EtlDocument,
354    fault_tree_probs: &BTreeMap<String, f64>,
355    monitor_name: &str,
356) -> Result<String, String> {
357    let indent = "    ".repeat(depth);
358    let mut output = String::new();
359
360    let handler_name = to_snake_case(&op.handler);
361    let timeout = op.timeout_ms.unwrap_or(5000);
362
363    if let Some(ref retry) = op.retry_policy {
364        let strategy = match retry
365            .backoff_strategy
366            .as_ref()
367            .unwrap_or(&BackoffStrategy::Fixed)
368        {
369            BackoffStrategy::Exponential => "BackoffStrategy::Exponential",
370            BackoffStrategy::Fixed => "BackoffStrategy::Fixed",
371        };
372        output.push_str(&format!(
373            "{}let retry = RetryPolicy {{\n\
374             {}    max_attempts: {},\n\
375             {}    backoff_ms: {},\n\
376             {}    strategy: {},\n\
377             {}}};\n",
378            indent, indent, retry.max_attempts, indent, retry.backoff_ms, indent, strategy, indent
379        ));
380        output.push_str(&format!(
381            "{}match retry.execute(|| {}(&message), Duration::from_millis({})).await {{\n",
382            indent, handler_name, timeout
383        ));
384    } else {
385        output.push_str(&format!(
386            "{}match {}(&message).await {{\n",
387            indent, handler_name
388        ));
389    }
390
391    output.push_str(&format!("{}    Ok(_result) => {{\n", indent));
392
393    // Record a success on the SAME SLA key `record_failure` (in the `Err`
394    // arm below) uses, so that key's rolling window sees both outcomes
395    // instead of only ever seeing failures — an operation that failed
396    // `sla::MIN_OBSERVATIONS` times over its lifetime otherwise triggered
397    // an unconditional false SLA anomaly regardless of its actual overall
398    // failure rate (observed frequency was permanently 1.0). See
399    // `BranchMonitor::record_success`'s doc comment.
400    if op.on_failure.is_some() {
401        if let Some(ref ps) = op.on_failure_probability_source {
402            let const_name = to_upper_snake(&format!("{}_failure_probability", node_id));
403            let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
404            if prob_exists {
405                output.push_str(&format!(
406                    "{}        {}.record_success(\"{}\", Some({}));\n",
407                    indent, monitor_name, node_id, const_name
408                ));
409            } else {
410                output.push_str(&format!(
411                    "{}        {}.record_success(\"{}\", None);\n",
412                    indent, monitor_name, node_id
413                ));
414            }
415        }
416    }
417
418    let next_node = tree.nodes.get(&op.next);
419    match next_node {
420        Some(Node::Consequence(cons)) => {
421            emit_send(cons, "_result", &indent, &mut output);
422        }
423        Some(_) => {
424            generate_node_code(
425                doc,
426                tree,
427                &op.next,
428                depth + 2,
429                fault_tree_probs,
430                monitor_name,
431            )
432            .map(|body| output.push_str(&body))?;
433        }
434        None => {}
435    }
436
437    output.push_str(&format!("{}    }}\n", indent));
438
439    if let Some(ref _on_failure_id) = op.on_failure {
440        output.push_str(&format!("{}    Err(err) => {{\n", indent));
441
442        if let Some(ref ps) = op.on_failure_probability_source {
443            let const_name = to_upper_snake(&format!("{}_failure_probability", node_id));
444            let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
445            if prob_exists {
446                output.push_str(&format!(
447                    "{}        {}.record_failure(\"{}\", &err, Some({}));\n",
448                    indent, monitor_name, node_id, const_name
449                ));
450            } else {
451                output.push_str(&format!(
452                    "{}        {}.record_failure(\"{}\", &err, None);\n",
453                    indent, monitor_name, node_id
454                ));
455            }
456        } else {
457            output.push_str(&format!(
458                "{}        {}.record_failure(\"{}\", &err, None);\n",
459                indent, monitor_name, node_id
460            ));
461        }
462
463        let on_failure_id = op.on_failure.as_ref().unwrap();
464        match tree.nodes.get(on_failure_id) {
465            Some(Node::Consequence(cons)) => {
466                emit_send(cons, "message", &indent, &mut output);
467            }
468            _ => {
469                generate_node_code(
470                    doc,
471                    tree,
472                    on_failure_id,
473                    depth + 2,
474                    fault_tree_probs,
475                    monitor_name,
476                )
477                .map(|body| output.push_str(&body))?;
478            }
479        }
480
481        output.push_str(&format!("{}    }}\n", indent));
482    } else {
483        output.push_str(&format!(
484            "{}    Err(err) => return Err(WorkflowError::new(format!(\"{{}}\", err))),\n",
485            indent
486        ));
487    }
488
489    output.push_str(&format!("{}}}\n", indent));
490
491    Ok(output)
492}
493
494/// Emit a `publisher.publish(...)` call for a `send` consequence.
495fn emit_send(
496    cons: &etdl_parser::ast::Consequence,
497    payload_expr: &str,
498    indent: &str,
499    output: &mut String,
500) {
501    match cons.consequence_operation {
502        etdl_parser::ast::ConsequenceOperation::Send => {
503            if let Some(ref channel_ref) = cons.channel {
504                let channel_name = channel_ref_name(channel_ref);
505                output.push_str(&format!(
506                    "{}        publisher.publish(\"{}\", &etdl_core::serde_json::to_value({}).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
507                    indent, channel_name, payload_expr
508                ));
509            }
510        }
511        etdl_parser::ast::ConsequenceOperation::Terminate => {}
512    }
513}
514
515fn generate_consequence_code(
516    cons: &etdl_parser::ast::Consequence,
517    depth: usize,
518) -> Result<String, String> {
519    let indent = "    ".repeat(depth);
520    let mut output = String::new();
521
522    match cons.consequence_operation {
523        etdl_parser::ast::ConsequenceOperation::Send => {
524            if let Some(ref channel_ref) = cons.channel {
525                let channel_name = channel_ref_name(channel_ref);
526                output.push_str(&format!(
527                    "{}publisher.publish(\"{}\", &etdl_core::serde_json::to_value(message).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
528                    indent, channel_name
529                ));
530            }
531        }
532        etdl_parser::ast::ConsequenceOperation::Terminate => {}
533    }
534
535    if depth == 1 {
536        output.push_str(&format!("{}Ok(())\n", indent));
537    }
538
539    Ok(output)
540}
541
542fn condition_to_rust_code(condition: &Condition) -> String {
543    match condition {
544        Condition::Default => "true".to_string(),
545        Condition::Comparison(cmp) => {
546            use ecel::Comparator as C;
547            match cmp.op {
548                // `in` and `matches` lower to etdl_core::condition helpers.
549                C::In => {
550                    let left = path_or_literal(&cmp.left);
551                    let right = array_or_literal(&cmp.right);
552                    format!("etdl_core::condition::contains(&{}, &{})", right, left)
553                }
554                C::Matches => {
555                    // `etdl_core::condition::matches` takes `value: &str`.
556                    // A path operand renders as a bare owned-field
557                    // expression (e.g. `message.payload.status`, typically
558                    // `String`); it must be borrowed (`&message.payload.
559                    // status`) so `&String -> &str` deref coercion applies
560                    // at the call site — passing it unborrowed was a type
561                    // error (`expected &str, found String`). A literal
562                    // operand (`ECEL §6.5` says the left side of `matches`
563                    // should be a path, but codegen stays defensive here)
564                    // already renders as a `&'static str` literal and must
565                    // NOT be re-borrowed.
566                    let left = match &cmp.left {
567                        ecel::Operand::Path(_) => format!("&{}", operand_to_path_expr(&cmp.left)),
568                        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
569                    };
570                    let right = literal_to_val_str(&literal_of(&cmp.right));
571                    format!("etdl_core::condition::matches({}, {})", left, right)
572                }
573                _ => {
574                    let (left_path, has_wildcard) = build_path_expression(&cmp.left);
575                    let right = operand_to_val_str(&cmp.right);
576                    let op = comparator_str(&cmp.op);
577
578                    if has_wildcard {
579                        // Wildcard path on the left: quantify over the array.
580                        format!(
581                            "{}.iter().all(|item| item{} {} {})",
582                            left_path.path_prefix, left_path.remaining_path, op, right
583                        )
584                    } else {
585                        let l = if left_path.path_prefix.is_empty()
586                            && left_path.remaining_path.is_empty()
587                        {
588                            operand_to_val_str(&cmp.left)
589                        } else {
590                            left_path.path_prefix + &left_path.remaining_path
591                        };
592                        let r = if right.is_empty() {
593                            operand_to_path_expr(&cmp.right)
594                        } else {
595                            right
596                        };
597                        format!("{} {} {}", l, op, r)
598                    }
599                }
600            }
601        }
602    }
603}
604
605/// Render a path operand (or literal) as an expression usable as the left side
606/// of a helper call.
607fn path_or_literal(operand: &ecel::Operand) -> String {
608    match operand {
609        ecel::Operand::Path(_) => operand_to_path_expr(operand),
610        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
611    }
612}
613
614/// Render an array path or array literal for the right side of `in`.
615fn array_or_literal(operand: &ecel::Operand) -> String {
616    match operand {
617        ecel::Operand::Path(_) => operand_to_path_expr(operand),
618        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
619    }
620}
621
622fn literal_of(operand: &ecel::Operand) -> ecel::Literal {
623    match operand {
624        ecel::Operand::Literal(lit) => lit.clone(),
625        ecel::Operand::Path(_) => ecel::Literal::String(String::new()),
626    }
627}
628
629struct PathParts {
630    path_prefix: String,
631    remaining_path: String,
632}
633
634fn build_path_expression(operand: &ecel::Operand) -> (PathParts, bool) {
635    match operand {
636        ecel::Operand::Path(path_expr) => {
637            let segments = &path_expr.segments;
638            let mut pre_wildcard = Vec::new();
639            let mut post_wildcard = Vec::new();
640            let mut has_wildcard = false;
641
642            for (i, seg) in segments.iter().enumerate() {
643                if i == 0 {
644                    continue;
645                }
646                if has_wildcard {
647                    post_wildcard.push(seg.clone());
648                } else if matches!(seg, ecel::PathSegment::Wildcard) {
649                    has_wildcard = true;
650                } else {
651                    pre_wildcard.push(seg.clone());
652                }
653            }
654
655            let mut prefix = String::from("message");
656            for seg in &pre_wildcard {
657                match seg {
658                    ecel::PathSegment::Field(name) => {
659                        prefix.push('.');
660                        prefix.push_str(&to_snake_case(name));
661                    }
662                    ecel::PathSegment::Index(idx) => {
663                        prefix.push_str(&format!("[{}]", idx));
664                    }
665                    ecel::PathSegment::QuotedKey(name) => {
666                        prefix.push_str(&format!("[\"{}\"]", name));
667                    }
668                    _ => {}
669                }
670            }
671
672            let mut suffix = String::new();
673            for seg in &post_wildcard {
674                match seg {
675                    ecel::PathSegment::Field(name) => {
676                        suffix.push('.');
677                        suffix.push_str(&to_snake_case(name));
678                    }
679                    ecel::PathSegment::Index(idx) => {
680                        suffix.push_str(&format!("[{}]", idx));
681                    }
682                    ecel::PathSegment::QuotedKey(name) => {
683                        suffix.push_str(&format!("[\"{}\"]", name));
684                    }
685                    _ => {}
686                }
687            }
688
689            (
690                PathParts {
691                    path_prefix: prefix,
692                    remaining_path: suffix,
693                },
694                has_wildcard,
695            )
696        }
697        ecel::Operand::Literal(_) => (
698            PathParts {
699                path_prefix: String::new(),
700                remaining_path: String::new(),
701            },
702            false,
703        ),
704    }
705}
706
707fn operand_to_path_expr(operand: &ecel::Operand) -> String {
708    match operand {
709        ecel::Operand::Path(path) => {
710            let segments = &path.segments;
711            let mut out = String::from("message");
712            for seg in segments.iter().skip(1) {
713                match seg {
714                    ecel::PathSegment::Field(name) => {
715                        out.push('.');
716                        out.push_str(&to_snake_case(name));
717                    }
718                    ecel::PathSegment::Wildcard => {}
719                    ecel::PathSegment::Index(idx) => {
720                        out.push_str(&format!("[{}]", idx));
721                    }
722                    ecel::PathSegment::QuotedKey(name) => {
723                        out.push_str(&format!("[\"{}\"]", name));
724                    }
725                }
726            }
727            out
728        }
729        ecel::Operand::Literal(_) => String::new(),
730    }
731}
732
733fn operand_to_val_str(operand: &ecel::Operand) -> String {
734    match operand {
735        ecel::Operand::Path(_) => "".to_string(),
736        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
737    }
738}
739
740fn literal_to_val_str(lit: &ecel::Literal) -> String {
741    match lit {
742        ecel::Literal::Number(n) => n.to_string(),
743        ecel::Literal::String(s) => format!("\"{}\"", s),
744        ecel::Literal::Bool(b) => b.to_string(),
745        ecel::Literal::Null => "None".to_string(),
746        ecel::Literal::Array(items) => {
747            let inner: Vec<String> = items.iter().map(literal_to_val_str).collect();
748            format!("vec![{}]", inner.join(", "))
749        }
750    }
751}
752
753/// Emit the comparison operator. `in` and `matches` are not Rust syntax, so they
754/// are emitted as calls to `etdl_core` helper functions. The helpers take the
755/// operands on the right (path value and literal) so the generated expression is
756/// `etdl_core::condition::contains(<path>, <literal>)` for `in` and
757/// `etdl_core::condition::matches(<path>, "<re2>")` for `matches`.
758fn comparator_str(op: &ecel::Comparator) -> &str {
759    match op {
760        ecel::Comparator::Eq => "==",
761        ecel::Comparator::Neq => "!=",
762        ecel::Comparator::Gte => ">=",
763        ecel::Comparator::Lte => "<=",
764        ecel::Comparator::Gt => ">",
765        ecel::Comparator::Lt => "<",
766        ecel::Comparator::In => "in",
767        ecel::Comparator::Matches => "matches",
768    }
769}
770
771fn ref_to_rust_type(msg_ref: &etdl_parser::ast::MessageRef) -> String {
772    let pointer = match msg_ref {
773        etdl_parser::ast::MessageRef::External(r) => &r.pointer,
774        etdl_parser::ast::MessageRef::Internal(r) => &r.pointer,
775    };
776    extract_last_segment_str(pointer)
777}
778
779/// The channel-name string a `publisher.publish(...)` call uses: for an
780/// External Reference, the last JSON Pointer segment (matching the
781/// pre-existing convention for message types); for a bare channel-name
782/// string (Section 5.3.5, only valid when the document has no
783/// `asyncapi_imports`), the literal name itself, unmodified — it is a
784/// runtime channel address, not a Rust identifier.
785fn channel_ref_name(channel_ref: &etdl_parser::ast::ChannelRef) -> String {
786    match channel_ref {
787        etdl_parser::ast::ChannelRef::External(ext_ref) => {
788            extract_last_segment_str(&ext_ref.pointer)
789        }
790        etdl_parser::ast::ChannelRef::Bare(name) => name.clone(),
791    }
792}
793
794fn extract_last_segment_str(pointer: &str) -> String {
795    let parts: Vec<&str> = pointer.split('/').collect();
796    let last = parts.last().unwrap_or(&"Unknown");
797    to_pascal_case(last)
798}
799
800/// Collects the `<id>`s of every Internal Message Reference
801/// (`#/components/messages/<id>`) used anywhere in `doc`, so their inline
802/// Message Schema Objects can have matching Rust types generated (there is
803/// no external, AsyncAPI-toolchain-generated module to import them from,
804/// unlike an External Reference's `{alias}::messages::*`).
805fn collect_internal_message_ids(doc: &EtlDocument) -> std::collections::BTreeSet<String> {
806    use etdl_parser::ast::MessageRef;
807
808    fn note(msg_ref: &MessageRef, ids: &mut std::collections::BTreeSet<String>) {
809        if let MessageRef::Internal(int_ref) = msg_ref {
810            if let Some(id) = int_ref.pointer.strip_prefix("#/components/messages/") {
811                ids.insert(id.to_string());
812            }
813        }
814    }
815
816    let mut ids = std::collections::BTreeSet::new();
817
818    for tree in doc.event_trees.values() {
819        note(&tree.initiating_event.message, &mut ids);
820
821        for node in tree.nodes.values() {
822            match node {
823                Node::Operation(op) => {
824                    if let Some(ref m) = op.emits {
825                        note(m, &mut ids);
826                    }
827                }
828                Node::Consequence(cons) => {
829                    if let Some(ref m) = cons.message {
830                        note(m, &mut ids);
831                    }
832                }
833                _ => {}
834            }
835        }
836    }
837
838    if let Some(ref fault_trees) = doc.fault_trees {
839        for ft in fault_trees.values() {
840            if let Some(ref m) = ft.top_event.message {
841                note(m, &mut ids);
842            }
843            for be in ft.basic_events.values() {
844                if let Some(ref m) = be.message {
845                    note(m, &mut ids);
846                }
847            }
848        }
849    }
850
851    ids
852}
853
854/// Generates a Rust struct (and any nested structs its object-typed
855/// properties need) for every inline `components.messages.<id>` entry
856/// actually referenced via an Internal Message Reference somewhere in
857/// `doc`. The top-level struct for `<id>` is named `to_pascal_case(<id>)`,
858/// matching `ref_to_rust_type`'s naming for the same reference.
859fn generate_inline_message_types(doc: &EtlDocument) -> String {
860    let ids = collect_internal_message_ids(doc);
861    if ids.is_empty() {
862        return String::new();
863    }
864
865    let messages = doc.components.as_ref().and_then(|c| c.messages.as_ref());
866
867    let mut output = String::new();
868    for id in &ids {
869        let Some(message) = messages.and_then(|m| m.get(id)) else {
870            continue;
871        };
872        generate_message_envelope(id, message, &mut output);
873    }
874    output
875}
876
877/// Emits the message envelope type for one inline `components.messages.<id>`
878/// entry: a `pub struct <Name> { payload: <Name>Payload, headers: Option<...> }`,
879/// matching the `{payload, headers}` shape an External Reference's
880/// AsyncAPI-toolchain-generated type already has (see
881/// `etdl-compiler/tests/gencheck/src/messages.rs`'s stubs) — this is what
882/// makes `message.payload.<field>` path codegen (`build_path_expression`)
883/// work identically regardless of which kind of Message Reference produced
884/// `message`.
885fn generate_message_envelope(id: &str, message: &etdl_parser::ast::Message, output: &mut String) {
886    let type_name = to_pascal_case(id);
887    let payload_type_name = format!("{}Payload", type_name);
888    let payload: serde_json::Value =
889        serde_json::to_value(&message.payload).unwrap_or(serde_json::Value::Null);
890
891    let mut nested = String::new();
892    let payload_rust_type = schema_to_rust_type(&payload_type_name, &payload, &mut nested);
893    output.push_str(&nested);
894
895    output.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
896    output.push_str(&format!("pub struct {} {{\n", type_name));
897    output.push_str(&format!("    pub payload: {},\n", payload_rust_type));
898    output.push_str("    #[serde(default)]\n");
899    output.push_str("    pub headers: Option<serde_json::Value>,\n");
900    output.push_str("}\n\n");
901}
902
903/// Emits a `pub struct <name> { ... }` for an object-typed JSON Schema
904/// (`schema.properties`), deriving `Serialize`/`Deserialize`. A property
905/// listed in `schema.required` becomes a non-optional field; any other
906/// property becomes `Option<T>` with `#[serde(default)]`. A schema with no
907/// `properties` (or a non-object schema) falls back to a
908/// `serde_json::Value`-wrapping newtype, so the type always exists even
909/// when the schema is too loose to model precisely.
910fn generate_struct_from_schema(name: &str, schema: &serde_json::Value, output: &mut String) {
911    let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
912        output.push_str(&format!(
913            "#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\npub struct {}(pub serde_json::Value);\n\n",
914            name
915        ));
916        return;
917    };
918
919    let required: std::collections::BTreeSet<&str> = schema
920        .get("required")
921        .and_then(|r| r.as_array())
922        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
923        .unwrap_or_default();
924
925    let mut nested = String::new();
926    let mut fields = String::new();
927
928    for (field_name, field_schema) in properties {
929        let snake = to_snake_case(field_name);
930        let nested_type_name = format!("{}{}", name, to_pascal_case(field_name));
931        let rust_type = schema_to_rust_type(&nested_type_name, field_schema, &mut nested);
932        let is_required = required.contains(field_name.as_str());
933
934        if snake != *field_name {
935            fields.push_str(&format!("    #[serde(rename = \"{}\")]\n", field_name));
936        }
937
938        if is_required {
939            fields.push_str(&format!("    pub {}: {},\n", snake, rust_type));
940        } else {
941            fields.push_str("    #[serde(default)]\n");
942            fields.push_str(&format!("    pub {}: Option<{}>,\n", snake, rust_type));
943        }
944    }
945
946    output.push_str(&nested);
947    output.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
948    output.push_str(&format!("pub struct {} {{\n", name));
949    output.push_str(&fields);
950    output.push_str("}\n\n");
951}
952
953/// Maps a JSON Schema fragment to a Rust type, generating a nested struct
954/// (appended to `nested`) for an object-typed property under
955/// `candidate_name`. Anything not representable as a concrete Rust type
956/// (missing/unrecognized `type`) falls back to `serde_json::Value`.
957fn schema_to_rust_type(candidate_name: &str, schema: &serde_json::Value, nested: &mut String) -> String {
958    match schema.get("type").and_then(|t| t.as_str()) {
959        Some("string") => "String".to_string(),
960        Some("integer") => "i64".to_string(),
961        Some("number") => "f64".to_string(),
962        Some("boolean") => "bool".to_string(),
963        Some("array") => {
964            let item_type = match schema.get("items") {
965                Some(items_schema) => {
966                    schema_to_rust_type(&format!("{}Item", candidate_name), items_schema, nested)
967                }
968                None => "serde_json::Value".to_string(),
969            };
970            format!("Vec<{}>", item_type)
971        }
972        Some("object") => {
973            if schema.get("properties").is_some() {
974                generate_struct_from_schema(candidate_name, schema, nested);
975                candidate_name.to_string()
976            } else {
977                "serde_json::Value".to_string()
978            }
979        }
980        _ => "serde_json::Value".to_string(),
981    }
982}
983
984fn get_branch_prob(
985    branch: &etdl_parser::ast::Branch,
986    _node_id: &str,
987    fault_tree_probs: &BTreeMap<String, f64>,
988) -> Option<f64> {
989    if let Some(ref ps) = branch.probability_source {
990        let ft_id = extract_ft_id(&ps.pointer);
991        return fault_tree_probs.get(&ft_id).copied();
992    }
993    branch.effective_probability()
994}
995
996fn extract_ft_id(pointer: &str) -> String {
997    pointer
998        .trim_start_matches("#/faultTrees/")
999        .trim_end_matches("/topEvent")
1000        .to_string()
1001}
1002
1003fn to_snake_case(s: &str) -> String {
1004    let mut result = String::new();
1005    for (i, c) in s.chars().enumerate() {
1006        if c.is_uppercase() {
1007            if i > 0 {
1008                result.push('_');
1009            }
1010            result.push(c.to_lowercase().next().unwrap());
1011        } else {
1012            result.push(c);
1013        }
1014    }
1015    result
1016}
1017
1018fn to_upper_snake(s: &str) -> String {
1019    let snake = to_snake_case(s);
1020    snake.to_uppercase()
1021}
1022
1023fn to_pascal_case(s: &str) -> String {
1024    let mut result = String::new();
1025    let mut capitalize = true;
1026    for c in s.chars() {
1027        if c == '_' || c == '-' || c == ' ' {
1028            capitalize = true;
1029        } else if capitalize {
1030            result.extend(c.to_uppercase());
1031            capitalize = false;
1032        } else {
1033            result.push(c);
1034        }
1035    }
1036    result
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use etdl_parser::ast::{EtlDocument, InternalRef};
1043
1044    fn parse(yaml: &str) -> EtlDocument {
1045        serde_yaml::from_str(yaml).expect("valid yaml")
1046    }
1047
1048    fn multi_ft_doc() -> EtlDocument {
1049        parse(
1050            r##"
1051etdl: "1.0.0"
1052info:
1053  title: "MultiFT"
1054  version: "1.0.0"
1055  domain: "D"
1056asyncapi_imports: {}
1057eventTrees:
1058  T:
1059    initiatingEvent:
1060      id: I
1061      message: "a#/m"
1062      next: O
1063    nodes:
1064      O:
1065        type: operation
1066        action: execute
1067        handler: "h"
1068        next: C
1069        onFailure: FC
1070        onFailureProbabilitySource: "#/faultTrees/B/topEvent"
1071      C:
1072        type: consequence
1073        operation: terminate
1074      FC:
1075        type: consequence
1076        operation: terminate
1077faultTrees:
1078  A:
1079    topEvent:
1080      id: A1
1081      description: "a"
1082      rootCause: AE
1083    basicEvents:
1084      AE:
1085        description: "ae"
1086        probability: 0.9
1087  B:
1088    topEvent:
1089      id: B1
1090      description: "b"
1091      rootCause: BE
1092    basicEvents:
1093      BE:
1094        description: "be"
1095        probability: 0.01
1096"##,
1097        )
1098    }
1099
1100    #[test]
1101    fn find_fault_tree_prob_selects_by_pointer() {
1102        let doc = multi_ft_doc();
1103        let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
1104        assert_eq!(probs["A"], 0.9);
1105        assert_eq!(probs["B"], 0.01);
1106
1107        // The pointer selects tree B, not the first map entry (A).
1108        let ps = InternalRef {
1109            pointer: "#/faultTrees/B/topEvent".to_string(),
1110        };
1111        let (id, prob) = find_fault_tree_prob(&ps, &probs).expect("resolves");
1112        assert_eq!(id, "B");
1113        assert!((prob - 0.01).abs() < 1e-9);
1114    }
1115
1116    #[test]
1117    fn generated_constants_use_correct_tree() {
1118        let doc = multi_ft_doc();
1119        let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
1120        let constants = generate_fault_tree_constants(&doc, &probs);
1121        assert!(constants.contains("faultTrees.B.topEvent"));
1122        assert!(constants.contains("= 0.010000"));
1123        assert!(!constants.contains("faultTrees.A.topEvent"));
1124    }
1125
1126    #[test]
1127    fn in_operator_lowers_to_contains() {
1128        let cond = etdl_parser::ecel::parse_condition(
1129            "message.payload.status in [\"PAID\", \"AUTHORIZED\"]",
1130        )
1131        .unwrap();
1132        let code = condition_to_rust_code(&cond);
1133        assert!(
1134            code.contains("etdl_core::condition::contains"),
1135            "got: {}",
1136            code
1137        );
1138        assert!(code.contains("\"PAID\""));
1139    }
1140
1141    #[test]
1142    fn matches_operator_lowers_to_regex() {
1143        let cond = etdl_parser::ecel::parse_condition(
1144            "message.payload.reference matches \"^ORD-[0-9]{8}$\"",
1145        )
1146        .unwrap();
1147        let code = condition_to_rust_code(&cond);
1148        assert!(
1149            code.contains("etdl_core::condition::matches"),
1150            "got: {}",
1151            code
1152        );
1153    }
1154
1155    #[test]
1156    fn comparison_emits_valid_rust() {
1157        let cond = etdl_parser::ecel::parse_condition("message.payload.amount >= 10000").unwrap();
1158        let code = condition_to_rust_code(&cond);
1159        assert_eq!(code, "message.payload.amount >= 10000");
1160    }
1161}