Skip to main content

etdl_target_python/
lib.rs

1//! The `python` [`CodeGenerator`] target.
2//!
3//! Consumes the exact same validated [`EtlDocument`] + resolved fault-tree
4//! probabilities + [`AsyncApiRegistry`] every other target consumes.
5//! Nothing here re-parses `.etdl`, re-validates ECEL conditions, or
6//! re-evaluates fault trees. Generated Python is a thin, `ctypes`-based
7//! binding to the same compiled `etdl-runtime-ffi` C ABI the Java target
8//! binds to via `java.lang.foreign` — branch/SLA accounting, retry
9//! backoff, and ECEL `matches`/`in` semantics stay in Rust. See
10//! `docs/architecture/targets.md`.
11//!
12//! Building this crate never requires Python — it only emits Python
13//! source text. Python (3.9+, for the `list[T]`/`X | None` type-hint
14//! syntax generated code uses) is needed only to *run* the generated
15//! output, and `etdl-runtime-ffi`'s compiled shared library is needed to
16//! run anything that touches `BranchMonitor`/`RetryPolicy`/`Condition`.
17//! `ctypes` is Python's standard library — no `pip install` is needed
18//! either.
19
20use etdl_compiler::{CodeGenerator, Diagnostic, GeneratedFile};
21use etdl_parser::ast::{
22    BackoffStrategy as AstBackoffStrategy, ChannelRef, Condition, EtlDocument, EventTree,
23    MessageRef, Node,
24};
25use etdl_parser::asyncapi::AsyncApiRegistry;
26use etdl_parser::ecel;
27use std::collections::BTreeMap;
28
29pub struct PythonCodeGenerator {
30    pub version: String,
31    /// Python package for document-specific output. `None` derives it
32    /// (snake_case) from `info.domain`.
33    pub package: Option<String>,
34}
35
36impl Default for PythonCodeGenerator {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl PythonCodeGenerator {
43    pub fn new() -> Self {
44        PythonCodeGenerator {
45            version: env!("CARGO_PKG_VERSION").to_string(),
46            package: None,
47        }
48    }
49
50    fn resolve_package(&self, doc: &EtlDocument) -> String {
51        if let Some(ref p) = self.package {
52            return p.clone();
53        }
54        sanitize_package(&doc.info.domain)
55    }
56}
57
58impl CodeGenerator for PythonCodeGenerator {
59    fn target_name(&self) -> &'static str {
60        "python"
61    }
62
63    fn generate_all(
64        &self,
65        doc: &EtlDocument,
66        fault_tree_probs: &BTreeMap<String, f64>,
67        registry: &AsyncApiRegistry,
68        stem: &str,
69        _diagnostics: &mut Vec<Diagnostic>,
70    ) -> Result<Vec<GeneratedFile>, String> {
71        let package = self.resolve_package(doc);
72        let _ = stem;
73
74        let mut files = runtime_files();
75        files.push(GeneratedFile::new(format!("{package}/__init__.py"), String::new()));
76
77        let message_refs = collect_message_refs(doc);
78        let mut messages_module = String::new();
79        messages_module.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\n\n");
80        messages_module.push_str("Message dataclasses resolved from AsyncAPI schemas (both External and\n");
81        messages_module.push_str("Internal References, via `AsyncApiRegistry.resolve_message` upstream in\n");
82        messages_module.push_str("the compiler) referenced anywhere in this document.\n\"\"\"\n\n");
83        messages_module.push_str("from __future__ import annotations\n\n");
84        messages_module.push_str("from dataclasses import dataclass\n\n");
85        for (type_name, msg_ref) in &message_refs {
86            let value = registry
87                .resolve_message(doc, msg_ref)
88                .map_err(|e| format!("resolving message for '{type_name}': {e}"))?;
89            messages_module.push_str(&generate_message_dataclass(type_name, &value));
90        }
91        if !message_refs.is_empty() {
92            files.push(GeneratedFile::new(
93                format!("{package}/messages.py"),
94                messages_module,
95            ));
96        }
97
98        for tree in doc.event_trees.values() {
99            let tree_id = tree_id_of(doc, tree);
100            let module_name = to_snake_case(&sanitize_ident(tree_id));
101            let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
102            let handler_methods = collect_handler_methods(tree);
103            files.push(GeneratedFile::new(
104                format!("{package}/{module_name}_handlers.py"),
105                generate_handlers_module(&class_name, &handler_methods, !message_refs.is_empty()),
106            ));
107        }
108
109        let workflow = generate_workflow_module(doc, fault_tree_probs, !message_refs.is_empty())?;
110        files.push(GeneratedFile::new(format!("{package}/workflow.py"), workflow));
111
112        Ok(files)
113    }
114}
115
116fn tree_id_of<'a>(doc: &'a EtlDocument, tree: &EventTree) -> &'a str {
117    doc.event_trees
118        .iter()
119        .find(|(_, t)| std::ptr::eq(*t, tree))
120        .map(|(id, _)| id.as_str())
121        .unwrap_or("Tree")
122}
123
124// ---------------------------------------------------------------------
125// Fixed, document-independent runtime (`etdl/runtime/*.py`)
126// ---------------------------------------------------------------------
127
128fn runtime_files() -> Vec<GeneratedFile> {
129    vec![
130        GeneratedFile::new("etdl/__init__.py", String::new()),
131        GeneratedFile::new("etdl/runtime/__init__.py", String::new()),
132        GeneratedFile::new("etdl/runtime/errors.py", ERRORS_PY),
133        GeneratedFile::new("etdl/runtime/native.py", NATIVE_PY),
134        GeneratedFile::new("etdl/runtime/branch_monitor.py", BRANCH_MONITOR_PY),
135        GeneratedFile::new("etdl/runtime/retry_policy.py", RETRY_POLICY_PY),
136        GeneratedFile::new("etdl/runtime/condition.py", CONDITION_PY),
137        GeneratedFile::new("etdl/runtime/publisher.py", PUBLISHER_PY),
138    ]
139}
140
141const ERRORS_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY."""
142
143
144class WorkflowError(Exception):
145    """Raised for a retryable operation failure. Analogous to the Rust
146    target's `etdl_core::WorkflowError` and the Java target's
147    `etdl.runtime.WorkflowError`."""
148"#;
149
150const PUBLISHER_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY."""
151
152from abc import ABC, abstractmethod
153from typing import Any
154
155
156class Publisher(ABC):
157    """The stable API boundary generated orchestration code calls into for
158    a `consequence: send` (core spec Section 5.5.3). A developer supplies a
159    concrete subclass (wiring it to a real broker/client and handling
160    serialization however fits their stack); generated code never depends
161    on a specific transport or JSON library. Application integration, not
162    ETDL runtime machinery — unlike `BranchMonitor`/`RetryPolicy`/
163    `Condition`, there is no native binding here: there is no shared
164    "publish" semantics in the Rust runtime to delegate to.
165    """
166
167    @abstractmethod
168    def publish(self, channel: str, payload: Any) -> None:
169        raise NotImplementedError
170"#;
171
172/// The native-binding layer: loads `libetdl_runtime_ffi` via `ctypes` and
173/// declares every function from `etdl-runtime-ffi/src/lib.rs` with its
174/// exact `argtypes`/`restype`. `BranchMonitor`/`RetryPolicy`/`Condition`
175/// are thin facades over this; nothing here re-implements ETDL semantics.
176const NATIVE_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
177
178Binds to `libetdl_runtime_ffi` (built from the `etdl-runtime-ffi` Rust
179crate — see `docs/architecture/targets.md`) via the standard-library
180`ctypes` module. No third-party package is required to run generated
181Python — only a built copy of the native library.
182
183Library resolution order:
1841. environment variable `ETDL_RUNTIME_LIBRARY` — an explicit absolute path
185   to the shared library (`libetdl_runtime_ffi.so`/`.dylib`,
186   `etdl_runtime_ffi.dll`).
1872. `ctypes.CDLL(<platform default name>)` — the platform's standard
188   shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH`
189   on macOS, `PATH` on Windows).
190"""
191
192import ctypes
193import os
194import sys
195
196RETRY_CALLBACK_TYPE = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p, ctypes.c_uint32)
197
198# Mirrors etdl-runtime-ffi's ETDL_OK / ETDL_ERR_* / ETDL_RETRY_* constants exactly.
199OK = 0
200ERR_NULL_HANDLE = -1
201ERR_INVALID_ARG = -2
202RETRY_OK = 0
203RETRY_EXHAUSTED = 1
204RETRY_FATAL = 2
205
206
207def _default_library_name() -> str:
208    if sys.platform == "darwin":
209        return "libetdl_runtime_ffi.dylib"
210    if sys.platform == "win32":
211        return "etdl_runtime_ffi.dll"
212    return "libetdl_runtime_ffi.so"
213
214
215def _load() -> ctypes.CDLL:
216    explicit = os.environ.get("ETDL_RUNTIME_LIBRARY")
217    name = _default_library_name()
218    if explicit:
219        return ctypes.CDLL(explicit)
220    try:
221        return ctypes.CDLL(name)
222    except OSError as e:
223        raise OSError(
224            f"could not load the ETDL Rust runtime ('{name}'). Build it with "
225            "`cargo build -p etdl-runtime-ffi --release` and either put its output "
226            "directory on your platform's shared-library search path, or set "
227            f"ETDL_RUNTIME_LIBRARY=<absolute path to {name}> directly. "
228            f"Original error: {e}"
229        ) from e
230
231
232def _configure(lib: ctypes.CDLL) -> None:
233    lib.etdl_runtime_abi_version.restype = ctypes.c_uint32
234    lib.etdl_runtime_abi_version.argtypes = []
235
236    lib.etdl_last_error_message.restype = ctypes.c_void_p
237    lib.etdl_last_error_message.argtypes = []
238
239    lib.etdl_string_free.restype = None
240    lib.etdl_string_free.argtypes = [ctypes.c_void_p]
241
242    lib.etdl_branch_monitor_new.restype = ctypes.c_void_p
243    lib.etdl_branch_monitor_new.argtypes = [ctypes.c_char_p]
244
245    lib.etdl_branch_monitor_free.restype = None
246    lib.etdl_branch_monitor_free.argtypes = [ctypes.c_void_p]
247
248    lib.etdl_branch_monitor_record_branch.restype = ctypes.c_int32
249    lib.etdl_branch_monitor_record_branch.argtypes = [
250        ctypes.c_void_p,
251        ctypes.c_char_p,
252        ctypes.c_double,
253    ]
254
255    lib.etdl_branch_monitor_record_success.restype = ctypes.c_int32
256    lib.etdl_branch_monitor_record_success.argtypes = [
257        ctypes.c_void_p,
258        ctypes.c_char_p,
259        ctypes.c_double,
260        ctypes.c_bool,
261    ]
262
263    lib.etdl_branch_monitor_record_failure.restype = ctypes.c_int32
264    lib.etdl_branch_monitor_record_failure.argtypes = [
265        ctypes.c_void_p,
266        ctypes.c_char_p,
267        ctypes.c_char_p,
268        ctypes.c_double,
269        ctypes.c_bool,
270    ]
271
272    lib.etdl_retry_policy_new.restype = ctypes.c_void_p
273    lib.etdl_retry_policy_new.argtypes = [ctypes.c_uint32, ctypes.c_uint64, ctypes.c_int32]
274
275    lib.etdl_retry_policy_free.restype = None
276    lib.etdl_retry_policy_free.argtypes = [ctypes.c_void_p]
277
278    lib.etdl_retry_policy_delay_ms.restype = ctypes.c_uint64
279    lib.etdl_retry_policy_delay_ms.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
280
281    lib.etdl_retry_policy_execute.restype = ctypes.c_int32
282    lib.etdl_retry_policy_execute.argtypes = [
283        ctypes.c_void_p,
284        RETRY_CALLBACK_TYPE,
285        ctypes.c_void_p,
286        ctypes.POINTER(ctypes.c_uint32),
287    ]
288
289    lib.etdl_condition_matches.restype = ctypes.c_int32
290    lib.etdl_condition_matches.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
291
292    lib.etdl_condition_contains.restype = ctypes.c_int32
293    lib.etdl_condition_contains.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
294
295
296lib = _load()
297_configure(lib)
298
299
300def last_error() -> str:
301    """Reads and frees the calling thread's last native error message."""
302    ptr = lib.etdl_last_error_message()
303    if not ptr:
304        return "(no further detail available from the native runtime)"
305    message = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8", errors="replace")
306    lib.etdl_string_free(ptr)
307    return message
308"#;
309
310/// The Python analog of `etdl_core::BranchMonitor` — not a
311/// reimplementation: every method is a direct call into the same
312/// `BranchMonitor` the Rust target's generated code has always used, via
313/// `etdl-runtime-ffi`.
314const BRANCH_MONITOR_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
315
316Thin facade over the native `etdl_core::BranchMonitor` (via
317`etdl-runtime-ffi`). Owns one native handle; always use as a context
318manager (generated orchestration code always does) so
319`etdl_branch_monitor_free` runs deterministically.
320"""
321
322from __future__ import annotations
323
324from . import native
325
326
327class BranchMonitor:
328    def __init__(self, node_id: str) -> None:
329        self._handle = native.lib.etdl_branch_monitor_new(node_id.encode("utf-8"))
330        if not self._handle:
331            raise RuntimeError(f"etdl_branch_monitor_new failed: {native.last_error()}")
332        self._closed = False
333
334    def record_branch(self, outcome: str, probability: float) -> None:
335        rc = native.lib.etdl_branch_monitor_record_branch(
336            self._handle, outcome.encode("utf-8"), probability
337        )
338        self._check(rc, "etdl_branch_monitor_record_branch")
339
340    def record_success(self, operation_id: str, probability: float | None) -> None:
341        rc = native.lib.etdl_branch_monitor_record_success(
342            self._handle,
343            operation_id.encode("utf-8"),
344            probability if probability is not None else 0.0,
345            probability is not None,
346        )
347        self._check(rc, "etdl_branch_monitor_record_success")
348
349    def record_failure(
350        self, operation_id: str, error: BaseException, probability: float | None
351    ) -> None:
352        rc = native.lib.etdl_branch_monitor_record_failure(
353            self._handle,
354            operation_id.encode("utf-8"),
355            str(error).encode("utf-8"),
356            probability if probability is not None else 0.0,
357            probability is not None,
358        )
359        self._check(rc, "etdl_branch_monitor_record_failure")
360
361    def _check(self, rc: int, call: str) -> None:
362        if rc != native.OK:
363            raise RuntimeError(f"{call} failed (code {rc}): {native.last_error()}")
364
365    def close(self) -> None:
366        if not self._closed:
367            self._closed = True
368            native.lib.etdl_branch_monitor_free(self._handle)
369
370    def __enter__(self) -> "BranchMonitor":
371        return self
372
373    def __exit__(self, exc_type, exc, tb) -> None:
374        self.close()
375"#;
376
377/// The Python analog of `etdl_core::retry::RetryPolicy` — the
378/// attempt-count/backoff *sequence* is computed once, in Rust
379/// (`etdl_retry_policy_execute`'s native loop), via a `ctypes.CFUNCTYPE`
380/// callback bound to `op`. Per-attempt timeout enforcement stays here (see
381/// `docs/architecture/targets.md`'s "Ownership model": no FFI boundary can
382/// safely cancel foreign code mid-flight) using a one-shot thread pool —
383/// note Python threads cannot be forcibly killed, so a "cancelled" timed
384/// out call's thread still runs to completion in the background; only the
385/// *retry loop* moves on.
386const RETRY_POLICY_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
387
388Thin facade over the native `etdl_core::RetryPolicy` (via
389`etdl-runtime-ffi`'s callback-driven `etdl_retry_policy_execute`). Owns one
390native handle; always use as a context manager.
391"""
392
393from __future__ import annotations
394
395import ctypes
396from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
397from typing import Any, Callable
398
399from . import native
400from .errors import WorkflowError
401
402
403class BackoffStrategy:
404    FIXED = 0
405    EXPONENTIAL = 1
406
407
408class RetryPolicy:
409    def __init__(self, max_attempts: int, backoff_ms: int, strategy: int) -> None:
410        self._handle = native.lib.etdl_retry_policy_new(max_attempts, backoff_ms, strategy)
411        if not self._handle:
412            raise RuntimeError(f"etdl_retry_policy_new failed: {native.last_error()}")
413        self._closed = False
414
415    def execute(self, op: Callable[[], Any], timeout_s: float | None = None) -> Any:
416        """`op` is a zero-argument callable. May raise `WorkflowError`
417        (retryable) or any other exception (fatal, not retried) — either
418        way, the exception is captured here and never crosses the native
419        call (ctypes callbacks cannot safely propagate a Python exception
420        into C); it is re-raised by this method afterward instead."""
421        result_box: dict[str, Any] = {}
422
423        def trampoline(_user_data: int, _attempt: int) -> int:
424            with ThreadPoolExecutor(max_workers=1) as executor:
425                future = executor.submit(op)
426                try:
427                    result_box["value"] = future.result(timeout=timeout_s)
428                    return 0
429                except FutureTimeoutError:
430                    result_box["checked_error"] = WorkflowError(
431                        f"operation timed out after {timeout_s}s"
432                    )
433                    return 1
434                except WorkflowError as e:
435                    result_box["checked_error"] = e
436                    return 1
437                except BaseException as e:  # noqa: BLE001 - must never cross the FFI boundary
438                    result_box["unchecked_error"] = e
439                    return -1
440
441        callback = native.RETRY_CALLBACK_TYPE(trampoline)
442        attempts_used = ctypes.c_uint32(0)
443        rc = native.lib.etdl_retry_policy_execute(
444            self._handle, callback, None, ctypes.byref(attempts_used)
445        )
446
447        if rc == native.RETRY_OK:
448            return result_box.get("value")
449        if "unchecked_error" in result_box:
450            raise result_box["unchecked_error"]
451        if "checked_error" in result_box:
452            raise result_box["checked_error"]
453        kind = "retry failed fatally" if rc == native.RETRY_FATAL else "retry exhausted"
454        raise WorkflowError(f"{kind}: {native.last_error()}")
455
456    def close(self) -> None:
457        if not self._closed:
458            self._closed = True
459            native.lib.etdl_retry_policy_free(self._handle)
460
461    def __enter__(self) -> "RetryPolicy":
462        return self
463
464    def __exit__(self, exc_type, exc, tb) -> None:
465        self.close()
466"#;
467
468/// The Python analog of `etdl_core::condition::{contains, matches}` — ECEL
469/// `in`/`matches` semantics stay in Rust; this facade only marshals values
470/// to/from the JSON text `etdl_condition_contains` expects, using Python's
471/// standard-library `json` module (unlike the Java target, no
472/// dependency-free hand-rolled encoder is needed here).
473const CONDITION_PY: &str = r#""""Read-only ETDL runtime. DO NOT EDIT DIRECTLY.
474
475ECEL (core spec Section 6) `in`/`matches` operators lower to these
476helpers, delegating to the native `etdl_core::condition` module (via
477`etdl-runtime-ffi`) — so a `matches` regex or an `in` membership check
478behaves byte-for-byte identically no matter which target (Rust, Java,
479Python, Go, .NET) evaluates it.
480"""
481
482from __future__ import annotations
483
484import json
485from typing import Any, Iterable
486
487from . import native
488
489
490def matches(value: str | None, pattern: str) -> bool:
491    rc = native.lib.etdl_condition_matches(
492        (value or "").encode("utf-8"), pattern.encode("utf-8")
493    )
494    if rc < 0:
495        raise ValueError(f"etdl_condition_matches: {native.last_error()}")
496    return rc == 1
497
498
499def contains(haystack: Iterable[Any] | None, needle: Any) -> bool:
500    needle_json = json.dumps(needle).encode("utf-8")
501    haystack_json = json.dumps(list(haystack) if haystack is not None else []).encode("utf-8")
502    rc = native.lib.etdl_condition_contains(needle_json, haystack_json)
503    if rc < 0:
504        raise ValueError(f"etdl_condition_contains: {native.last_error()}")
505    return rc == 1
506"#;
507
508// ---------------------------------------------------------------------
509// Message dataclasses
510// ---------------------------------------------------------------------
511
512fn collect_message_refs(doc: &EtlDocument) -> BTreeMap<String, MessageRef> {
513    let mut refs: BTreeMap<String, MessageRef> = BTreeMap::new();
514    let note = |m: &MessageRef, refs: &mut BTreeMap<String, MessageRef>| {
515        let name = ref_to_type_name(m);
516        refs.entry(name).or_insert_with(|| m.clone());
517    };
518
519    for tree in doc.event_trees.values() {
520        note(&tree.initiating_event.message, &mut refs);
521        for node in tree.nodes.values() {
522            match node {
523                Node::Operation(op) => {
524                    if let Some(ref m) = op.emits {
525                        note(m, &mut refs);
526                    }
527                }
528                Node::Consequence(cons) => {
529                    if let Some(ref m) = cons.message {
530                        note(m, &mut refs);
531                    }
532                }
533                _ => {}
534            }
535        }
536    }
537
538    if let Some(ref fault_trees) = doc.fault_trees {
539        for ft in fault_trees.values() {
540            if let Some(ref m) = ft.top_event.message {
541                note(m, &mut refs);
542            }
543            for be in ft.basic_events.values() {
544                if let Some(ref m) = be.message {
545                    note(m, &mut refs);
546                }
547            }
548        }
549    }
550
551    refs
552}
553
554fn ref_to_type_name(msg_ref: &MessageRef) -> String {
555    let pointer = match msg_ref {
556        MessageRef::External(r) => &r.pointer,
557        MessageRef::Internal(r) => &r.pointer,
558    };
559    extract_last_segment(pointer)
560}
561
562fn extract_last_segment(pointer: &str) -> String {
563    let last = pointer.rsplit('/').next().unwrap_or("Unknown");
564    to_pascal_case(last)
565}
566
567fn generate_message_dataclass(type_name: &str, message: &serde_json::Value) -> String {
568    let payload_type_name = format!("{type_name}Payload");
569    let payload_schema = message.get("payload").cloned().unwrap_or(serde_json::Value::Null);
570
571    let mut nested = String::new();
572    let payload_py_type = schema_to_python_type(&payload_type_name, &payload_schema, &mut nested, false);
573
574    let mut out = String::new();
575    out.push_str(&nested);
576    out.push_str("@dataclass\n");
577    out.push_str(&format!("class {type_name}:\n"));
578    out.push_str(&format!("    payload: {payload_py_type}\n"));
579    out.push_str("    headers: dict[str, object] | None = None\n\n\n");
580    out
581}
582
583/// Python analog of the Rust/Java target's struct/record generators, but
584/// with one Python-specific constraint neither of those has: a
585/// `@dataclass`'s fields without a default value must all precede fields
586/// *with* a default, so (unlike the schema's own property order) required
587/// fields are always emitted before optional ones.
588fn generate_dataclass_from_schema(name: &str, schema: &serde_json::Value, output: &mut String) {
589    let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) else {
590        return;
591    };
592
593    let required: std::collections::BTreeSet<&str> = schema
594        .get("required")
595        .and_then(|r| r.as_array())
596        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
597        .unwrap_or_default();
598
599    let mut nested = String::new();
600    let mut required_fields = Vec::new();
601    let mut optional_fields = Vec::new();
602
603    for (field_name, field_schema) in properties {
604        let snake = to_snake_case(field_name);
605        let nested_type_name = format!("{name}{}", to_pascal_case(field_name));
606        let is_required = required.contains(field_name.as_str());
607        let py_type = schema_to_python_type(&nested_type_name, field_schema, &mut nested, !is_required);
608        if is_required {
609            required_fields.push(format!("    {snake}: {py_type}\n"));
610        } else {
611            optional_fields.push(format!("    {snake}: {py_type} = None\n"));
612        }
613    }
614
615    output.push_str(&nested);
616    output.push_str("@dataclass\n");
617    output.push_str(&format!("class {name}:\n"));
618    if required_fields.is_empty() && optional_fields.is_empty() {
619        output.push_str("    pass\n");
620    } else {
621        for f in &required_fields {
622            output.push_str(f);
623        }
624        for f in &optional_fields {
625            output.push_str(f);
626        }
627    }
628    output.push_str("\n\n");
629}
630
631fn schema_to_python_type(
632    candidate_name: &str,
633    schema: &serde_json::Value,
634    nested: &mut String,
635    nullable: bool,
636) -> String {
637    let base = match schema.get("type").and_then(|t| t.as_str()) {
638        Some("string") => "str".to_string(),
639        Some("integer") => "int".to_string(),
640        Some("number") => "float".to_string(),
641        Some("boolean") => "bool".to_string(),
642        Some("array") => {
643            let item_type = match schema.get("items") {
644                Some(items_schema) => schema_to_python_type(
645                    &format!("{candidate_name}Item"),
646                    items_schema,
647                    nested,
648                    true,
649                ),
650                None => "object".to_string(),
651            };
652            format!("list[{item_type}]")
653        }
654        Some("object") => {
655            if schema.get("properties").is_some() {
656                generate_dataclass_from_schema(candidate_name, schema, nested);
657                candidate_name.to_string()
658            } else {
659                "dict[str, object]".to_string()
660            }
661        }
662        _ => "object".to_string(),
663    };
664    if nullable {
665        format!("{base} | None")
666    } else {
667        base
668    }
669}
670
671// ---------------------------------------------------------------------
672// Handler ABCs (one per event tree — the user-editable boundary)
673// ---------------------------------------------------------------------
674
675fn collect_handler_methods(tree: &EventTree) -> Vec<String> {
676    let mut seen = std::collections::BTreeSet::new();
677    let mut methods = Vec::new();
678    for node in tree.nodes.values() {
679        if let Node::Operation(op) = node {
680            let name = to_snake_case(&op.handler);
681            if seen.insert(name.clone()) {
682                methods.push(name);
683            }
684        }
685    }
686    methods
687}
688
689fn generate_handlers_module(class_name: &str, methods: &[String], has_messages: bool) -> String {
690    let mut out = String::new();
691    out.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\n\n");
692    out.push_str("Implement this class in your own (never regenerated) module; the generated\n");
693    out.push_str("orchestration in workflow.py calls through it and never the other way\n");
694    out.push_str("around.\n\"\"\"\n\n");
695    out.push_str("from __future__ import annotations\n\n");
696    out.push_str("from abc import ABC, abstractmethod\n");
697    out.push_str("from typing import Any\n");
698    if has_messages {
699        out.push_str("\nfrom .messages import *  # noqa: F401,F403 - message types used in signatures below\n");
700    }
701    out.push_str(&format!("\n\nclass {class_name}(ABC):\n"));
702    if methods.is_empty() {
703        out.push_str("    pass\n");
704    }
705    for method in methods {
706        out.push_str("    @abstractmethod\n");
707        out.push_str(&format!("    def {method}(self, message: Any) -> Any:\n"));
708        out.push_str("        raise NotImplementedError\n\n");
709    }
710    out
711}
712
713// ---------------------------------------------------------------------
714// Workflow orchestration module
715// ---------------------------------------------------------------------
716
717fn generate_workflow_module(
718    doc: &EtlDocument,
719    fault_tree_probs: &BTreeMap<String, f64>,
720    has_messages: bool,
721) -> Result<String, String> {
722    let mut out = String::new();
723    out.push_str("\"\"\"AUTOGENERATED BY ETDL COMPILER - DO NOT EDIT DIRECTLY.\"\"\"\n\n");
724    out.push_str("from __future__ import annotations\n\n");
725    out.push_str("from etdl.runtime.branch_monitor import BranchMonitor\n");
726    out.push_str("from etdl.runtime.condition import contains, matches\n");
727    out.push_str("from etdl.runtime.errors import WorkflowError\n");
728    out.push_str("from etdl.runtime.publisher import Publisher\n");
729    out.push_str("from etdl.runtime.retry_policy import BackoffStrategy, RetryPolicy\n");
730    if has_messages {
731        out.push_str("\nfrom .messages import *  # noqa: F401,F403\n");
732    }
733    for tree in doc.event_trees.values() {
734        let tree_id = tree_id_of(doc, tree);
735        let module_name = to_snake_case(&sanitize_ident(tree_id));
736        let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
737        out.push_str(&format!("from .{module_name}_handlers import {class_name}\n"));
738    }
739    out.push('\n');
740
741    out.push_str(&generate_fault_tree_constants(doc, fault_tree_probs));
742
743    for tree in doc.event_trees.values() {
744        let method = generate_event_tree_function(doc, tree, fault_tree_probs)?;
745        out.push_str(&method);
746        out.push('\n');
747    }
748
749    Ok(out)
750}
751
752fn generate_fault_tree_constants(doc: &EtlDocument, fault_tree_probs: &BTreeMap<String, f64>) -> String {
753    let mut output = String::new();
754    for tree in doc.event_trees.values() {
755        for (node_id, node) in &tree.nodes {
756            if let Node::Operation(op) = node {
757                if let Some(ref ps) = op.on_failure_probability_source {
758                    if let Some((ft_id, prob)) = find_fault_tree_prob(ps, fault_tree_probs) {
759                        output.push_str(&format!(
760                            "# Computed from faultTrees.{ft_id}.topEvent at build time (Section 5.16)\n"
761                        ));
762                        let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
763                        output.push_str(&format!("{const_name}: float = {prob:.6}\n\n"));
764                    }
765                }
766            }
767        }
768    }
769    output
770}
771
772fn find_fault_tree_prob(
773    ps: &etdl_parser::ast::InternalRef,
774    fault_tree_probs: &BTreeMap<String, f64>,
775) -> Option<(String, f64)> {
776    let ft_id = extract_ft_id(&ps.pointer);
777    fault_tree_probs.get(&ft_id).map(|&v| (ft_id.clone(), v))
778}
779
780fn extract_ft_id(pointer: &str) -> String {
781    pointer
782        .trim_start_matches("#/faultTrees/")
783        .trim_end_matches("/topEvent")
784        .to_string()
785}
786
787fn generate_event_tree_function(
788    doc: &EtlDocument,
789    tree: &EventTree,
790    fault_tree_probs: &BTreeMap<String, f64>,
791) -> Result<String, String> {
792    let mut out = String::new();
793
794    let tree_id = tree_id_of(doc, tree);
795    let class_name = format!("{}Handlers", to_pascal_case(&sanitize_ident(tree_id)));
796    let function_name = format!("handle_{}", to_snake_case(&sanitize_ident(&tree.initiating_event.id)));
797
798    out.push_str(&format!(
799        "def {function_name}(message, publisher: Publisher, handlers: {class_name}) -> None:\n"
800    ));
801
802    let first_barrier = find_first_barrier(tree);
803    let monitor_name = first_barrier.map(to_snake_case).unwrap_or_else(|| "monitor".to_string());
804    out.push_str(&format!(
805        "    with BranchMonitor(\"{}\") as {monitor_name}:\n",
806        first_barrier.unwrap_or("root")
807    ));
808
809    let start_node_id = &tree.initiating_event.next;
810    let body = generate_node_code(doc, tree, start_node_id, 2, fault_tree_probs, &monitor_name)?;
811    out.push_str(&body);
812
813    Ok(out)
814}
815
816fn find_first_barrier(tree: &EventTree) -> Option<&str> {
817    let mut current = &tree.initiating_event.next;
818    loop {
819        match tree.nodes.get(current.as_str()) {
820            Some(Node::Barrier(_)) => return Some(current.as_str()),
821            Some(Node::Operation(op)) => current = &op.next,
822            Some(Node::Consequence(_)) => return None,
823            None => return None,
824        }
825    }
826}
827
828fn generate_node_code(
829    doc: &EtlDocument,
830    tree: &EventTree,
831    node_id: &str,
832    depth: usize,
833    fault_tree_probs: &BTreeMap<String, f64>,
834    monitor_name: &str,
835) -> Result<String, String> {
836    let node = tree
837        .nodes
838        .get(node_id)
839        .ok_or_else(|| format!("node '{node_id}' not found"))?;
840
841    match node {
842        Node::Barrier(barrier) => {
843            generate_barrier_code(tree, node_id, barrier, depth, doc, fault_tree_probs, monitor_name)
844        }
845        Node::Operation(op) => {
846            generate_operation_code(tree, node_id, op, depth, doc, fault_tree_probs, monitor_name)
847        }
848        Node::Consequence(cons) => generate_consequence_code(cons, depth),
849    }
850}
851
852fn generate_barrier_code(
853    tree: &EventTree,
854    node_id: &str,
855    barrier: &etdl_parser::ast::Barrier,
856    depth: usize,
857    doc: &EtlDocument,
858    fault_tree_probs: &BTreeMap<String, f64>,
859    monitor_name: &str,
860) -> Result<String, String> {
861    let indent = "    ".repeat(depth);
862    let mut output = String::new();
863
864    for (i, branch) in barrier.branches.iter().enumerate() {
865        let is_default = branch.condition == Condition::Default;
866        let keyword = if i == 0 {
867            "if"
868        } else if is_default {
869            "else"
870        } else {
871            "elif"
872        };
873
874        if is_default && i > 0 {
875            output.push_str(&format!("{indent}else:\n"));
876        } else {
877            let cond = condition_to_python_code(&branch.condition);
878            output.push_str(&format!("{indent}{keyword} {cond}:\n"));
879        }
880
881        emit_branch_record(&format!("{indent}    "), monitor_name, branch, node_id, fault_tree_probs, &mut output);
882        let body = generate_node_code(doc, tree, &branch.next, depth + 1, fault_tree_probs, monitor_name)?;
883        output.push_str(&body);
884    }
885
886    Ok(output)
887}
888
889fn emit_branch_record(
890    indent: &str,
891    monitor_name: &str,
892    branch: &etdl_parser::ast::Branch,
893    node_id: &str,
894    fault_tree_probs: &BTreeMap<String, f64>,
895    output: &mut String,
896) {
897    if let Some(p) = get_branch_prob(branch, node_id, fault_tree_probs) {
898        output.push_str(&format!(
899            "{indent}{monitor_name}.record_branch(\"{}\", {p:.6})\n",
900            branch.outcome
901        ));
902    } else {
903        output.push_str(&format!("{indent}pass\n"));
904    }
905}
906
907fn get_branch_prob(
908    branch: &etdl_parser::ast::Branch,
909    _node_id: &str,
910    fault_tree_probs: &BTreeMap<String, f64>,
911) -> Option<f64> {
912    if let Some(ref ps) = branch.probability_source {
913        let ft_id = extract_ft_id(&ps.pointer);
914        return fault_tree_probs.get(&ft_id).copied();
915    }
916    branch.effective_probability()
917}
918
919fn generate_operation_code(
920    tree: &EventTree,
921    node_id: &str,
922    op: &etdl_parser::ast::Operation,
923    depth: usize,
924    doc: &EtlDocument,
925    fault_tree_probs: &BTreeMap<String, f64>,
926    monitor_name: &str,
927) -> Result<String, String> {
928    let indent = "    ".repeat(depth);
929    let mut output = String::new();
930
931    let handler_name = to_snake_case(&op.handler);
932    let timeout_s = op.timeout_ms.unwrap_or(5000) as f64 / 1000.0;
933
934    output.push_str(&format!("{indent}try:\n"));
935    if let Some(ref retry) = op.retry_policy {
936        let strategy = match retry.backoff_strategy.as_ref().unwrap_or(&AstBackoffStrategy::Fixed) {
937            AstBackoffStrategy::Exponential => "BackoffStrategy.EXPONENTIAL",
938            AstBackoffStrategy::Fixed => "BackoffStrategy.FIXED",
939        };
940        output.push_str(&format!(
941            "{indent}    with RetryPolicy({}, {}, {strategy}) as retry:\n",
942            retry.max_attempts, retry.backoff_ms
943        ));
944        output.push_str(&format!(
945            "{indent}        _result = retry.execute(lambda: handlers.{handler_name}(message), timeout_s={timeout_s})\n"
946        ));
947    } else {
948        output.push_str(&format!(
949            "{indent}    _result = handlers.{handler_name}(message)\n"
950        ));
951    }
952
953    if op.on_failure.is_some() {
954        if let Some(ref ps) = op.on_failure_probability_source {
955            let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
956            let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
957            let indent2 = "    ".repeat(depth + 1 + usize::from(op.retry_policy.is_some()));
958            if prob_exists {
959                output.push_str(&format!(
960                    "{indent2}{monitor_name}.record_success(\"{node_id}\", {const_name})\n"
961                ));
962            } else {
963                output.push_str(&format!(
964                    "{indent2}{monitor_name}.record_success(\"{node_id}\", None)\n"
965                ));
966            }
967        }
968    }
969
970    let body_indent = depth + 1 + usize::from(op.retry_policy.is_some());
971    let next_node = tree.nodes.get(&op.next);
972    match next_node {
973        Some(Node::Consequence(cons)) => {
974            emit_send(cons, "_result", &"    ".repeat(body_indent), &mut output);
975        }
976        Some(_) => {
977            let body = generate_node_code(doc, tree, &op.next, body_indent, fault_tree_probs, monitor_name)?;
978            output.push_str(&body);
979        }
980        None => {
981            output.push_str(&format!("{}pass\n", "    ".repeat(body_indent)));
982        }
983    }
984
985    output.push_str(&format!("{indent}except WorkflowError as err:\n"));
986
987    if let Some(ref ps) = op.on_failure_probability_source {
988        let const_name = to_upper_snake(&format!("{node_id}_failure_probability"));
989        let prob_exists = find_fault_tree_prob(ps, fault_tree_probs).is_some();
990        if prob_exists {
991            output.push_str(&format!(
992                "{indent}    {monitor_name}.record_failure(\"{node_id}\", err, {const_name})\n"
993            ));
994        } else {
995            output.push_str(&format!(
996                "{indent}    {monitor_name}.record_failure(\"{node_id}\", err, None)\n"
997            ));
998        }
999    } else {
1000        output.push_str(&format!(
1001            "{indent}    {monitor_name}.record_failure(\"{node_id}\", err, None)\n"
1002        ));
1003    }
1004
1005    if let Some(ref on_failure_id) = op.on_failure {
1006        match tree.nodes.get(on_failure_id) {
1007            Some(Node::Consequence(cons)) => {
1008                emit_send(cons, "message", &format!("{indent}    "), &mut output);
1009            }
1010            _ => {
1011                let body = generate_node_code(doc, tree, on_failure_id, depth + 1, fault_tree_probs, monitor_name)?;
1012                output.push_str(&body);
1013            }
1014        }
1015    } else {
1016        output.push_str(&format!("{indent}    raise\n"));
1017    }
1018
1019    Ok(output)
1020}
1021
1022fn emit_send(cons: &etdl_parser::ast::Consequence, payload_expr: &str, indent: &str, output: &mut String) {
1023    if let etdl_parser::ast::ConsequenceOperation::Send = cons.consequence_operation {
1024        if let Some(ref channel_ref) = cons.channel {
1025            let channel_name = channel_ref_name(channel_ref);
1026            output.push_str(&format!(
1027                "{indent}publisher.publish(\"{channel_name}\", {payload_expr})\n"
1028            ));
1029            return;
1030        }
1031    }
1032    output.push_str(&format!("{indent}pass\n"));
1033}
1034
1035fn generate_consequence_code(cons: &etdl_parser::ast::Consequence, depth: usize) -> Result<String, String> {
1036    let indent = "    ".repeat(depth);
1037    let mut output = String::new();
1038
1039    if let etdl_parser::ast::ConsequenceOperation::Send = cons.consequence_operation {
1040        if let Some(ref channel_ref) = cons.channel {
1041            let channel_name = channel_ref_name(channel_ref);
1042            output.push_str(&format!("{indent}publisher.publish(\"{channel_name}\", message)\n"));
1043            return Ok(output);
1044        }
1045    }
1046    output.push_str(&format!("{indent}pass\n"));
1047    Ok(output)
1048}
1049
1050fn channel_ref_name(channel_ref: &ChannelRef) -> String {
1051    match channel_ref {
1052        ChannelRef::External(ext_ref) => extract_last_segment(&ext_ref.pointer),
1053        ChannelRef::Bare(name) => name.clone(),
1054    }
1055}
1056
1057// ---------------------------------------------------------------------
1058// ECEL condition -> Python
1059// ---------------------------------------------------------------------
1060
1061fn condition_to_python_code(condition: &Condition) -> String {
1062    match condition {
1063        Condition::Default => "True".to_string(),
1064        Condition::Expr(expr) => render_bool_expr(expr),
1065    }
1066}
1067
1068/// `boolean-expr` (spec §6.2): `and`/`or`/`not` are keywords in Python, not
1069/// symbolic operators like `&&`/`||`/`!` — everything else about combining
1070/// comparisons is unchanged.
1071fn render_bool_expr(expr: &ecel::BoolExpr) -> String {
1072    use ecel::BoolExpr as B;
1073    match expr {
1074        B::And(a, b) => format!("({}) and ({})", render_bool_expr(a), render_bool_expr(b)),
1075        B::Or(a, b) => format!("({}) or ({})", render_bool_expr(a), render_bool_expr(b)),
1076        B::Not(a) => format!("not ({})", render_bool_expr(a)),
1077        B::Comparison(cmp) => render_comparison(cmp),
1078        B::Quantifier(q) => render_quantifier(q),
1079        B::Defined(path) => render_defined(path),
1080    }
1081}
1082
1083fn render_comparison(cmp: &ecel::Comparison) -> String {
1084    use ecel::Comparator as C;
1085    match cmp.op {
1086        C::In => {
1087            let left = path_or_literal(&cmp.left);
1088            let right = array_or_literal(&cmp.right);
1089            format!("contains({right}, {left})")
1090        }
1091        C::Matches => {
1092            let left = operand_to_path_expr_or_literal(&cmp.left);
1093            let right = literal_to_val_str(&literal_of(&cmp.right));
1094            format!("matches({left}, {right})")
1095        }
1096        _ => {
1097            let (left_path, has_wildcard) = match as_path(&cmp.left) {
1098                Some(p) => build_path_expression(p),
1099                None => (PathParts { path_prefix: String::new(), remaining_path: String::new() }, false),
1100            };
1101            let right = operand_to_val_str(&cmp.right);
1102            let op = comparator_str(&cmp.op);
1103
1104            if has_wildcard {
1105                format!(
1106                    "all(item{} {} {} for item in {})",
1107                    left_path.remaining_path, op, right, left_path.path_prefix
1108                )
1109            } else {
1110                let l = if left_path.path_prefix.is_empty() && left_path.remaining_path.is_empty() {
1111                    operand_to_val_str(&cmp.left)
1112                } else {
1113                    left_path.path_prefix + &left_path.remaining_path
1114                };
1115                let r = if right.is_empty() { operand_to_path_expr(&cmp.right) } else { right };
1116                format!("{l} {op} {r}")
1117            }
1118        }
1119    }
1120}
1121
1122/// `quantifier-expr` (spec §6.4): `any`/`all` over a wildcarded path's
1123/// matching elements — Python's own `any(... for ...)`/`all(... for ...)`
1124/// generator-expression builtins map directly onto the two
1125/// `QuantifierKind` values, reusing the same wildcard-split machinery a
1126/// bare comparison's implicit-`all` case already uses.
1127fn render_quantifier(q: &ecel::QuantifierExpr) -> String {
1128    if let Some(path_expr) = as_path(&q.comparison.left) {
1129        if path_has_wildcard(path_expr) && !is_headers_path(path_expr) {
1130            let (parts, _has_wildcard) = build_path_expression(path_expr);
1131            let right = operand_to_val_str(&q.comparison.right);
1132            let op = comparator_str(&q.comparison.op);
1133            let builtin = match q.kind {
1134                ecel::QuantifierKind::Any => "any",
1135                ecel::QuantifierKind::All => "all",
1136            };
1137            return format!(
1138                "{}(item{} {} {} for item in {})",
1139                builtin, parts.remaining_path, op, right, parts.path_prefix
1140            );
1141        }
1142    }
1143    render_comparison(&q.comparison)
1144}
1145
1146/// `defined-expr` (spec §6.2): whether a path both exists and is non-null.
1147/// `headers` is a plain `dict`, so a missing key and an absent field are
1148/// both `... is not None` after a chain of `.get(...)` lookups; a payload
1149/// path is a `@dataclass` attribute chain, so only null-ness (`is not
1150/// None`) is checkable — a required, non-optional field is always
1151/// "defined" by construction, matching Rust/Java's own treatment.
1152fn render_defined(path: &ecel::PathExpr) -> String {
1153    if is_headers_path(path) {
1154        let mut expr = String::from("message.headers");
1155        for seg in path.segments.iter().skip(2) {
1156            let key = match seg {
1157                ecel::PathSegment::Field(name) | ecel::PathSegment::QuotedKey(name) => {
1158                    format!("\"{name}\"")
1159                }
1160                ecel::PathSegment::Index(idx) => idx.to_string(),
1161                ecel::PathSegment::Wildcard => continue,
1162            };
1163            expr = format!("({expr} or {{}}).get({key})");
1164        }
1165        format!("(({expr}) is not None)")
1166    } else {
1167        format!("(({}) is not None)", render_path_expr(path))
1168    }
1169}
1170
1171fn path_or_literal(operand: &ecel::Operand) -> String {
1172    match as_path(operand) {
1173        Some(_) => operand_to_path_expr(operand),
1174        None => operand_to_val_str(operand),
1175    }
1176}
1177
1178fn array_or_literal(operand: &ecel::Operand) -> String {
1179    match as_path(operand) {
1180        Some(_) => operand_to_path_expr(operand),
1181        None => operand_to_val_str(operand),
1182    }
1183}
1184
1185fn operand_to_path_expr_or_literal(operand: &ecel::Operand) -> String {
1186    match as_path(operand) {
1187        Some(_) => operand_to_path_expr(operand),
1188        None => operand_to_val_str(operand),
1189    }
1190}
1191
1192fn literal_of(operand: &ecel::Operand) -> ecel::Literal {
1193    match operand {
1194        ecel::Operand::Literal(lit) => lit.clone(),
1195        ecel::Operand::Value(_) => ecel::Literal::String(String::new()),
1196    }
1197}
1198
1199/// `Some(path)` if `operand` is a bare path (spec §6.2's `value-expr ::=
1200/// path-expr`), independent of whether it's used at the top level of a
1201/// comparison or nested inside arithmetic/a function-call argument.
1202fn as_path(operand: &ecel::Operand) -> Option<&ecel::PathExpr> {
1203    match operand {
1204        ecel::Operand::Value(ecel::ValueExpr::Path(p)) => Some(p),
1205        _ => None,
1206    }
1207}
1208
1209fn path_has_wildcard(path: &ecel::PathExpr) -> bool {
1210    path.segments.iter().any(|s| matches!(s, ecel::PathSegment::Wildcard))
1211}
1212
1213/// Whether `path` is rooted at `message.headers` (spec §6.3's second root)
1214/// rather than `message.payload` — decided purely syntactically, matching
1215/// `RustCodeGenerator::path_root`'s own discipline.
1216fn is_headers_path(path: &ecel::PathExpr) -> bool {
1217    matches!(path.segments.get(1), Some(ecel::PathSegment::Field(name)) if name == "headers")
1218}
1219
1220struct PathParts {
1221    path_prefix: String,
1222    remaining_path: String,
1223}
1224
1225/// Python analog of the Rust/Java targets' path-expression builder.
1226/// Segments render as plain attribute access (`.field`) — unlike Java's
1227/// records, a Python `@dataclass`'s fields are ordinary attributes, not
1228/// accessor methods — and `Index`/`QuotedKey` segments render as `[...]`
1229/// subscripting, which works directly on both `list` (index) and `dict`
1230/// (key) without the method-call detour Java's `List`/`Map` fallback
1231/// types needed.
1232fn build_path_expression(path_expr: &ecel::PathExpr) -> (PathParts, bool) {
1233    let segments = &path_expr.segments;
1234    let mut pre_wildcard = Vec::new();
1235    let mut post_wildcard = Vec::new();
1236    let mut has_wildcard = false;
1237
1238    for (i, seg) in segments.iter().enumerate() {
1239        if i == 0 {
1240            continue;
1241        }
1242        if has_wildcard {
1243            post_wildcard.push(seg.clone());
1244        } else if matches!(seg, ecel::PathSegment::Wildcard) {
1245            has_wildcard = true;
1246        } else {
1247            pre_wildcard.push(seg.clone());
1248        }
1249    }
1250
1251    let mut prefix = String::from("message");
1252    for seg in &pre_wildcard {
1253        append_segment(&mut prefix, seg);
1254    }
1255
1256    let mut suffix = String::new();
1257    for seg in &post_wildcard {
1258        append_segment(&mut suffix, seg);
1259    }
1260
1261    (
1262        PathParts { path_prefix: prefix, remaining_path: suffix },
1263        has_wildcard,
1264    )
1265}
1266
1267fn append_segment(out: &mut String, seg: &ecel::PathSegment) {
1268    match seg {
1269        ecel::PathSegment::Field(name) => {
1270            out.push('.');
1271            out.push_str(&to_snake_case(name));
1272        }
1273        ecel::PathSegment::Index(idx) => {
1274            out.push_str(&format!("[{idx}]"));
1275        }
1276        ecel::PathSegment::QuotedKey(name) => {
1277            out.push_str(&format!("[\"{name}\"]"));
1278        }
1279        _ => {}
1280    }
1281}
1282
1283/// Core path-segment rendering, shared by the top-level
1284/// `operand_to_path_expr` (bare `Operand::Value(Path(_))`) and
1285/// `render_value_expr`'s `Path` arm (a path nested inside an arithmetic
1286/// expression or function-call argument, e.g.
1287/// `length(message.payload.name)`).
1288fn render_path_expr(path: &ecel::PathExpr) -> String {
1289    let mut out = String::from("message");
1290    for seg in path.segments.iter().skip(1) {
1291        if matches!(seg, ecel::PathSegment::Wildcard) {
1292            continue;
1293        }
1294        append_segment(&mut out, seg);
1295    }
1296    out
1297}
1298
1299fn operand_to_path_expr(operand: &ecel::Operand) -> String {
1300    match as_path(operand) {
1301        Some(path) => render_path_expr(path),
1302        None => String::new(),
1303    }
1304}
1305
1306fn operand_to_val_str(operand: &ecel::Operand) -> String {
1307    match operand {
1308        // A bare path renders empty here on purpose: `render_comparison`
1309        // tries `build_path_expression`/`operand_to_path_expr` first for a
1310        // path operand (to get wildcard-quantification right) and only
1311        // falls back to this function for the non-path cases below.
1312        ecel::Operand::Value(ecel::ValueExpr::Path(_)) => String::new(),
1313        ecel::Operand::Value(v) => render_value_expr(v),
1314        ecel::Operand::Literal(lit) => literal_to_val_str(lit),
1315    }
1316}
1317
1318/// Renders a `value-expr` (spec §6.2) as a Python expression. Python's
1319/// `len()` builtin already handles both `str` and `list` uniformly at
1320/// runtime — unlike Java (a payload accessor has a concrete static type,
1321/// making a naive `isinstance` check unnecessary here in the first place;
1322/// there's no static-type system to conflict with), no dedicated runtime
1323/// helper is needed for `length()`.
1324fn render_value_expr(expr: &ecel::ValueExpr) -> String {
1325    use ecel::{FuncName as F, ValueExpr as V};
1326    match expr {
1327        V::Path(path) => render_path_expr(path),
1328        V::Number(n) => n.to_string(),
1329        V::Call(func, arg) => {
1330            let arg_code = render_value_expr(arg);
1331            match func {
1332                F::Length => format!("len({arg_code})"),
1333                F::Abs => format!("abs({arg_code})"),
1334                F::Lower => format!("({arg_code}).lower()"),
1335                F::Upper => format!("({arg_code}).upper()"),
1336            }
1337        }
1338        V::Add(a, b) => format!("(({}) + ({}))", render_value_expr(a), render_value_expr(b)),
1339        V::Sub(a, b) => format!("(({}) - ({}))", render_value_expr(a), render_value_expr(b)),
1340        V::Mul(a, b) => format!("(({}) * ({}))", render_value_expr(a), render_value_expr(b)),
1341        V::Div(a, b) => format!("(({}) / ({}))", render_value_expr(a), render_value_expr(b)),
1342    }
1343}
1344
1345fn literal_to_val_str(lit: &ecel::Literal) -> String {
1346    match lit {
1347        ecel::Literal::Number(n) => n.to_string(),
1348        ecel::Literal::String(s) => format!("\"{s}\""),
1349        ecel::Literal::Bool(b) => if *b { "True".to_string() } else { "False".to_string() },
1350        ecel::Literal::Null => "None".to_string(),
1351        ecel::Literal::Array(items) => {
1352            let inner: Vec<String> = items.iter().map(literal_to_val_str).collect();
1353            format!("[{}]", inner.join(", "))
1354        }
1355    }
1356}
1357
1358fn comparator_str(op: &ecel::Comparator) -> &str {
1359    match op {
1360        ecel::Comparator::Eq => "==",
1361        ecel::Comparator::Neq => "!=",
1362        ecel::Comparator::Gte => ">=",
1363        ecel::Comparator::Lte => "<=",
1364        ecel::Comparator::Gt => ">",
1365        ecel::Comparator::Lt => "<",
1366        ecel::Comparator::In => "in",
1367        ecel::Comparator::Matches => "matches",
1368    }
1369}
1370
1371// ---------------------------------------------------------------------
1372// Naming helpers
1373// ---------------------------------------------------------------------
1374
1375fn sanitize_package(domain: &str) -> String {
1376    let snake = to_snake_case(domain);
1377    if snake.is_empty() {
1378        "etdl_generated".to_string()
1379    } else {
1380        snake
1381    }
1382}
1383
1384fn sanitize_ident(s: &str) -> String {
1385    let cleaned: String = s
1386        .chars()
1387        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1388        .collect();
1389    if cleaned.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(true) {
1390        format!("_{cleaned}")
1391    } else {
1392        cleaned
1393    }
1394}
1395
1396fn to_snake_case(s: &str) -> String {
1397    let mut result = String::new();
1398    for (i, c) in s.chars().enumerate() {
1399        if c.is_uppercase() {
1400            if i > 0 {
1401                result.push('_');
1402            }
1403            result.push(c.to_lowercase().next().unwrap());
1404        } else if c == '-' || c == ' ' {
1405            result.push('_');
1406        } else {
1407            result.push(c);
1408        }
1409    }
1410    result
1411}
1412
1413fn to_upper_snake(s: &str) -> String {
1414    to_snake_case(s).to_uppercase()
1415}
1416
1417fn to_pascal_case(s: &str) -> String {
1418    let mut result = String::new();
1419    let mut capitalize = true;
1420    for c in s.chars() {
1421        if c == '_' || c == '-' || c == ' ' {
1422            capitalize = true;
1423        } else if capitalize {
1424            result.extend(c.to_uppercase());
1425            capitalize = false;
1426        } else {
1427            result.push(c);
1428        }
1429    }
1430    result
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436
1437    #[test]
1438    fn package_snake_cases_domain() {
1439        assert_eq!(sanitize_package("FulfillmentContext"), "fulfillment_context");
1440        assert_eq!(sanitize_package(""), "etdl_generated");
1441    }
1442
1443    #[test]
1444    fn snake_and_pascal_case() {
1445        assert_eq!(to_snake_case("orderId"), "order_id");
1446        assert_eq!(to_pascal_case("order_placed"), "OrderPlaced");
1447    }
1448
1449    #[test]
1450    fn in_operator_lowers_to_native_contains_call() {
1451        let cond = ecel::parse_condition(
1452            "message.payload.status in [\"PAID\", \"AUTHORIZED\"]",
1453        )
1454        .unwrap();
1455        let code = condition_to_python_code(&cond);
1456        assert!(code.contains("contains("), "got: {code}");
1457        assert!(code.contains("\"PAID\""));
1458    }
1459
1460    #[test]
1461    fn matches_operator_lowers_to_native_matches_call() {
1462        let cond = ecel::parse_condition(
1463            "message.payload.reference matches \"^ORD-[0-9]{8}$\"",
1464        )
1465        .unwrap();
1466        let code = condition_to_python_code(&cond);
1467        assert!(code.contains("matches("), "got: {code}");
1468    }
1469
1470    #[test]
1471    fn comparison_uses_plain_attribute_access() {
1472        let cond = ecel::parse_condition("message.payload.amount >= 10000").unwrap();
1473        let code = condition_to_python_code(&cond);
1474        assert_eq!(code, "message.payload.amount >= 10000");
1475    }
1476
1477    #[test]
1478    fn wildcard_condition_uses_all_comprehension() {
1479        let cond = ecel::parse_condition("message.payload.items[*].qty > 0").unwrap();
1480        let code = condition_to_python_code(&cond);
1481        assert_eq!(code, "all(item.qty > 0 for item in message.payload.items)");
1482    }
1483
1484    #[test]
1485    fn boolean_and_or_not_combine_comparisons() {
1486        let cond = ecel::parse_condition(
1487            "message.payload.amount >= 10000 && !(message.payload.status == \"VOID\")",
1488        )
1489        .unwrap();
1490        let code = condition_to_python_code(&cond);
1491        assert!(code.contains(" and "), "got: {code}");
1492        assert!(code.contains("not ("), "got: {code}");
1493
1494        let cond = ecel::parse_condition(
1495            "message.payload.amount >= 10000 || message.payload.priority == true",
1496        )
1497        .unwrap();
1498        let code = condition_to_python_code(&cond);
1499        assert!(code.contains(" or "), "got: {code}");
1500    }
1501
1502    #[test]
1503    fn arithmetic_and_function_calls_render_as_python_expressions() {
1504        let cond = ecel::parse_condition("message.payload.amount + 5 > 100").unwrap();
1505        let code = condition_to_python_code(&cond);
1506        assert!(code.contains("message.payload.amount"), "got: {code}");
1507        assert!(code.contains('+'), "got: {code}");
1508
1509        let cond = ecel::parse_condition("abs(message.payload.amount) > 100").unwrap();
1510        let code = condition_to_python_code(&cond);
1511        assert!(code.contains("abs("), "got: {code}");
1512
1513        let cond = ecel::parse_condition("length(message.payload.reference) > 5").unwrap();
1514        let code = condition_to_python_code(&cond);
1515        assert_eq!(code, "len(message.payload.reference) > 5");
1516    }
1517
1518    #[test]
1519    fn quantifier_lowers_to_any_or_all_generator_expression() {
1520        let cond = ecel::parse_condition(
1521            "any(message.payload.items, message.payload.items[*].qty > 0)",
1522        )
1523        .unwrap();
1524        let code = condition_to_python_code(&cond);
1525        assert!(code.starts_with("any("), "got: {code}");
1526
1527        let cond = ecel::parse_condition(
1528            "all(message.payload.items, message.payload.items[*].qty > 0)",
1529        )
1530        .unwrap();
1531        let code = condition_to_python_code(&cond);
1532        assert!(code.starts_with("all("), "got: {code}");
1533    }
1534
1535    #[test]
1536    fn defined_checks_payload_and_headers_paths() {
1537        let cond = ecel::parse_condition("defined(message.payload.reference)").unwrap();
1538        let code = condition_to_python_code(&cond);
1539        assert_eq!(code, "((message.payload.reference) is not None)");
1540
1541        let cond = ecel::parse_condition("defined(message.headers.traceId)").unwrap();
1542        let code = condition_to_python_code(&cond);
1543        assert!(code.contains("message.headers"), "got: {code}");
1544        assert!(code.contains("is not None"), "got: {code}");
1545    }
1546}