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
14/// Everything condition/path codegen needs beyond the immediate node being
15/// rendered: the document (for cross-references), the fault-tree
16/// probabilities, the AsyncAPI registry (schema lookups for `length()`'s
17/// array-vs-string choice and `message.headers.*` coercion), and the
18/// enclosing tree's initiating message reference — ECEL only ever sees
19/// "the message that triggered the tree" (spec §6.1/§6.3), a single
20/// reference constant for the whole handler function being generated.
21/// Bundled to avoid a 6+ positional-parameter signature on every codegen
22/// function in this recursive call chain.
23struct 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
170/// Resolve an `onFailureProbabilitySource` internal reference to its fault-tree
171/// top-event probability. The pointer is `#/faultTrees/<id>/topEvent`; the
172/// referenced fault-tree id selects the probability from the resolver output.
173fn 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    // Record a success on the SAME SLA key `record_failure` (in the `Err`
367    // arm below) uses, so that key's rolling window sees both outcomes
368    // instead of only ever seeing failures — an operation that failed
369    // `sla::MIN_OBSERVATIONS` times over its lifetime otherwise triggered
370    // an unconditional false SLA anomaly regardless of its actual overall
371    // failure rate (observed frequency was permanently 1.0). See
372    // `BranchMonitor::record_success`'s doc comment.
373    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
453/// Emit a `publisher.publish(...)` call for a `send` consequence.
454fn 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
528/// Which of ECEL's two root paths (spec §6.3) a `message.*` path resolves
529/// under — decided purely syntactically (the segment right after
530/// `message`), never requiring schema access.
531enum 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/// A hint for which concrete Rust primitive a headers value should coerce
550/// to, inferred from the comparator's other operand (a literal's type, or a
551/// built-in function's known argument/result type) — never from schema
552/// access, unlike payload paths (which don't need coercion at all: they're
553/// already concretely typed Rust struct fields).
554#[derive(Clone, Copy)]
555enum ScalarHint {
556    /// Numeric, but the concrete Rust type is ambiguous (could be `i64` or
557    /// `f64` depending on the schema) — leave a literal operand as a bare,
558    /// untyped-integer token and let Rust infer its type from context,
559    /// exactly like the pre-existing (unhinted) behavior.
560    Number,
561    /// Numeric *and* guaranteed to be a concrete `f64` at this point
562    /// (an arithmetic result, `abs()`, `length()`, or a headers scalar) —
563    /// a literal operand on the other side of the comparison must render
564    /// as an explicit `f64` literal, or `f64 > <untyped-int-literal>`
565    /// fails to compile (Rust never coerces between numeric types).
566    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
594/// Payload-only: direct Rust struct-field access (`message.payload.foo.bar`,
595/// snake_cased) — unchanged from the pre-existing, well-tested behavior.
596/// Never called on a headers-rooted path (those go through
597/// `render_headers_chain`/`render_headers_scalar` instead, since `headers`
598/// stays an untyped `Option<serde_json::Value>` regardless of which kind of
599/// Message Reference produced `message` — see the codegen implementation
600/// plan's Context section).
601fn 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
622/// Payload-only wildcard-aware path split (segments before/after the first
623/// `[*]`), for quantification codegen (`.iter().any/all(...)`, spec §6.4).
624fn 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
675/// Builds a `message.headers.as_ref().and_then(|v| v.get("field").cloned())...`
676/// chain for a headers-rooted path, yielding `Option<serde_json::Value>`.
677/// Uses each segment's *original* name (never snake_cased) — this indexes a
678/// runtime JSON object by its real key, not a Rust struct field.
679fn 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
694/// Coerces a headers chain to a concrete Rust scalar, defaulting on absence
695/// — this is the actual bug fix: `message.headers.*` previously rendered as
696/// direct field access (`message.headers.trace_id`), which cannot compile
697/// since `headers` is `Option<serde_json::Value>`, not a struct.
698fn 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        // `Value::as_str()` returns `Option<&str>` borrowed from its
706        // receiver — since the chain above `.cloned()`s at every hop, that
707        // receiver is a value owned by the `and_then` closure itself, so a
708        // borrow from it can't escape the closure (E0515). Cloning into an
709        // owned `String` sidesteps that; comparing an owned `String`
710        // against a `&str` literal still works directly via `PartialEq`.
711        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
718/// `== null` / `!= null` against a headers path can't reuse the payload
719/// `Option<T> != None` trick (headers values are `serde_json::Value`, where
720/// "absent" and "explicitly JSON null" are different runtime states) —
721/// treat both as "is null" here, consistent with `defined()` (spec §6.4.1)
722/// being the construct that actually distinguishes the two.
723fn 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
735/// `defined(path)` (spec §6.4.1). For headers, the `.get()` chain already
736/// carries presence information directly (`.is_some()`). For payload, a
737/// field may be a required (non-`Option`) Rust type or an `Option<T>` one
738/// depending on the schema — rather than needing schema access to pick
739/// between `.is_some()` and a literal `true`, round-trip the whole payload
740/// through `serde_json` once: that erases the `Option<T>`-vs-`T` distinction
741/// uniformly (a required field always serializes present-and-non-null; an
742/// absent optional one does not), so one code shape is correct either way.
743fn 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    // The inner comparison's `[*]` refers to the quantified array (spec
765    // §6.4) — reuse the same wildcard-split machinery a bare comparison's
766    // implicit-`all` case already uses, just parameterized on `any`/`all`.
767    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    // No wildcard where the grammar expects one — a validly type-checked
783    // document (spec §6.4) never reaches this; degrade to the plain
784    // comparison rather than emitting nonsense.
785    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        // `in` and `matches` lower to etdl_core::condition helpers.
803        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            // `etdl_core::condition::matches` takes `value: &str`. A path
810            // (payload or headers) renders as an owned `String` expression
811            // — a payload field directly (typically `String`), a headers
812            // path via `render_headers_scalar`'s owned-`String` coercion
813            // (§ its own doc comment: `Value::as_str()` borrows from a
814            // value the chain already owns, so it clones to `String`
815            // instead) — either way it must be borrowed (`&expr`) so
816            // `&String -> &str` deref coercion applies at the call site. A
817            // literal already renders as a `&'static str` and must NOT be
818            // re-borrowed.
819            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                    // Wildcard path on the left: quantify over the array
837                    // (implicit universal quantification, spec §6.4).
838                    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        // A bare numeric literal renders as an untyped-integer token by
860        // default, letting Rust infer its type from context (`i64`, `f64`,
861        // ...) — but when the *other* side is a guaranteed-`f64` value
862        // (arithmetic, `abs()`, `length()`), that inference fails
863        // (`f64 > 0` doesn't compile: Rust never coerces between numeric
864        // types), so an explicit `f64` literal is required here instead.
865        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            // A function's argument is used as a bare method-call receiver
889            // (`(<arg>).abs()`, `(<arg>).to_ascii_lowercase()`, ...), which
890            // an `Option<T>` payload field can't be — unlike a plain
891            // comparison operand (unaffected, pre-existing behavior), this
892            // needs unwrapping.
893            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
913/// A payload path rendered as a concrete (never `Option`-wrapped) value —
914/// for use as an arithmetic leaf or a function-call receiver, neither of
915/// which compile against `Option<T>`. Checks the resolved schema's
916/// `required` array (via the same `is_path_required` built for `defined()`
917/// in the schema-resolution pass) and defaults an absent optional field to
918/// its type's `Default` (`0`/`0.0` for numbers, `""` for strings) — ECEL's
919/// type system has no distinct "nullable number" (§6.7), so this is the
920/// same "absent numeric input contributes nothing" convention the
921/// reliability crate already uses elsewhere in this codebase, not a new
922/// invention. An unresolvable path (registry lookup failure) is treated as
923/// required/non-optional, preserving the pre-existing direct-access
924/// behavior rather than guessing.
925fn 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
940/// Renders any `value-expr` as an `f64`-typed Rust expression. Every
941/// arithmetic leaf goes through this (spec §6.5's `+`/`-`/`*`/`/`) so an
942/// `i64` payload field (JSON `integer`) and an `f64` one (JSON `number`)
943/// combine without a Rust type mismatch — ECEL's type system unifies both
944/// as one `Number` runtime type (§6.7); Rust needs one concrete type
945/// picked to actually add them, so arithmetic always computes in `f64`.
946/// This only casts *inside* an arithmetic sub-tree — a bare `path-expr`/
947/// `number` operand with no arithmetic operator (`render_value_expr`,
948/// called everywhere else) is untouched, so ordinary, non-arithmetic
949/// comparison codegen keeps generating exactly what it always has.
950fn 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
966/// `length()`'s result depends on whether its argument is a `string`
967/// (UTF-8 codepoint count, spec §6.5.1 — not Rust `.len()`'s byte count) or
968/// an `array` (element count). Resolving that needs a schema lookup — the
969/// one place in this module that still consults `ctx.registry` — for a
970/// non-payload or unresolvable argument, default to the string form.
971fn 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
1003/// Emit the comparison operator. `in` and `matches` are not Rust syntax, so they
1004/// are emitted as calls to `etdl_core` helper functions. The helpers take the
1005/// operands on the right (path value and literal) so the generated expression is
1006/// `etdl_core::condition::contains(<path>, <literal>)` for `in` and
1007/// `etdl_core::condition::matches(<path>, "<re2>")` for `matches`.
1008fn 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
1029/// The channel-name string a `publisher.publish(...)` call uses: for an
1030/// External Reference, the last JSON Pointer segment (matching the
1031/// pre-existing convention for message types); for a bare channel-name
1032/// string (Section 5.3.5, only valid when the document has no
1033/// `asyncapi_imports`), the literal name itself, unmodified — it is a
1034/// runtime channel address, not a Rust identifier.
1035fn 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
1050/// Collects the `<id>`s of every Internal Message Reference
1051/// (`#/components/messages/<id>`) used anywhere in `doc`, so their inline
1052/// Message Schema Objects can have matching Rust types generated (there is
1053/// no external, AsyncAPI-toolchain-generated module to import them from,
1054/// unlike an External Reference's `{alias}::messages::*`).
1055fn 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
1104/// Generates a Rust struct (and any nested structs its object-typed
1105/// properties need) for every inline `components.messages.<id>` entry
1106/// actually referenced via an Internal Message Reference somewhere in
1107/// `doc`. The top-level struct for `<id>` is named `to_pascal_case(<id>)`,
1108/// matching `ref_to_rust_type`'s naming for the same reference.
1109fn 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
1127/// Emits the message envelope type for one inline `components.messages.<id>`
1128/// entry: a `pub struct <Name> { payload: <Name>Payload, headers: Option<...> }`,
1129/// matching the `{payload, headers}` shape an External Reference's
1130/// AsyncAPI-toolchain-generated type already has (see
1131/// `etdl-compiler/tests/gencheck/src/messages.rs`'s stubs) — this is what
1132/// makes `message.payload.<field>` path codegen (`build_path_expression`)
1133/// work identically regardless of which kind of Message Reference produced
1134/// `message`.
1135fn 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
1153/// Emits a `pub struct <name> { ... }` for an object-typed JSON Schema
1154/// (`schema.properties`), deriving `Serialize`/`Deserialize`. A property
1155/// listed in `schema.required` becomes a non-optional field; any other
1156/// property becomes `Option<T>` with `#[serde(default)]`. A schema with no
1157/// `properties` (or a non-object schema) falls back to a
1158/// `serde_json::Value`-wrapping newtype, so the type always exists even
1159/// when the schema is too loose to model precisely.
1160fn 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
1203/// Maps a JSON Schema fragment to a Rust type, generating a nested struct
1204/// (appended to `nested`) for an object-typed property under
1205/// `candidate_name`. Anything not representable as a concrete Rust type
1206/// (missing/unrecognized `type`) falls back to `serde_json::Value`.
1207fn 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        // The pointer selects tree B, not the first map entry (A).
1358        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    /// A minimal, schema-free fixture for condition-codegen tests: none of
1377    /// them exercise `length()` on a payload path (the one place codegen
1378    /// still consults `ctx.registry`), so an empty registry and an
1379    /// unresolvable message reference are fine — every other rendering
1380    /// path in this module is purely syntactic.
1381    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: &registry,
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    // --- new grammar: boolean combinators ---
1432
1433    #[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    // --- new grammar: arithmetic ---
1458
1459    #[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    // --- new grammar: built-in functions ---
1469
1470    #[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    // --- new grammar: defined() ---
1486
1487    #[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    // --- the actual bug fix: message.headers.* used to emit direct field
1505    // access (`message.headers.trace_id`), which cannot compile since
1506    // `headers` is `Option<serde_json::Value>`, not a struct. ---
1507
1508    #[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    // --- explicit quantifiers reuse the wildcard `.iter()` machinery ---
1534
1535    #[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}