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;
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 generate_all(
30        &self,
31        doc: &EtlDocument,
32        fault_tree_probs: &BTreeMap<String, f64>,
33        _registry: &AsyncApiRegistry,
34        _diagnostics: &mut Vec<Diagnostic>,
35    ) -> Result<String, String> {
36        let mut output = String::new();
37
38        output.push_str(&format!(
39            "// AUTOGENERATED BY ETDL COMPILER v{} - DO NOT EDIT DIRECTLY\n\n",
40            self.version
41        ));
42
43        let imports = collect_imports(doc);
44        output.push_str(&imports);
45        output.push('\n');
46
47        let constants = generate_fault_tree_constants(doc, fault_tree_probs);
48        output.push_str(&constants);
49
50        for tree in doc.event_trees.values() {
51            let handler_code = generate_event_tree_handler(doc, tree, fault_tree_probs)?;
52            output.push_str(&handler_code);
53            output.push('\n');
54        }
55
56        Ok(output)
57    }
58}
59
60fn collect_imports(doc: &EtlDocument) -> String {
61    let mut imports = String::from(
62        "use std::time::Duration;\n\
63         use etdl_core::BranchMonitor;\n\
64         use etdl_core::condition::{contains, matches};\n\
65         use etdl_core::publisher::Publisher;\n\
66         use etdl_core::retry::{RetryPolicy, BackoffStrategy};\n\
67         use etdl_core::WorkflowError;\n\
68         use serde::Serialize;\n",
69    );
70
71    let mut alias_set: BTreeMap<String, bool> = BTreeMap::new();
72
73    for tree in doc.event_trees.values() {
74        alias_set.insert(tree.initiating_event.message.alias.clone(), true);
75
76        for node in tree.nodes.values() {
77            match node {
78                Node::Operation(op) => {
79                    if let Some(ref ext_ref) = op.emits {
80                        alias_set.insert(ext_ref.alias.clone(), true);
81                    }
82                }
83                Node::Consequence(cons) => {
84                    if let Some(ref ext_ref) = cons.channel {
85                        alias_set.insert(ext_ref.alias.clone(), true);
86                    }
87                    if let Some(ref ext_ref) = cons.message {
88                        alias_set.insert(ext_ref.alias.clone(), true);
89                    }
90                }
91                _ => {}
92            }
93        }
94    }
95
96    for alias in alias_set.keys() {
97        let mod_name = alias.replace('-', "_");
98        imports.push_str(&format!("use {}::messages::*;\n", mod_name));
99    }
100
101    imports
102}
103
104fn generate_fault_tree_constants(
105    doc: &EtlDocument,
106    fault_tree_probs: &BTreeMap<String, f64>,
107) -> String {
108    let mut output = String::new();
109
110    for tree in doc.event_trees.values() {
111        for (node_id, node) in &tree.nodes {
112            if let Node::Operation(op) = node {
113                if let Some(ref ps) = op.on_failure_probability_source {
114                    if let Some((ft_id, prob)) = find_fault_tree_prob(ps, fault_tree_probs) {
115                        output.push_str(&format!(
116                            "// Computed from faultTrees.{}.topEvent at build time (Section 5.16)\n",
117                            ft_id
118                        ));
119                        let const_name =
120                            to_upper_snake(&format!("{}_failure_probability", node_id));
121                        output.push_str(&format!("const {}: f64 = {:.6};\n\n", const_name, prob));
122                    }
123                }
124            }
125        }
126    }
127
128    output
129}
130
131/// Resolve an `onFailureProbabilitySource` internal reference to its fault-tree
132/// top-event probability. The pointer is `#/faultTrees/<id>/topEvent`; the
133/// referenced fault-tree id selects the probability from the resolver output.
134fn find_fault_tree_prob(
135    ps: &etdl_parser::ast::InternalRef,
136    fault_tree_probs: &BTreeMap<String, f64>,
137) -> Option<(String, f64)> {
138    let ft_id = extract_ft_id(&ps.pointer);
139    fault_tree_probs.get(&ft_id).map(|&v| (ft_id.clone(), v))
140}
141
142fn generate_event_tree_handler(
143    doc: &EtlDocument,
144    tree: &EventTree,
145    fault_tree_probs: &BTreeMap<String, f64>,
146) -> Result<String, String> {
147    let mut output = String::new();
148
149    let fn_name = format!("handle_{}", to_snake_case(&tree.initiating_event.id));
150    let message_type = ref_to_rust_type(&tree.initiating_event.message);
151
152    output.push_str(&format!(
153        "pub async fn {}(message: {}, publisher: &dyn Publisher) -> Result<(), WorkflowError> {{\n",
154        fn_name, message_type
155    ));
156
157    let first_barrier = find_first_barrier(tree);
158    let monitor_name = first_barrier
159        .map(to_snake_case)
160        .unwrap_or_else(|| "monitor".to_string());
161
162    output.push_str(&format!(
163        "    let mut {} = BranchMonitor::new(\"{}\");\n\n",
164        monitor_name,
165        first_barrier.unwrap_or("root")
166    ));
167
168    let start_node_id = &tree.initiating_event.next;
169    let body = generate_node_code(doc, tree, start_node_id, 1, fault_tree_probs, &monitor_name)?;
170    output.push_str(&body);
171
172    output.push_str("    Ok(())\n");
173    output.push_str("}\n");
174
175    Ok(output)
176}
177
178fn find_first_barrier(tree: &EventTree) -> Option<&str> {
179    let mut current = &tree.initiating_event.next;
180    loop {
181        match tree.nodes.get(current.as_str()) {
182            Some(Node::Barrier(_)) => return Some(current.as_str()),
183            Some(Node::Operation(op)) => {
184                current = &op.next;
185            }
186            Some(Node::Consequence(_)) => return None,
187            None => return None,
188        }
189    }
190}
191
192fn generate_node_code(
193    doc: &EtlDocument,
194    tree: &EventTree,
195    node_id: &str,
196    depth: usize,
197    fault_tree_probs: &BTreeMap<String, f64>,
198    monitor_name: &str,
199) -> Result<String, String> {
200    let node = tree
201        .nodes
202        .get(node_id)
203        .ok_or_else(|| format!("node '{}' not found", node_id))?;
204
205    match node {
206        Node::Barrier(barrier) => generate_barrier_code(
207            tree,
208            node_id,
209            barrier,
210            depth,
211            doc,
212            fault_tree_probs,
213            monitor_name,
214        ),
215        Node::Operation(op) => generate_operation_code(
216            tree,
217            node_id,
218            op,
219            depth,
220            doc,
221            fault_tree_probs,
222            monitor_name,
223        ),
224        Node::Consequence(cons) => generate_consequence_code(cons, depth),
225    }
226}
227
228fn generate_barrier_code(
229    tree: &EventTree,
230    node_id: &str,
231    barrier: &etdl_parser::ast::Barrier,
232    depth: usize,
233    doc: &EtlDocument,
234    fault_tree_probs: &BTreeMap<String, f64>,
235    monitor_name: &str,
236) -> Result<String, String> {
237    let indent = "    ".repeat(depth);
238    let mut output = String::new();
239
240    for (i, branch) in barrier.branches.iter().enumerate() {
241        if i == 0 {
242            if branch.condition == Condition::Default {
243                let prob = get_branch_prob(branch, node_id, fault_tree_probs);
244                if let Some(p) = prob {
245                    output.push_str(&format!(
246                        "{}    {}.record_branch(\"{}\", {:.6});\n",
247                        indent, monitor_name, branch.outcome, p
248                    ));
249                }
250                let body = generate_node_code(
251                    doc,
252                    tree,
253                    &branch.next,
254                    depth + 1,
255                    fault_tree_probs,
256                    monitor_name,
257                )?;
258                output.push_str(&body);
259            } else {
260                let cond = condition_to_rust_code(&branch.condition);
261                output.push_str(&format!("{}if {} {{\n", indent, cond));
262
263                let prob = get_branch_prob(branch, node_id, fault_tree_probs);
264                if let Some(p) = prob {
265                    output.push_str(&format!(
266                        "{}    {}.record_branch(\"{}\", {:.6});\n",
267                        indent, monitor_name, branch.outcome, p
268                    ));
269                }
270
271                let body = generate_node_code(
272                    doc,
273                    tree,
274                    &branch.next,
275                    depth + 1,
276                    fault_tree_probs,
277                    monitor_name,
278                )?;
279                output.push_str(&body);
280                output.push_str(&format!("{}}}", indent));
281            }
282        } else if branch.condition == Condition::Default {
283            output.push_str(" else {\n");
284
285            let prob = get_branch_prob(branch, node_id, fault_tree_probs);
286            if let Some(p) = prob {
287                output.push_str(&format!(
288                    "{}    {}.record_branch(\"{}\", {:.6});\n",
289                    indent, monitor_name, branch.outcome, p
290                ));
291            }
292
293            let body = generate_node_code(
294                doc,
295                tree,
296                &branch.next,
297                depth + 1,
298                fault_tree_probs,
299                monitor_name,
300            )?;
301            output.push_str(&body);
302            output.push_str(&format!("{}}}\n", indent));
303        } else {
304            let cond = condition_to_rust_code(&branch.condition);
305            output.push_str(&format!(" else if {} {{\n", cond));
306
307            let prob = get_branch_prob(branch, node_id, fault_tree_probs);
308            if let Some(p) = prob {
309                output.push_str(&format!(
310                    "{}    {}.record_branch(\"{}\", {:.6});\n",
311                    indent, monitor_name, branch.outcome, p
312                ));
313            }
314
315            let body = generate_node_code(
316                doc,
317                tree,
318                &branch.next,
319                depth + 1,
320                fault_tree_probs,
321                monitor_name,
322            )?;
323            output.push_str(&body);
324            output.push_str(&format!("{}}}", indent));
325        }
326    }
327
328    Ok(output)
329}
330
331fn generate_operation_code(
332    tree: &EventTree,
333    node_id: &str,
334    op: &etdl_parser::ast::Operation,
335    depth: usize,
336    doc: &EtlDocument,
337    fault_tree_probs: &BTreeMap<String, f64>,
338    monitor_name: &str,
339) -> Result<String, String> {
340    let indent = "    ".repeat(depth);
341    let mut output = String::new();
342
343    let handler_name = to_snake_case(&op.handler);
344    let timeout = op.timeout_ms.unwrap_or(5000);
345
346    if let Some(ref retry) = op.retry_policy {
347        let strategy = match retry
348            .backoff_strategy
349            .as_ref()
350            .unwrap_or(&BackoffStrategy::Fixed)
351        {
352            BackoffStrategy::Exponential => "BackoffStrategy::Exponential",
353            BackoffStrategy::Fixed => "BackoffStrategy::Fixed",
354        };
355        output.push_str(&format!(
356            "{}let retry = RetryPolicy {{\n\
357             {}    max_attempts: {},\n\
358             {}    backoff_ms: {},\n\
359             {}    strategy: {},\n\
360             {}}};\n",
361            indent, indent, retry.max_attempts, indent, retry.backoff_ms, indent, strategy, indent
362        ));
363        output.push_str(&format!(
364            "{}match retry.execute(|| {}(&message), Duration::from_millis({})).await {{\n",
365            indent, handler_name, timeout
366        ));
367    } else {
368        output.push_str(&format!(
369            "{}match {}(&message).await {{\n",
370            indent, handler_name
371        ));
372    }
373
374    output.push_str(&format!("{}    Ok(_result) => {{\n", indent));
375
376    let next_node = tree.nodes.get(&op.next);
377    match next_node {
378        Some(Node::Consequence(cons)) => {
379            emit_send(cons, "_result", &indent, &mut output);
380        }
381        Some(_) => {
382            generate_node_code(
383                doc,
384                tree,
385                &op.next,
386                depth + 2,
387                fault_tree_probs,
388                monitor_name,
389            )
390            .map(|body| output.push_str(&body))?;
391        }
392        None => {}
393    }
394
395    output.push_str(&format!("{}    }}\n", indent));
396
397    if let Some(ref _on_failure_id) = op.on_failure {
398        output.push_str(&format!("{}    Err(err) => {{\n", indent));
399
400        if let Some(ref ps) = op.on_failure_probability_source {
401            let const_name = to_upper_snake(&format!("{}_failure_probability", node_id));
402            let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
403            if prob_exists {
404                output.push_str(&format!(
405                    "{}        {}.record_failure(\"{}\", &err, Some({}));\n",
406                    indent, monitor_name, node_id, const_name
407                ));
408            } else {
409                output.push_str(&format!(
410                    "{}        {}.record_failure(\"{}\", &err, None);\n",
411                    indent, monitor_name, node_id
412                ));
413            }
414        } else {
415            output.push_str(&format!(
416                "{}        {}.record_failure(\"{}\", &err, None);\n",
417                indent, monitor_name, node_id
418            ));
419        }
420
421        let on_failure_id = op.on_failure.as_ref().unwrap();
422        match tree.nodes.get(on_failure_id) {
423            Some(Node::Consequence(cons)) => {
424                emit_send(cons, "message", &indent, &mut output);
425            }
426            _ => {
427                generate_node_code(
428                    doc,
429                    tree,
430                    on_failure_id,
431                    depth + 2,
432                    fault_tree_probs,
433                    monitor_name,
434                )
435                .map(|body| output.push_str(&body))?;
436            }
437        }
438
439        output.push_str(&format!("{}    }}\n", indent));
440    } else {
441        output.push_str(&format!(
442            "{}    Err(err) => return Err(WorkflowError::new(format!(\"{{}}\", err))),\n",
443            indent
444        ));
445    }
446
447    output.push_str(&format!("{}}}\n", indent));
448
449    Ok(output)
450}
451
452/// Emit a `publisher.publish(...)` call for a `send` consequence.
453fn emit_send(
454    cons: &etdl_parser::ast::Consequence,
455    payload_expr: &str,
456    indent: &str,
457    output: &mut String,
458) {
459    match cons.consequence_operation {
460        etdl_parser::ast::ConsequenceOperation::Send => {
461            if let Some(ref channel_ref) = cons.channel {
462                let channel_name = extract_last_segment(channel_ref);
463                output.push_str(&format!(
464                    "{}        publisher.publish(\"{}\", &etdl_core::serde_json::to_value({}).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
465                    indent, channel_name, payload_expr
466                ));
467            }
468        }
469        etdl_parser::ast::ConsequenceOperation::Terminate => {}
470    }
471}
472
473fn generate_consequence_code(
474    cons: &etdl_parser::ast::Consequence,
475    depth: usize,
476) -> Result<String, String> {
477    let indent = "    ".repeat(depth);
478    let mut output = String::new();
479
480    match cons.consequence_operation {
481        etdl_parser::ast::ConsequenceOperation::Send => {
482            if let Some(ref channel_ref) = cons.channel {
483                let channel_name = extract_last_segment(channel_ref);
484                output.push_str(&format!(
485                    "{}publisher.publish(\"{}\", &etdl_core::serde_json::to_value(message).map_err(|e| WorkflowError::new(format!(\"{{}}\", e)))?)?;\n",
486                    indent, channel_name
487                ));
488            }
489        }
490        etdl_parser::ast::ConsequenceOperation::Terminate => {}
491    }
492
493    if depth == 1 {
494        output.push_str(&format!("{}Ok(())\n", indent));
495    }
496
497    Ok(output)
498}
499
500fn condition_to_rust_code(condition: &Condition) -> String {
501    match condition {
502        Condition::Default => "true".to_string(),
503        Condition::Comparison(cmp) => {
504            use ecel::Comparator as C;
505            match cmp.op {
506                // `in` and `matches` lower to etdl_core::condition helpers.
507                C::In => {
508                    let left = path_or_literal(&cmp.left);
509                    let right = array_or_literal(&cmp.right);
510                    format!("etdl_core::condition::contains(&{}, &{})", right, left)
511                }
512                C::Matches => {
513                    let left = path_or_literal(&cmp.left);
514                    let right = literal_to_val_str(&literal_of(&cmp.right));
515                    format!("etdl_core::condition::matches({}, {})", left, right)
516                }
517                _ => {
518                    let (left_path, has_wildcard) = build_path_expression(&cmp.left);
519                    let right = operand_to_val_str(&cmp.right);
520                    let op = comparator_str(&cmp.op);
521
522                    if has_wildcard {
523                        // Wildcard path on the left: quantify over the array.
524                        format!(
525                            "{}.iter().all(|item| item{} {} {})",
526                            left_path.path_prefix, left_path.remaining_path, op, right
527                        )
528                    } else {
529                        let l = if left_path.path_prefix.is_empty()
530                            && left_path.remaining_path.is_empty()
531                        {
532                            operand_to_val_str(&cmp.left)
533                        } else {
534                            left_path.path_prefix + &left_path.remaining_path
535                        };
536                        let r = if right.is_empty() {
537                            operand_to_path_expr(&cmp.right)
538                        } else {
539                            right
540                        };
541                        format!("{} {} {}", l, op, r)
542                    }
543                }
544            }
545        }
546    }
547}
548
549/// Render a path operand (or literal) as an expression usable as the left side
550/// of a helper call.
551fn path_or_literal(operand: &ecel::Operand) -> String {
552    match operand {
553        ecel::Operand::Path(_) => operand_to_path_expr(operand),
554        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
555    }
556}
557
558/// Render an array path or array literal for the right side of `in`.
559fn array_or_literal(operand: &ecel::Operand) -> String {
560    match operand {
561        ecel::Operand::Path(_) => operand_to_path_expr(operand),
562        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
563    }
564}
565
566fn literal_of(operand: &ecel::Operand) -> ecel::Literal {
567    match operand {
568        ecel::Operand::Literal(lit) => lit.clone(),
569        ecel::Operand::Path(_) => ecel::Literal::String(String::new()),
570    }
571}
572
573struct PathParts {
574    path_prefix: String,
575    remaining_path: String,
576}
577
578fn build_path_expression(operand: &ecel::Operand) -> (PathParts, bool) {
579    match operand {
580        ecel::Operand::Path(path_expr) => {
581            let segments = &path_expr.segments;
582            let mut pre_wildcard = Vec::new();
583            let mut post_wildcard = Vec::new();
584            let mut has_wildcard = false;
585
586            for (i, seg) in segments.iter().enumerate() {
587                if i == 0 {
588                    continue;
589                }
590                if has_wildcard {
591                    post_wildcard.push(seg.clone());
592                } else if matches!(seg, ecel::PathSegment::Wildcard) {
593                    has_wildcard = true;
594                } else {
595                    pre_wildcard.push(seg.clone());
596                }
597            }
598
599            let mut prefix = String::from("message");
600            for seg in &pre_wildcard {
601                match seg {
602                    ecel::PathSegment::Field(name) => {
603                        prefix.push('.');
604                        prefix.push_str(&to_snake_case(name));
605                    }
606                    ecel::PathSegment::Index(idx) => {
607                        prefix.push_str(&format!("[{}]", idx));
608                    }
609                    ecel::PathSegment::QuotedKey(name) => {
610                        prefix.push_str(&format!("[\"{}\"]", name));
611                    }
612                    _ => {}
613                }
614            }
615
616            let mut suffix = String::new();
617            for seg in &post_wildcard {
618                match seg {
619                    ecel::PathSegment::Field(name) => {
620                        suffix.push('.');
621                        suffix.push_str(&to_snake_case(name));
622                    }
623                    ecel::PathSegment::Index(idx) => {
624                        suffix.push_str(&format!("[{}]", idx));
625                    }
626                    ecel::PathSegment::QuotedKey(name) => {
627                        suffix.push_str(&format!("[\"{}\"]", name));
628                    }
629                    _ => {}
630                }
631            }
632
633            (
634                PathParts {
635                    path_prefix: prefix,
636                    remaining_path: suffix,
637                },
638                has_wildcard,
639            )
640        }
641        ecel::Operand::Literal(_) => (
642            PathParts {
643                path_prefix: String::new(),
644                remaining_path: String::new(),
645            },
646            false,
647        ),
648    }
649}
650
651fn operand_to_path_expr(operand: &ecel::Operand) -> String {
652    match operand {
653        ecel::Operand::Path(path) => {
654            let segments = &path.segments;
655            let mut out = String::from("message");
656            for seg in segments.iter().skip(1) {
657                match seg {
658                    ecel::PathSegment::Field(name) => {
659                        out.push('.');
660                        out.push_str(&to_snake_case(name));
661                    }
662                    ecel::PathSegment::Wildcard => {}
663                    ecel::PathSegment::Index(idx) => {
664                        out.push_str(&format!("[{}]", idx));
665                    }
666                    ecel::PathSegment::QuotedKey(name) => {
667                        out.push_str(&format!("[\"{}\"]", name));
668                    }
669                }
670            }
671            out
672        }
673        ecel::Operand::Literal(_) => String::new(),
674    }
675}
676
677fn operand_to_val_str(operand: &ecel::Operand) -> String {
678    match operand {
679        ecel::Operand::Path(_) => "".to_string(),
680        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
681    }
682}
683
684fn literal_to_val_str(lit: &ecel::Literal) -> String {
685    match lit {
686        ecel::Literal::Number(n) => n.to_string(),
687        ecel::Literal::String(s) => format!("\"{}\"", s),
688        ecel::Literal::Bool(b) => b.to_string(),
689        ecel::Literal::Null => "None".to_string(),
690        ecel::Literal::Array(items) => {
691            let inner: Vec<String> = items.iter().map(literal_to_val_str).collect();
692            format!("vec![{}]", inner.join(", "))
693        }
694    }
695}
696
697/// Emit the comparison operator. `in` and `matches` are not Rust syntax, so they
698/// are emitted as calls to `etdl_core` helper functions. The helpers take the
699/// operands on the right (path value and literal) so the generated expression is
700/// `etdl_core::condition::contains(<path>, <literal>)` for `in` and
701/// `etdl_core::condition::matches(<path>, "<re2>")` for `matches`.
702fn comparator_str(op: &ecel::Comparator) -> &str {
703    match op {
704        ecel::Comparator::Eq => "==",
705        ecel::Comparator::Neq => "!=",
706        ecel::Comparator::Gte => ">=",
707        ecel::Comparator::Lte => "<=",
708        ecel::Comparator::Gt => ">",
709        ecel::Comparator::Lt => "<",
710        ecel::Comparator::In => "in",
711        ecel::Comparator::Matches => "matches",
712    }
713}
714
715fn ref_to_rust_type(ext_ref: &etdl_parser::ast::ExternalRef) -> String {
716    extract_last_segment(ext_ref)
717}
718
719fn extract_last_segment(ext_ref: &etdl_parser::ast::ExternalRef) -> String {
720    let pointer = &ext_ref.pointer;
721    let parts: Vec<&str> = pointer.split('/').collect();
722    let last = parts.last().unwrap_or(&"Unknown");
723    to_pascal_case(last)
724}
725
726fn get_branch_prob(
727    branch: &etdl_parser::ast::Branch,
728    _node_id: &str,
729    fault_tree_probs: &BTreeMap<String, f64>,
730) -> Option<f64> {
731    if let Some(ref ps) = branch.probability_source {
732        let ft_id = extract_ft_id(&ps.pointer);
733        return fault_tree_probs.get(&ft_id).copied();
734    }
735    branch.effective_probability()
736}
737
738fn extract_ft_id(pointer: &str) -> String {
739    pointer
740        .trim_start_matches("#/faultTrees/")
741        .trim_end_matches("/topEvent")
742        .to_string()
743}
744
745fn to_snake_case(s: &str) -> String {
746    let mut result = String::new();
747    for (i, c) in s.chars().enumerate() {
748        if c.is_uppercase() {
749            if i > 0 {
750                result.push('_');
751            }
752            result.push(c.to_lowercase().next().unwrap());
753        } else {
754            result.push(c);
755        }
756    }
757    result
758}
759
760fn to_upper_snake(s: &str) -> String {
761    let snake = to_snake_case(s);
762    snake.to_uppercase()
763}
764
765fn to_pascal_case(s: &str) -> String {
766    let mut result = String::new();
767    let mut capitalize = true;
768    for c in s.chars() {
769        if c == '_' || c == '-' || c == ' ' {
770            capitalize = true;
771        } else if capitalize {
772            result.extend(c.to_uppercase());
773            capitalize = false;
774        } else {
775            result.push(c);
776        }
777    }
778    result
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use etdl_parser::ast::{EtlDocument, InternalRef};
785
786    fn parse(yaml: &str) -> EtlDocument {
787        serde_yaml::from_str(yaml).expect("valid yaml")
788    }
789
790    fn multi_ft_doc() -> EtlDocument {
791        parse(
792            r##"
793etdl: "1.0.0"
794info:
795  title: "MultiFT"
796  version: "1.0.0"
797  domain: "D"
798asyncapi_imports: {}
799eventTrees:
800  T:
801    initiatingEvent:
802      id: I
803      message: "a#/m"
804      next: O
805    nodes:
806      O:
807        type: operation
808        action: execute
809        handler: "h"
810        next: C
811        onFailure: FC
812        onFailureProbabilitySource: "#/faultTrees/B/topEvent"
813      C:
814        type: consequence
815        operation: terminate
816      FC:
817        type: consequence
818        operation: terminate
819faultTrees:
820  A:
821    topEvent:
822      id: A1
823      description: "a"
824      rootCause: AE
825    basicEvents:
826      AE:
827        description: "ae"
828        probability: 0.9
829  B:
830    topEvent:
831      id: B1
832      description: "b"
833      rootCause: BE
834    basicEvents:
835      BE:
836        description: "be"
837        probability: 0.01
838"##,
839        )
840    }
841
842    #[test]
843    fn find_fault_tree_prob_selects_by_pointer() {
844        let doc = multi_ft_doc();
845        let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
846        assert_eq!(probs["A"], 0.9);
847        assert_eq!(probs["B"], 0.01);
848
849        // The pointer selects tree B, not the first map entry (A).
850        let ps = InternalRef {
851            pointer: "#/faultTrees/B/topEvent".to_string(),
852        };
853        let (id, prob) = find_fault_tree_prob(&ps, &probs).expect("resolves");
854        assert_eq!(id, "B");
855        assert!((prob - 0.01).abs() < 1e-9);
856    }
857
858    #[test]
859    fn generated_constants_use_correct_tree() {
860        let doc = multi_ft_doc();
861        let probs = crate::fault_tree::resolve_fault_trees(&doc, &mut Vec::new());
862        let constants = generate_fault_tree_constants(&doc, &probs);
863        assert!(constants.contains("faultTrees.B.topEvent"));
864        assert!(constants.contains("= 0.010000"));
865        assert!(!constants.contains("faultTrees.A.topEvent"));
866    }
867
868    #[test]
869    fn in_operator_lowers_to_contains() {
870        let cond = etdl_parser::ecel::parse_condition(
871            "message.payload.status in [\"PAID\", \"AUTHORIZED\"]",
872        )
873        .unwrap();
874        let code = condition_to_rust_code(&cond);
875        assert!(
876            code.contains("etdl_core::condition::contains"),
877            "got: {}",
878            code
879        );
880        assert!(code.contains("\"PAID\""));
881    }
882
883    #[test]
884    fn matches_operator_lowers_to_regex() {
885        let cond = etdl_parser::ecel::parse_condition(
886            "message.payload.reference matches \"^ORD-[0-9]{8}$\"",
887        )
888        .unwrap();
889        let code = condition_to_rust_code(&cond);
890        assert!(
891            code.contains("etdl_core::condition::matches"),
892            "got: {}",
893            code
894        );
895    }
896
897    #[test]
898    fn comparison_emits_valid_rust() {
899        let cond = etdl_parser::ecel::parse_condition("message.payload.amount >= 10000").unwrap();
900        let code = condition_to_rust_code(&cond);
901        assert_eq!(code, "message.payload.amount >= 10000");
902    }
903}