Skip to main content

alef_codegen/
error_gen.rs

1use ahash::AHashSet;
2use alef_core::ir::{ErrorDef, ErrorVariant};
3
4use crate::conversions::is_tuple_variant;
5
6/// Generate a wildcard match pattern for an error variant.
7/// Struct variants use `{ .. }`, tuple variants use `(..)`, unit variants have no suffix.
8fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
9    if variant.is_unit {
10        format!("{rust_path}::{}", variant.name)
11    } else if is_tuple_variant(&variant.fields) {
12        format!("{rust_path}::{}(..)", variant.name)
13    } else {
14        format!("{rust_path}::{} {{ .. }}", variant.name)
15    }
16}
17
18/// Python builtin exception names that must not be shadowed (A004 compliance).
19const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
20    "ConnectionError",
21    "TimeoutError",
22    "PermissionError",
23    "FileNotFoundError",
24    "ValueError",
25    "TypeError",
26    "RuntimeError",
27    "OSError",
28    "IOError",
29    "KeyError",
30    "IndexError",
31    "AttributeError",
32    "ImportError",
33    "MemoryError",
34    "OverflowError",
35    "StopIteration",
36    "RecursionError",
37    "SystemError",
38    "ReferenceError",
39    "BufferError",
40    "EOFError",
41    "LookupError",
42    "ArithmeticError",
43    "AssertionError",
44    "BlockingIOError",
45    "BrokenPipeError",
46    "ChildProcessError",
47    "FileExistsError",
48    "InterruptedError",
49    "IsADirectoryError",
50    "NotADirectoryError",
51    "ProcessLookupError",
52    "UnicodeError",
53];
54
55/// Compute a prefix from the error type name by stripping a trailing "Error" suffix.
56/// E.g. `"CrawlError"` -> `"Crawl"`, `"MyException"` -> `"MyException"`.
57fn error_base_prefix(error_name: &str) -> &str {
58    error_name.strip_suffix("Error").unwrap_or(error_name)
59}
60
61/// Return the Python exception name for a variant, avoiding shadowing of Python builtins.
62///
63/// 1. Appends `"Error"` suffix if not already present (N818 compliance).
64/// 2. If the resulting name shadows a Python builtin, prefixes it with the error type's base
65///    name. E.g. for `CrawlError::Connection` -> `ConnectionError` (shadowed) -> `CrawlConnectionError`.
66pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
67    let candidate = if variant_name.ends_with("Error") {
68        variant_name.to_string()
69    } else {
70        format!("{}Error", variant_name)
71    };
72
73    if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
74        let prefix = error_base_prefix(error_name);
75        // Avoid double-prefixing if the candidate already starts with the prefix
76        if candidate.starts_with(prefix) {
77            candidate
78        } else {
79            format!("{}{}", prefix, candidate)
80        }
81    } else {
82        candidate
83    }
84}
85
86/// Generate `pyo3::create_exception!` macros for each error variant plus the base error type.
87/// Appends "Error" suffix to variant names that don't already have it (N818 compliance).
88/// Prefixes names that would shadow Python builtins (A004 compliance).
89pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
90    // Pre-compute variant names that haven't been seen yet
91    let mut variant_names = Vec::new();
92    for variant in &error.variants {
93        let variant_name = python_exception_name(&variant.name, &error.name);
94        if seen_exceptions.insert(variant_name.clone()) {
95            variant_names.push(variant_name);
96        }
97    }
98
99    // Check if base error hasn't been seen
100    let include_base = seen_exceptions.insert(error.name.clone());
101
102    crate::template_env::render(
103        "error_gen/pyo3_error_types.jinja",
104        minijinja::context! {
105            variant_names => variant_names,
106            module_name => module_name,
107            error_name => error.name.as_str(),
108            include_base => include_base,
109        },
110    )
111}
112
113/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
114/// Uses Error-suffixed names for variant exceptions (N818 compliance).
115///
116/// When `error.methods` is non-empty, the constructor receives a tuple
117/// `(message, status_code, is_transient, error_type)` so that the `#[getter]`
118/// impl generated by [`gen_pyo3_error_methods_impl`] can read them back.
119pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
120    let rust_path = if error.rust_path.is_empty() {
121        format!("{core_import}::{}", error.name)
122    } else {
123        let normalized = error.rust_path.replace('-', "_");
124        // Paths with more than 2 segments (e.g. `mylib_core::di::error::DependencyError`)
125        // reference private internal modules that are not accessible from generated binding code.
126        // Fall back to the public re-export form `{crate}::{ErrorName}` (2 segments).
127        let segments: Vec<&str> = normalized.split("::").collect();
128        if segments.len() > 2 {
129            let crate_name = segments[0];
130            let error_name = segments[segments.len() - 1];
131            format!("{crate_name}::{error_name}")
132        } else {
133            normalized
134        }
135    };
136
137    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
138    let has_methods = !error.methods.is_empty();
139
140    // Pre-compute variants as (pattern, exc_name) tuples
141    let mut variants = Vec::new();
142    for variant in &error.variants {
143        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
144        let variant_exc_name = python_exception_name(&variant.name, &error.name);
145        variants.push((pattern, variant_exc_name));
146    }
147
148    crate::template_env::render(
149        "error_gen/pyo3_error_converter.jinja",
150        minijinja::context! {
151            rust_path => rust_path.as_str(),
152            fn_name => fn_name.as_str(),
153            error_name => error.name.as_str(),
154            variants => variants,
155            has_methods => has_methods,
156        },
157    )
158}
159
160/// Generate `m.add(...)` registration calls for each exception type.
161/// Uses Error-suffixed names for variant exceptions (N818 compliance).
162/// Prefixes names that would shadow Python builtins (A004 compliance).
163pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
164    let mut registrations = Vec::with_capacity(error.variants.len() + 1);
165
166    for variant in &error.variants {
167        let variant_exc_name = python_exception_name(&variant.name, &error.name);
168        if seen_registrations.insert(variant_exc_name.clone()) {
169            registrations.push(format!(
170                "    m.add(\"{}\", m.py().get_type::<{}>())?;",
171                variant_exc_name, variant_exc_name
172            ));
173        }
174    }
175
176    // Base exception
177    if seen_registrations.insert(error.name.clone()) {
178        registrations.push(format!(
179            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
180            error.name, error.name
181        ));
182    }
183
184    registrations
185}
186
187/// Return the converter function name for a given error type.
188pub fn converter_fn_name(error: &ErrorDef) -> String {
189    format!("{}_to_py_err", to_snake_case(&error.name))
190}
191
192/// Simple CamelCase to snake_case conversion.
193fn to_snake_case(s: &str) -> String {
194    let mut result = String::with_capacity(s.len() + 4);
195    for (i, c) in s.chars().enumerate() {
196        if c.is_uppercase() {
197            if i > 0 {
198                result.push('_');
199            }
200            result.push(c.to_ascii_lowercase());
201        } else {
202            result.push(c);
203        }
204    }
205    result
206}
207
208// ---------------------------------------------------------------------------
209// NAPI (Node.js) error generation
210// ---------------------------------------------------------------------------
211
212/// Generate a `JsError` enum with string constants for each error variant name.
213pub fn gen_napi_error_types(error: &ErrorDef) -> String {
214    // Pre-compute (const_name, variant_name) pairs
215    let mut variants = Vec::new();
216    let error_screaming = to_screaming_snake(&error.name);
217    for variant in &error.variants {
218        let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
219        variants.push((variant_const, variant.name.clone()));
220    }
221
222    crate::template_env::render(
223        "error_gen/napi_error_types.jinja",
224        minijinja::context! {
225            variants => variants,
226        },
227    )
228}
229
230/// Generate a converter function that maps a core error to `napi::Error`.
231pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
232    let rust_path = if error.rust_path.is_empty() {
233        format!("{core_import}::{}", error.name)
234    } else {
235        error.rust_path.replace('-', "_")
236    };
237
238    let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
239
240    // Pre-compute (pattern, variant_name) pairs
241    let mut variants = Vec::new();
242    for variant in &error.variants {
243        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
244        variants.push((pattern, variant.name.clone()));
245    }
246
247    crate::template_env::render(
248        "error_gen/napi_error_converter.jinja",
249        minijinja::context! {
250            rust_path => rust_path.as_str(),
251            fn_name => fn_name.as_str(),
252            variants => variants,
253        },
254    )
255}
256
257/// Return the NAPI converter function name for a given error type.
258pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
259    format!("{}_to_napi_err", to_snake_case(&error.name))
260}
261
262// ---------------------------------------------------------------------------
263// WASM (wasm-bindgen) error generation
264// ---------------------------------------------------------------------------
265
266/// Generate a converter function that maps a core error to a `JsValue` object
267/// with `code` (string) and `message` (string) fields, plus a private
268/// `error_code` helper that returns the variant code string.
269pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
270    let rust_path = if error.rust_path.is_empty() {
271        format!("{core_import}::{}", error.name)
272    } else {
273        error.rust_path.replace('-', "_")
274    };
275
276    let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
277    let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
278
279    // Pre-compute variants for error_code helper: (pattern, code) pairs
280    let mut code_variants = Vec::new();
281    for variant in &error.variants {
282        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
283        let code = to_snake_case(&variant.name);
284        code_variants.push((pattern, code));
285    }
286    let default_code = to_snake_case(&error.name);
287
288    let code_fn = crate::template_env::render(
289        "error_gen/wasm_error_code_fn.jinja",
290        minijinja::context! {
291            rust_path => rust_path.as_str(),
292            code_fn_name => code_fn_name.as_str(),
293            variants => code_variants,
294            default_code => default_code.as_str(),
295        },
296    );
297
298    let converter_fn = crate::template_env::render(
299        "error_gen/wasm_error_converter.jinja",
300        minijinja::context! {
301            rust_path => rust_path.as_str(),
302            fn_name => fn_name.as_str(),
303            code_fn_name => code_fn_name.as_str(),
304        },
305    );
306
307    format!("{}\n\n{}", code_fn, converter_fn)
308}
309
310/// Return the WASM converter function name for a given error type.
311pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
312    format!("{}_to_js_value", to_snake_case(&error.name))
313}
314
315/// Generate a `#[wasm_bindgen]` opaque struct for an error type together with an
316/// `impl` block that exposes the whitelisted introspection methods
317/// (`status_code`, `is_transient`, `error_type`) declared in `error.methods`.
318///
319/// The struct follows the same `pub(crate) inner: CoreType` convention used by
320/// all other opaque WASM handles in the codebase.
321///
322/// `wasm_prefix` is the full WASM type prefix string (from `config.wasm_type_prefix()`,
323/// e.g. `"Wasm"`).  The generated struct name is `{wasm_prefix}{error.name}`
324/// (e.g. `WasmLiterLlmError`).
325///
326/// Returns an empty string when `error.methods` is empty so callers can
327/// unconditionally append the result without adding noise to the output file.
328pub fn gen_wasm_error_methods(error: &ErrorDef, core_import: &str, wasm_prefix: &str) -> String {
329    if error.methods.is_empty() {
330        return String::new();
331    }
332
333    let rust_path = if error.rust_path.is_empty() {
334        format!("{core_import}::{}", error.name)
335    } else {
336        error.rust_path.replace('-', "_")
337    };
338
339    // The struct name mirrors the convention used for other WASM opaque handles:
340    // `{wasm_type_prefix}{ErrorName}` (e.g. prefix="Wasm", name="LiterLlmError" → "WasmLiterLlmError").
341    let wasm_struct_name = format!("{wasm_prefix}{}", error.name);
342
343    let struct_def = format!(
344        "/// Opaque WASM handle for [`{rust_path}`] that exposes introspection methods.\n\
345         #[wasm_bindgen]\n\
346         pub struct {wasm_struct_name} {{\n\
347             pub(crate) inner: {rust_path},\n\
348         }}"
349    );
350
351    let mut method_bodies = Vec::new();
352    for method in &error.methods {
353        let method_src = match method.name.as_str() {
354            "status_code" => "    /// HTTP status code for this error variant.\n    \
355                 #[wasm_bindgen(js_name = \"statusCode\")]\n    \
356                 pub fn status_code(&self) -> u16 {\n        \
357                 self.inner.status_code()\n    }"
358                .to_string(),
359            "is_transient" => "    /// Returns `true` if the error is transient and a retry may succeed.\n    \
360                 #[wasm_bindgen(js_name = \"isTransient\")]\n    \
361                 pub fn is_transient(&self) -> bool {\n        \
362                 self.inner.is_transient()\n    }"
363                .to_string(),
364            "error_type" => "    /// Returns a machine-readable error category string.\n    \
365                 #[wasm_bindgen(js_name = \"errorType\")]\n    \
366                 pub fn error_type(&self) -> String {\n        \
367                 self.inner.error_type().to_string()\n    }"
368                .to_string(),
369            other => {
370                // Unrecognised whitelisted method — emit a dead-code stub so the binding
371                // still compiles and the method name remains visible to reviewers.
372                format!(
373                    "    // TODO: emit binding for method `{other}` on `{wasm_struct_name}`\n    \
374                     #[allow(dead_code)]\n    \
375                     pub fn {other}(&self) {{}}"
376                )
377            }
378        };
379        method_bodies.push(method_src);
380    }
381
382    let impl_block = format!(
383        "#[wasm_bindgen]\nimpl {wasm_struct_name} {{\n{}\n}}",
384        method_bodies.join("\n\n")
385    );
386
387    format!("{struct_def}\n\n{impl_block}")
388}
389
390// ---------------------------------------------------------------------------
391// PyO3 (Python) error methods — companion class
392// ---------------------------------------------------------------------------
393
394/// Generate a `#[pyclass]` companion struct for error introspection, exposing
395/// the whitelisted methods as `#[getter]` properties.
396///
397/// `pyo3::create_exception!` types are zero-sized marker types that do not
398/// implement `PyClass`, so `#[pymethods]` blocks cannot be added to them
399/// directly. Instead we emit a separate `{ErrorName}Info` `#[pyclass]` that
400/// stores the three fields and is built by a `#[pyfunction]` free function
401/// which extracts the values from the exception's args tuple (indices 1–3,
402/// which the converter already populates).
403///
404/// Returns an empty string when `error.methods` is empty.
405pub fn gen_pyo3_error_methods_impl(error: &ErrorDef) -> String {
406    if error.methods.is_empty() {
407        return String::new();
408    }
409
410    let struct_name = format!("{}Info", error.name);
411    let snake_name = to_snake_case(&error.name);
412    let fn_name = format!("{snake_name}_info");
413
414    let mut fields = Vec::new();
415    let mut getters = Vec::new();
416
417    let has_status_code = error.methods.iter().any(|m| m.name == "status_code");
418    let has_is_transient = error.methods.iter().any(|m| m.name == "is_transient");
419    let has_error_type = error.methods.iter().any(|m| m.name == "error_type");
420
421    if has_status_code {
422        fields.push("    pub status_code: u16,".to_string());
423        getters.push(
424            concat!(
425                "    /// HTTP status code for this error (0 means no associated status).\n",
426                "    #[getter]\n",
427                "    fn status_code(&self) -> u16 {\n",
428                "        self.status_code\n",
429                "    }",
430            )
431            .to_string(),
432        );
433    }
434    if has_is_transient {
435        fields.push("    pub is_transient: bool,".to_string());
436        getters.push(
437            concat!(
438                "    /// Returns `true` if the error is transient and a retry may succeed.\n",
439                "    #[getter]\n",
440                "    fn is_transient(&self) -> bool {\n",
441                "        self.is_transient\n",
442                "    }",
443            )
444            .to_string(),
445        );
446    }
447    if has_error_type {
448        fields.push("    pub error_type: String,".to_string());
449        getters.push(
450            concat!(
451                "    /// Machine-readable error category string for matching and logging.\n",
452                "    #[getter]\n",
453                "    fn error_type(&self) -> String {\n",
454                "        self.error_type.clone()\n",
455                "    }",
456            )
457            .to_string(),
458        );
459    }
460    // Emit TODO stubs for any other whitelisted methods.
461    for method in &error.methods {
462        match method.name.as_str() {
463            "status_code" | "is_transient" | "error_type" => {}
464            other => getters.push(format!(
465                "    // TODO: emit getter for method `{other}` on `{struct_name}`"
466            )),
467        }
468    }
469
470    // The converter stores (msg, status_code, is_transient, error_type) at args indices 0-3.
471    // We extract via getattr("args") which returns Option<Bound<PyAny>>.
472    let mut ctor_fields = Vec::new();
473    if has_status_code {
474        ctor_fields.push(
475            "        status_code: args\n\
476             \x20           .as_ref()\n\
477             \x20           .and_then(|a| a.get_item(1).ok())\n\
478             \x20           .and_then(|v| v.extract::<u16>().ok())\n\
479             \x20           .unwrap_or(0),",
480        );
481    }
482    if has_is_transient {
483        ctor_fields.push(
484            "        is_transient: args\n\
485             \x20           .as_ref()\n\
486             \x20           .and_then(|a| a.get_item(2).ok())\n\
487             \x20           .and_then(|v| v.extract::<bool>().ok())\n\
488             \x20           .unwrap_or(false),",
489        );
490    }
491    if has_error_type {
492        ctor_fields.push(
493            "        error_type: args\n\
494             \x20           .as_ref()\n\
495             \x20           .and_then(|a| a.get_item(3).ok())\n\
496             \x20           .and_then(|v| v.extract::<String>().ok())\n\
497             \x20           .unwrap_or_default(),",
498        );
499    }
500
501    let struct_def = format!(
502        "#[pyclass(name = \"{struct_name}\")]\npub struct {struct_name} {{\n{}\n}}",
503        fields.join("\n")
504    );
505
506    let impl_block = format!("#[pymethods]\nimpl {struct_name} {{\n{}\n}}", getters.join("\n\n"));
507
508    let free_fn = format!(
509        "/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
510         ///\n\
511         /// The converter stores `(message, status_code, is_transient, error_type)` in the\n\
512         /// exception args tuple; this function extracts those values at indices 1–3.\n\
513         #[pyfunction]\n\
514         pub fn {fn_name}(err: pyo3::Bound<'_, pyo3::types::PyAny>) -> {struct_name} {{\n\
515             let args = err.getattr(\"args\").ok();\n\
516             {struct_name} {{\n\
517         {ctor}\n\
518             }}\n\
519         }}",
520        error_name = error.name,
521        ctor = ctor_fields.join("\n"),
522    );
523
524    format!("{struct_def}\n\n{impl_block}\n\n{free_fn}")
525}
526
527/// Returns `true` when the error has whitelisted introspection methods that
528/// require extended constructor args `(msg, status_code, is_transient, error_type)`.
529pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
530    !error.methods.is_empty()
531}
532
533/// Return the name of the companion info struct for an error type.
534pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
535    format!("{}Info", error.name)
536}
537
538/// Return the name of the free function that builds the companion info struct.
539pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
540    format!("{}_info", to_snake_case(&error.name))
541}
542
543// ---------------------------------------------------------------------------
544// NAPI (Node.js) error methods class
545// ---------------------------------------------------------------------------
546
547/// Generate a `#[napi]` class struct for the error type that stores the
548/// whitelisted introspection method values as fields and exposes them as methods.
549///
550/// Returns an empty string when `error.methods` is empty.
551pub fn gen_napi_error_class(error: &ErrorDef, core_import: &str) -> String {
552    if error.methods.is_empty() {
553        return String::new();
554    }
555
556    let rust_path = if error.rust_path.is_empty() {
557        format!("{core_import}::{}", error.name)
558    } else {
559        error.rust_path.replace('-', "_")
560    };
561
562    let struct_name = format!("Js{}Info", error.name);
563
564    let mut fields = Vec::new();
565    let mut methods = Vec::new();
566    let mut ctor_assignments = Vec::new();
567
568    for method in &error.methods {
569        match method.name.as_str() {
570            "status_code" => {
571                fields.push("    pub status_code: u16,".to_string());
572                methods.push(
573                    concat!(
574                        "    /// HTTP status code for this error (0 means no associated status).\n",
575                        "    #[napi(js_name = \"statusCode\")]\n",
576                        "    pub fn status_code(&self) -> u16 {\n",
577                        "        self.status_code\n",
578                        "    }",
579                    )
580                    .to_string(),
581                );
582                ctor_assignments.push("        status_code: e.status_code(),".to_string());
583            }
584            "is_transient" => {
585                fields.push("    pub is_transient: bool,".to_string());
586                methods.push(
587                    concat!(
588                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
589                        "    #[napi(js_name = \"isTransient\")]\n",
590                        "    pub fn is_transient(&self) -> bool {\n",
591                        "        self.is_transient\n",
592                        "    }",
593                    )
594                    .to_string(),
595                );
596                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
597            }
598            "error_type" => {
599                fields.push("    pub error_type: String,".to_string());
600                methods.push(
601                    concat!(
602                        "    /// Machine-readable error category string for matching and logging.\n",
603                        "    #[napi(js_name = \"errorType\")]\n",
604                        "    pub fn error_type(&self) -> String {\n",
605                        "        self.error_type.clone()\n",
606                        "    }",
607                    )
608                    .to_string(),
609                );
610                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
611            }
612            other => {
613                methods.push(format!("    // TODO: emit #[napi] method `{other}` on `{struct_name}`"));
614            }
615        }
616    }
617
618    let struct_def = format!("#[napi]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
619
620    let from_fn = format!(
621        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
622        ctor_assignments.join("\n"),
623        snake_name = to_snake_case(&error.name),
624    );
625
626    let impl_block = format!("#[napi]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
627
628    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
629}
630
631// ---------------------------------------------------------------------------
632// Magnus (Ruby) error methods struct
633// ---------------------------------------------------------------------------
634
635/// Generate a Magnus-wrapped Rust struct that stores the whitelisted error
636/// introspection method return values and exposes them as Ruby instance methods.
637///
638/// Returns an empty string when `error.methods` is empty.
639pub fn gen_magnus_error_methods_struct(error: &ErrorDef, core_import: &str) -> String {
640    if error.methods.is_empty() {
641        return String::new();
642    }
643
644    let rust_path = if error.rust_path.is_empty() {
645        format!("{core_import}::{}", error.name)
646    } else {
647        error.rust_path.replace('-', "_")
648    };
649
650    let struct_name = format!("{}Info", error.name);
651
652    let mut fields = Vec::new();
653    let mut methods = Vec::new();
654    let mut ctor_assignments = Vec::new();
655
656    for method in &error.methods {
657        match method.name.as_str() {
658            "status_code" => {
659                fields.push("    status_code: u16,".to_string());
660                methods.push(
661                    concat!(
662                        "    /// HTTP status code for this error (0 means no associated status).\n",
663                        "    pub fn status_code(&self) -> u16 {\n",
664                        "        self.status_code\n",
665                        "    }",
666                    )
667                    .to_string(),
668                );
669                ctor_assignments.push("        status_code: e.status_code(),".to_string());
670            }
671            "is_transient" => {
672                fields.push("    is_transient: bool,".to_string());
673                methods.push(
674                    concat!(
675                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
676                        "    pub fn transient(&self) -> bool {\n",
677                        "        self.is_transient\n",
678                        "    }",
679                    )
680                    .to_string(),
681                );
682                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
683            }
684            "error_type" => {
685                fields.push("    error_type: String,".to_string());
686                methods.push(
687                    concat!(
688                        "    /// Machine-readable error category string for matching and logging.\n",
689                        "    pub fn error_type(&self) -> String {\n",
690                        "        self.error_type.clone()\n",
691                        "    }",
692                    )
693                    .to_string(),
694                );
695                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
696            }
697            other => {
698                methods.push(format!("    // TODO: emit method `{other}` on `{struct_name}`"));
699            }
700        }
701    }
702
703    let struct_def = format!(
704        "#[magnus::wrap(class = \"{struct_name}\", free_immediately, size)]\npub struct {struct_name} {{\n{}\n}}",
705        fields.join("\n")
706    );
707
708    let from_fn = format!(
709        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
710        ctor_assignments.join("\n"),
711        snake_name = to_snake_case(&error.name),
712    );
713
714    let impl_block = format!("impl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
715
716    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
717}
718
719/// Returns the `define_class` + `define_method` registration lines for the error info struct.
720pub fn magnus_error_methods_registrations(error: &ErrorDef) -> Vec<String> {
721    if error.methods.is_empty() {
722        return Vec::new();
723    }
724    let struct_name = format!("{}Info", error.name);
725    let snake = to_snake_case(&error.name);
726    let class_var = format!("{snake}_info_class");
727    let mut lines = Vec::new();
728    lines.push(format!(
729        "    let {class_var} = module.define_class(\"{struct_name}\", ruby.class_object())?;"
730    ));
731    for method in &error.methods {
732        let (ruby_name, rust_fn) = if method.name == "is_transient" {
733            ("transient?".to_string(), "transient".to_string())
734        } else {
735            (method.name.clone(), method.name.clone())
736        };
737        lines.push(format!(
738            "    {class_var}.define_method(\"{ruby_name}\", magnus::method!({struct_name}::{rust_fn}, 0))?;"
739        ));
740    }
741    lines
742}
743
744// ---------------------------------------------------------------------------
745// PHP (ext-php-rs) error generation
746// ---------------------------------------------------------------------------
747
748/// Generate a converter function that maps a core error to `PhpException`.
749pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
750    let rust_path = if error.rust_path.is_empty() {
751        format!("{core_import}::{}", error.name)
752    } else {
753        error.rust_path.replace('-', "_")
754    };
755
756    let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
757
758    // Pre-compute (pattern, variant_name) pairs
759    let mut variants = Vec::new();
760    for variant in &error.variants {
761        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
762        variants.push((pattern, variant.name.clone()));
763    }
764
765    crate::template_env::render(
766        "error_gen/php_error_converter.jinja",
767        minijinja::context! {
768            rust_path => rust_path.as_str(),
769            fn_name => fn_name.as_str(),
770            variants => variants,
771        },
772    )
773}
774
775/// Return the PHP converter function name for a given error type.
776pub fn php_converter_fn_name(error: &ErrorDef) -> String {
777    format!("{}_to_php_err", to_snake_case(&error.name))
778}
779
780/// Generate a `#[php_class]` + `#[php_impl]` block for the error type, storing
781/// the whitelisted introspection method return values as Rust fields exposed via
782/// `#[php_method]`.
783///
784/// Returns an empty string when `error.methods` is empty.
785pub fn gen_php_error_methods_impl(error: &ErrorDef, core_import: &str) -> String {
786    if error.methods.is_empty() {
787        return String::new();
788    }
789
790    let rust_path = if error.rust_path.is_empty() {
791        format!("{core_import}::{}", error.name)
792    } else {
793        error.rust_path.replace('-', "_")
794    };
795
796    let struct_name = format!("{}Info", error.name);
797
798    let mut fields = Vec::new();
799    let mut methods = Vec::new();
800    let mut ctor_assignments = Vec::new();
801
802    for method in &error.methods {
803        match method.name.as_str() {
804            "status_code" => {
805                fields.push("    pub status_code: u16,".to_string());
806                methods.push(
807                    concat!(
808                        "    /// HTTP status code for this error (0 means no associated status).\n",
809                        "    pub fn status_code(&self) -> u16 {\n",
810                        "        self.status_code\n",
811                        "    }",
812                    )
813                    .to_string(),
814                );
815                ctor_assignments.push("        status_code: e.status_code(),".to_string());
816            }
817            "is_transient" => {
818                fields.push("    pub is_transient: bool,".to_string());
819                methods.push(
820                    concat!(
821                        "    /// Returns `true` if the error is transient and a retry may succeed.\n",
822                        "    pub fn is_transient(&self) -> bool {\n",
823                        "        self.is_transient\n",
824                        "    }",
825                    )
826                    .to_string(),
827                );
828                ctor_assignments.push("        is_transient: e.is_transient(),".to_string());
829            }
830            "error_type" => {
831                fields.push("    pub error_type: String,".to_string());
832                methods.push(
833                    concat!(
834                        "    /// Machine-readable error category string for matching and logging.\n",
835                        "    pub fn error_type(&self) -> String {\n",
836                        "        self.error_type.clone()\n",
837                        "    }",
838                    )
839                    .to_string(),
840                );
841                ctor_assignments.push("        error_type: e.error_type().to_string(),".to_string());
842            }
843            other => {
844                methods.push(format!("    // TODO: emit method for `{other}` on `{struct_name}`"));
845            }
846        }
847    }
848
849    let struct_def = format!("#[php_class]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
850
851    let from_fn = format!(
852        "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n    {struct_name} {{\n{}\n    }}\n}}",
853        ctor_assignments.join("\n"),
854        snake_name = to_snake_case(&error.name),
855    );
856
857    let impl_block = format!("#[php_impl]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
858
859    format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
860}
861
862// ---------------------------------------------------------------------------
863// Magnus (Ruby) error generation
864// ---------------------------------------------------------------------------
865
866/// Generate a converter function that maps a core error to `magnus::Error`.
867pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
868    let rust_path = if error.rust_path.is_empty() {
869        format!("{core_import}::{}", error.name)
870    } else {
871        error.rust_path.replace('-', "_")
872    };
873
874    let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
875
876    crate::template_env::render(
877        "error_gen/magnus_error_converter.jinja",
878        minijinja::context! {
879            rust_path => rust_path.as_str(),
880            fn_name => fn_name.as_str(),
881        },
882    )
883}
884
885/// Return the Magnus converter function name for a given error type.
886pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
887    format!("{}_to_magnus_err", to_snake_case(&error.name))
888}
889
890// ---------------------------------------------------------------------------
891// Rustler (Elixir) error generation
892// ---------------------------------------------------------------------------
893
894/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
895pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
896    let rust_path = if error.rust_path.is_empty() {
897        format!("{core_import}::{}", error.name)
898    } else {
899        error.rust_path.replace('-', "_")
900    };
901
902    let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
903
904    crate::template_env::render(
905        "error_gen/rustler_error_converter.jinja",
906        minijinja::context! {
907            rust_path => rust_path.as_str(),
908            fn_name => fn_name.as_str(),
909        },
910    )
911}
912
913/// Return the Rustler converter function name for a given error type.
914pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
915    format!("{}_to_rustler_err", to_snake_case(&error.name))
916}
917
918// ---------------------------------------------------------------------------
919// FFI (C) error code generation
920// ---------------------------------------------------------------------------
921
922/// Generate a C enum of error codes plus an error-message function declaration.
923///
924/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
925/// per variant, plus a function that returns the default message for a given code.
926pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
927    let prefix = to_screaming_snake(&error.name);
928    let prefix_lower = to_snake_case(&error.name);
929
930    // Pre-compute (variant_screaming, index) pairs
931    let mut variant_variants = Vec::new();
932    for (i, variant) in error.variants.iter().enumerate() {
933        let variant_screaming = to_screaming_snake(&variant.name);
934        variant_variants.push((variant_screaming, (i + 1).to_string()));
935    }
936
937    crate::template_env::render(
938        "error_gen/ffi_error_codes.jinja",
939        minijinja::context! {
940            error_name => error.name.as_str(),
941            prefix => prefix.as_str(),
942            prefix_lower => prefix_lower.as_str(),
943            variant_variants => variant_variants,
944        },
945    )
946}
947
948/// Generate `#[no_mangle] extern "C"` helper functions for the whitelisted
949/// introspection methods (`status_code`, `is_transient`, `error_type`) declared
950/// in `error.methods`.
951///
952/// Each function follows the opaque-pointer convention: accepts a
953/// `*const {rust_path}` (null-checked before dereference) and returns the
954/// method's value. For `error_type` an additional `*_error_type_free` companion
955/// is emitted so callers can release the `CString`-allocated memory.
956///
957/// Returns an empty string when `error.methods` is empty.
958pub fn gen_ffi_error_methods(error: &ErrorDef, core_import: &str, api_prefix: &str) -> String {
959    if error.methods.is_empty() {
960        return String::new();
961    }
962
963    let rust_path = if error.rust_path.is_empty() {
964        format!("{core_import}::{}", error.name)
965    } else {
966        error.rust_path.replace('-', "_")
967    };
968
969    let error_snake = to_snake_case(&error.name);
970    let mut items: Vec<String> = Vec::new();
971
972    for method in &error.methods {
973        match method.name.as_str() {
974            "status_code" => {
975                let fn_name = format!("{api_prefix}_{error_snake}_status_code");
976                items.push(format!(
977                    "/// Return the HTTP status code for the error pointed to by `err`.\n\
978                     /// Returns `0` if `err` is null.\n\
979                     #[no_mangle]\n\
980                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> u16 {{\n\
981                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
982                         // allocated by this library, or is null.\n\
983                         if err.is_null() {{\n\
984                             return 0;\n\
985                         }}\n\
986                         (*err).status_code()\n\
987                     }}"
988                ));
989            }
990            "is_transient" => {
991                let fn_name = format!("{api_prefix}_{error_snake}_is_transient");
992                items.push(format!(
993                    "/// Return whether the error pointed to by `err` is transient.\n\
994                     /// Returns `false` if `err` is null.\n\
995                     #[no_mangle]\n\
996                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> bool {{\n\
997                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
998                         // allocated by this library, or is null.\n\
999                         if err.is_null() {{\n\
1000                             return false;\n\
1001                         }}\n\
1002                         (*err).is_transient()\n\
1003                     }}"
1004                ));
1005            }
1006            "error_type" => {
1007                let fn_name = format!("{api_prefix}_{error_snake}_error_type");
1008                let free_fn_name = format!("{fn_name}_free");
1009                items.push(format!(
1010                    "/// Return the machine-readable error category string for the error pointed\n\
1011                     /// to by `err` as a heap-allocated, NUL-terminated C string.\n\
1012                     /// The caller must free the returned pointer with `{free_fn_name}`.\n\
1013                     /// Returns a null pointer if `err` is null.\n\
1014                     #[no_mangle]\n\
1015                     pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> *mut std::ffi::c_char {{\n\
1016                         // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
1017                         // allocated by this library, or is null.\n\
1018                         if err.is_null() {{\n\
1019                             return std::ptr::null_mut();\n\
1020                         }}\n\
1021                         let s = (*err).error_type();\n\
1022                         // SAFETY: `error_type()` returns a `'static str` containing no NUL bytes.\n\
1023                         std::ffi::CString::new(s)\n\
1024                             .map(|c| c.into_raw())\n\
1025                             .unwrap_or(std::ptr::null_mut())\n\
1026                     }}\n\n\
1027                     /// Free a string previously returned by `{fn_name}`.\n\
1028                     /// Passing a null pointer is a no-op.\n\
1029                     #[no_mangle]\n\
1030                     pub unsafe extern \"C\" fn {free_fn_name}(ptr: *mut std::ffi::c_char) {{\n\
1031                         // SAFETY: `ptr` was allocated by `CString::into_raw` inside\n\
1032                         // `{fn_name}` and is now being reclaimed by the matching\n\
1033                         // `CString::from_raw`.  Passing null is explicitly allowed.\n\
1034                         if !ptr.is_null() {{\n\
1035                             drop(std::ffi::CString::from_raw(ptr));\n\
1036                         }}\n\
1037                     }}"
1038                ));
1039            }
1040            other => {
1041                // Unknown whitelisted method — emit a comment so it is visible in review.
1042                items.push(format!(
1043                    "// TODO: emit FFI helper for method `{other}` on `{rust_path}`"
1044                ));
1045            }
1046        }
1047    }
1048
1049    items.join("\n\n")
1050}
1051
1052// ---------------------------------------------------------------------------
1053// Go error type generation
1054// ---------------------------------------------------------------------------
1055
1056/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
1057///
1058/// `pkg_name` is the Go package name (e.g. `"literllm"`). When the error struct
1059/// name starts with the package name (case-insensitively), the package-name
1060/// prefix is stripped to avoid the revive `exported` stutter lint error
1061/// (e.g. `LiterLlmError` in package `literllm` → exported as `Error`).
1062pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
1063    let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
1064    let structured = gen_go_error_struct(error, pkg_name);
1065    format!("{}\n\n{}", sentinels, structured)
1066}
1067
1068/// Generate a single consolidated `var (...)` block of Go sentinel errors
1069/// across multiple `ErrorDef`s.
1070///
1071/// When the same variant name appears in more than one `ErrorDef` (e.g. both
1072/// `GraphQLError` and `SchemaError` define `ValidationError`), the colliding
1073/// const names are disambiguated by prefixing with the parent error type's
1074/// stripped base name. For example, `GraphQLError::ValidationError` and
1075/// `SchemaError::ValidationError` become `ErrGraphQLValidationError` and
1076/// `ErrSchemaValidationError`. Variant names that are unique across all
1077/// errors are emitted as plain `Err{Variant}` consts.
1078pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
1079    if errors.is_empty() {
1080        return String::new();
1081    }
1082    let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1083    for err in errors {
1084        for v in &err.variants {
1085            *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
1086        }
1087    }
1088    let mut seen = std::collections::HashSet::new();
1089    let mut sentinels = Vec::new();
1090    for err in errors {
1091        let parent_base = error_base_prefix(&err.name);
1092        for variant in &err.variants {
1093            let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
1094            let const_name = if collides {
1095                format!("Err{}{}", parent_base, variant.name)
1096            } else {
1097                format!("Err{}", variant.name)
1098            };
1099            if !seen.insert(const_name.clone()) {
1100                continue;
1101            }
1102            let msg = variant_display_message(variant);
1103            sentinels.push((const_name, msg));
1104        }
1105    }
1106
1107    crate::template_env::render(
1108        "error_gen/go_sentinel_errors.jinja",
1109        minijinja::context! {
1110            sentinels => sentinels,
1111        },
1112    )
1113}
1114
1115/// Generate the structured error type (struct + Error() method) for a single
1116/// error definition. Sentinel errors are emitted separately by
1117/// [`gen_go_sentinel_errors`].
1118///
1119/// When `error.methods` is non-empty, each whitelisted introspection method
1120/// produces an exported struct field of the matching Go type plus a receiver
1121/// method that returns that field.
1122pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
1123    let go_type_name = strip_package_prefix(&error.name, pkg_name);
1124
1125    // Build per-method info for the template.
1126    // Each entry: { field_name, go_type, method_name, doc }
1127    // field_name: PascalCase exported field (e.g. StatusCode)
1128    // go_type:    Go type string (uint16 / bool / string)
1129    // method_name: exported Go method name (e.g. StatusCode)
1130    let methods: Vec<serde_json::Value> = error
1131        .methods
1132        .iter()
1133        .map(|m| {
1134            let go_type = typeref_to_go_type(&m.return_type);
1135            let method_name = to_pascal_case(&m.name);
1136            // Collapse multi-line rustdoc to a single-line summary so the template's
1137            // `// {{ method_name }} returns {{ doc }}.` does not emit unprefixed
1138            // continuation lines (markdown body, fenced code, `# Examples`) which
1139            // Go rejects as syntax errors.
1140            let doc_summary = if m.doc.is_empty() {
1141                String::new()
1142            } else {
1143                let first = crate::doc_emission::doc_first_paragraph_joined(&m.doc);
1144                first.trim_end_matches('.').trim_end().to_string()
1145            };
1146            serde_json::json!({
1147                "field_name": method_name,
1148                "go_type": go_type,
1149                "method_name": method_name,
1150                "doc": doc_summary,
1151            })
1152        })
1153        .collect();
1154    let has_methods = !methods.is_empty();
1155
1156    crate::template_env::render(
1157        "error_gen/go_error_struct.jinja",
1158        minijinja::context! {
1159            go_type_name => go_type_name.as_str(),
1160            methods => methods,
1161            has_methods => has_methods,
1162        },
1163    )
1164}
1165
1166/// Map an IR `TypeRef` to a Go type string for error introspection method returns.
1167/// Only the primitive subset needed for the whitelisted methods is handled;
1168/// everything else falls back to `string`.
1169fn typeref_to_go_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1170    use alef_core::ir::{PrimitiveType, TypeRef};
1171    match ty {
1172        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1173        TypeRef::Primitive(PrimitiveType::U8) => "uint8",
1174        TypeRef::Primitive(PrimitiveType::U16) => "uint16",
1175        TypeRef::Primitive(PrimitiveType::U32) => "uint32",
1176        TypeRef::Primitive(PrimitiveType::U64) => "uint64",
1177        TypeRef::Primitive(PrimitiveType::I8) => "int8",
1178        TypeRef::Primitive(PrimitiveType::I16) => "int16",
1179        TypeRef::Primitive(PrimitiveType::I32) => "int32",
1180        TypeRef::Primitive(PrimitiveType::I64) => "int64",
1181        TypeRef::Primitive(PrimitiveType::F32) => "float32",
1182        TypeRef::Primitive(PrimitiveType::F64) => "float64",
1183        TypeRef::String => "string",
1184        _ => "string",
1185    }
1186}
1187
1188/// Convert a snake_case or camelCase name to PascalCase.
1189fn to_pascal_case(s: &str) -> String {
1190    s.split('_')
1191        .map(|word| {
1192            let mut chars = word.chars();
1193            match chars.next() {
1194                None => String::new(),
1195                Some(first) => first.to_uppercase().to_string() + chars.as_str(),
1196            }
1197        })
1198        .collect()
1199}
1200
1201/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
1202///
1203/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
1204/// and that stutters` when a type name begins with the package name. This function
1205/// removes the prefix when it matches (case-insensitively) so that the exported name
1206/// does not repeat the package name.
1207///
1208/// Examples:
1209/// - `("LiterLlmError", "literllm")` → `"Error"` (lowercased `literllm` is a prefix
1210///   of lowercased `literllmerror`)
1211/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
1212fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
1213    let type_lower = type_name.to_lowercase();
1214    let pkg_lower = pkg_name.to_lowercase();
1215    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
1216        // Retain the original casing for the suffix part.
1217        type_name[pkg_lower.len()..].to_string()
1218    } else {
1219        type_name.to_string()
1220    }
1221}
1222
1223// ---------------------------------------------------------------------------
1224// Java error type generation
1225// ---------------------------------------------------------------------------
1226
1227/// Generate Java exception sub-classes for each error variant.
1228///
1229/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
1230/// class followed by one per-variant exception.  The caller writes each to a
1231/// separate `.java` file.
1232///
1233/// When `error.methods` is non-empty, the base exception class gains private
1234/// final fields, an extended constructor, and public getter methods for each
1235/// whitelisted introspection method.  Variant classes delegate via `super(…)`.
1236pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
1237    let mut files = Vec::with_capacity(error.variants.len() + 1);
1238
1239    // Base exception class
1240    let base_name = format!("{}Exception", error.name);
1241    let doc_lines: Vec<&str> = error.doc.lines().collect();
1242
1243    // Build per-method info for the template.
1244    // Each entry: { field_name, java_type, getter_name, doc }
1245    let method_infos: Vec<serde_json::Value> = error
1246        .methods
1247        .iter()
1248        .map(|m| {
1249            let java_type = typeref_to_java_type(&m.return_type);
1250            let getter_name = java_getter_name(&m.name);
1251            let field_name = java_field_name(&m.name);
1252            let default_value = java_default_value(&m.return_type);
1253            serde_json::json!({
1254                "field_name": field_name,
1255                "java_type": java_type,
1256                "getter_name": getter_name,
1257                "default_value": default_value,
1258                "doc": m.doc,
1259            })
1260        })
1261        .collect();
1262    let has_methods = !method_infos.is_empty();
1263
1264    let base = crate::template_env::render(
1265        "error_gen/java_error_base.jinja",
1266        minijinja::context! {
1267            package => package,
1268            base_name => base_name.as_str(),
1269            doc => !error.doc.is_empty(),
1270            doc_lines => doc_lines,
1271            methods => method_infos,
1272            has_methods => has_methods,
1273        },
1274    );
1275    files.push((base_name.clone(), base));
1276
1277    // Per-variant exception classes
1278    for variant in &error.variants {
1279        let class_name = format!("{}Exception", variant.name);
1280        let doc_lines: Vec<&str> = variant.doc.lines().collect();
1281
1282        let content = crate::template_env::render(
1283            "error_gen/java_error_variant.jinja",
1284            minijinja::context! {
1285                package => package,
1286                class_name => class_name.as_str(),
1287                base_name => base_name.as_str(),
1288                doc => !variant.doc.is_empty(),
1289                doc_lines => doc_lines,
1290                has_methods => has_methods,
1291            },
1292        );
1293        files.push((class_name, content));
1294    }
1295
1296    files
1297}
1298
1299/// Map an IR `TypeRef` to a Java type string for error introspection getters.
1300fn typeref_to_java_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1301    use alef_core::ir::{PrimitiveType, TypeRef};
1302    match ty {
1303        TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
1304        TypeRef::Primitive(
1305            PrimitiveType::U8
1306            | PrimitiveType::I8
1307            | PrimitiveType::I16
1308            | PrimitiveType::U16
1309            | PrimitiveType::I32
1310            | PrimitiveType::U32,
1311        ) => "int",
1312        TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
1313        TypeRef::Primitive(PrimitiveType::F32) => "float",
1314        TypeRef::Primitive(PrimitiveType::F64) => "double",
1315        TypeRef::String => "String",
1316        _ => "String",
1317    }
1318}
1319
1320/// Convert a snake_case method name to a Java getter name.
1321/// E.g. `status_code` → `getStatusCode`, `is_transient` → `isTransient`.
1322fn java_getter_name(snake: &str) -> String {
1323    if let Some(rest) = snake.strip_prefix("is_") {
1324        // is_transient → isTransient
1325        let pascal = to_pascal_case(rest);
1326        format!("is{pascal}")
1327    } else {
1328        // status_code → getStatusCode, error_type → getErrorType
1329        let pascal = to_pascal_case(snake);
1330        format!("get{pascal}")
1331    }
1332}
1333
1334/// Convert a snake_case method name to a Java field name (camelCase).
1335/// E.g. `status_code` → `statusCode`, `is_transient` → `isTransient`.
1336fn java_field_name(snake: &str) -> String {
1337    let parts: Vec<&str> = snake.split('_').collect();
1338    if parts.is_empty() {
1339        return snake.to_string();
1340    }
1341    let mut out = parts[0].to_string();
1342    for part in &parts[1..] {
1343        let mut chars = part.chars();
1344        match chars.next() {
1345            None => {}
1346            Some(first) => {
1347                out.push_str(&first.to_uppercase().to_string());
1348                out.push_str(chars.as_str());
1349            }
1350        }
1351    }
1352    out
1353}
1354
1355/// Return the Java zero-value literal for a type (used in the no-args default constructor).
1356fn java_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1357    use alef_core::ir::{PrimitiveType, TypeRef};
1358    match ty {
1359        TypeRef::Primitive(PrimitiveType::Bool) => "false",
1360        TypeRef::String => "\"\"",
1361        _ => "0",
1362    }
1363}
1364
1365// ---------------------------------------------------------------------------
1366// C# error type generation
1367// ---------------------------------------------------------------------------
1368
1369/// Generate C# exception sub-classes for each error variant.
1370///
1371/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
1372/// class followed by one per-variant exception.  The caller writes each to a
1373/// separate `.cs` file.
1374///
1375/// `fallback_class` is the name of the generic library exception class (e.g.
1376/// `TreeSitterLanguagePackException`) that the base error class should extend so that
1377/// callers can `catch` the general library exception and catch all typed errors.
1378///
1379/// When `error.methods` is non-empty, the base exception class gains get-only
1380/// properties for each whitelisted introspection method.  Variant classes
1381/// delegate via `base(…)` and inherit the properties.
1382pub fn gen_csharp_error_types(
1383    error: &ErrorDef,
1384    namespace: &str,
1385    fallback_class: Option<&str>,
1386) -> Vec<(String, String)> {
1387    let mut files = Vec::with_capacity(error.variants.len() + 1);
1388
1389    let base_name = format!("{}Exception", error.name);
1390    // Inherit from the generic library exception when provided so that
1391    // `Assert.ThrowsAny<LibException>()` catches typed errors too.
1392    let base_parent = fallback_class.unwrap_or("Exception");
1393    let error_doc_lines: Vec<&str> = error.doc.lines().collect();
1394
1395    // Build per-method info for the template.
1396    // Each entry: { prop_name, cs_type, param_name, doc }
1397    let method_infos: Vec<serde_json::Value> = error
1398        .methods
1399        .iter()
1400        .map(|m| {
1401            let cs_type = typeref_to_csharp_type(&m.return_type);
1402            let prop_name = to_pascal_case(&m.name);
1403            let param_name = java_field_name(&m.name); // camelCase ctor parameter
1404            let default_value = csharp_default_value(&m.return_type);
1405            serde_json::json!({
1406                "prop_name": prop_name,
1407                "cs_type": cs_type,
1408                "param_name": param_name,
1409                "default_value": default_value,
1410                "doc": m.doc,
1411            })
1412        })
1413        .collect();
1414    let has_methods = !method_infos.is_empty();
1415
1416    // Base exception class
1417    {
1418        let out = crate::template_env::render(
1419            "error_gen/csharp_error_base.jinja",
1420            minijinja::context! {
1421                namespace => namespace,
1422                base_name => base_name.as_str(),
1423                base_parent => base_parent,
1424                doc => !error.doc.is_empty(),
1425                doc_lines => error_doc_lines,
1426                methods => method_infos,
1427                has_methods => has_methods,
1428            },
1429        );
1430        files.push((base_name.clone(), out));
1431    }
1432
1433    // Per-variant exception classes
1434    for variant in &error.variants {
1435        let class_name = format!("{}Exception", variant.name);
1436        let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
1437
1438        let out = crate::template_env::render(
1439            "error_gen/csharp_error_variant.jinja",
1440            minijinja::context! {
1441                namespace => namespace,
1442                class_name => class_name.as_str(),
1443                base_name => base_name.as_str(),
1444                doc => !variant.doc.is_empty(),
1445                doc_lines => variant_doc_lines,
1446                has_methods => has_methods,
1447            },
1448        );
1449        files.push((class_name, out));
1450    }
1451
1452    files
1453}
1454
1455/// Map an IR `TypeRef` to a C# type string for error introspection properties.
1456fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1457    use alef_core::ir::{PrimitiveType, TypeRef};
1458    match ty {
1459        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1460        TypeRef::Primitive(PrimitiveType::U8) => "byte",
1461        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
1462        TypeRef::Primitive(PrimitiveType::I16) => "short",
1463        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
1464        TypeRef::Primitive(PrimitiveType::I32) => "int",
1465        TypeRef::Primitive(PrimitiveType::U32) => "uint",
1466        TypeRef::Primitive(PrimitiveType::I64) => "long",
1467        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
1468        TypeRef::Primitive(PrimitiveType::F32) => "float",
1469        TypeRef::Primitive(PrimitiveType::F64) => "double",
1470        TypeRef::String => "string",
1471        _ => "string",
1472    }
1473}
1474
1475/// Return the C# zero-value literal for a type (used in the default constructor).
1476fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1477    use alef_core::ir::{PrimitiveType, TypeRef};
1478    match ty {
1479        TypeRef::Primitive(PrimitiveType::Bool) => "false",
1480        TypeRef::String => "string.Empty",
1481        _ => "0",
1482    }
1483}
1484
1485// ---------------------------------------------------------------------------
1486// Helpers
1487// ---------------------------------------------------------------------------
1488
1489/// Convert CamelCase to SCREAMING_SNAKE_CASE.
1490fn to_screaming_snake(s: &str) -> String {
1491    let mut result = String::with_capacity(s.len() + 4);
1492    for (i, c) in s.chars().enumerate() {
1493        if c.is_uppercase() {
1494            if i > 0 {
1495                result.push('_');
1496            }
1497            result.push(c.to_ascii_uppercase());
1498        } else {
1499            result.push(c.to_ascii_uppercase());
1500        }
1501    }
1502    result
1503}
1504
1505/// Well-known acronyms recognised by the doc/error renderers.
1506///
1507/// When emitting human-readable Display strings (e.g. for Go sentinel
1508/// `errors.New("...")`), variant names like `IoError` must render as
1509/// "IO error" — not "iO error" (the result of naive `lowercase first
1510/// character` after `to_snake_case`).
1511const TECHNICAL_ACRONYMS: &[&str] = &[
1512    "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
1513    "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
1514    "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
1515    "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
1516];
1517
1518/// Strip `thiserror`-style `{name}` placeholders from a Display template
1519/// without leaving stray punctuation.
1520///
1521/// Examples:
1522///
1523/// - `"OCR error: {message}"`           → `"OCR error"`
1524/// - `"plugin error in '{plugin_name}'"` → `"plugin error"`
1525/// - `"timed out after {elapsed_ms}ms (limit: {limit_ms}ms)"` → `"timed out"`
1526/// - `"I/O error: {0}"`                  → `"I/O error"`
1527///
1528/// Used by `variant_display_message` and binding error renderers
1529/// (Dart, Go, …) so the literal placeholder string never reaches
1530/// the runtime.
1531pub fn strip_thiserror_placeholders(template: &str) -> String {
1532    // Remove every `{...}` segment.
1533    let mut without_placeholders = String::with_capacity(template.len());
1534    let mut depth = 0u32;
1535    for ch in template.chars() {
1536        match ch {
1537            '{' => depth = depth.saturating_add(1),
1538            '}' => depth = depth.saturating_sub(1),
1539            other if depth == 0 => without_placeholders.push(other),
1540            _ => {}
1541        }
1542    }
1543    // Remove orphaned punctuation/whitespace immediately around the holes
1544    // (collapse runs of whitespace, drop trailing `:`/quote runs, drop
1545    // `(...)` shells that wrapped only placeholders).
1546    let mut compacted = String::with_capacity(without_placeholders.len());
1547    let mut last_was_space = false;
1548    for ch in without_placeholders.chars() {
1549        if ch.is_whitespace() {
1550            if !last_was_space && !compacted.is_empty() {
1551                compacted.push(' ');
1552            }
1553            last_was_space = true;
1554        } else {
1555            compacted.push(ch);
1556            last_was_space = false;
1557        }
1558    }
1559    // Trim trailing punctuation that only made sense before a placeholder.
1560    let trimmed = compacted
1561        .trim()
1562        .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
1563        .trim();
1564    // If we left e.g. `"limit: ms ms"` artefacts behind, collapse stray
1565    // empty parens / paired quotes.
1566    let cleaned = trimmed
1567        .replace("()", "")
1568        .replace("''", "")
1569        .replace("\"\"", "")
1570        .replace("  ", " ");
1571    cleaned.trim().to_string()
1572}
1573
1574/// Convert a PascalCase variant name into a human readable phrase that
1575/// preserves canonical acronyms.
1576///
1577/// Examples:
1578/// - `"IoError"`           → `"IO error"`
1579/// - `"OcrError"`          → `"OCR error"`
1580/// - `"PdfParse"`          → `"PDF parse"`
1581/// - `"HttpRequestFailed"` → `"HTTP request failed"`
1582/// - `"Other"`             → `"other"`
1583pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
1584    if variant_name.is_empty() {
1585        return String::new();
1586    }
1587    // Split into PascalCase words (each word starts with an uppercase letter).
1588    let bytes = variant_name.as_bytes();
1589    let mut words: Vec<&str> = Vec::new();
1590    let mut start = 0usize;
1591    for i in 1..bytes.len() {
1592        if bytes[i].is_ascii_uppercase() {
1593            words.push(&variant_name[start..i]);
1594            start = i;
1595        }
1596    }
1597    words.push(&variant_name[start..]);
1598
1599    let mut rendered: Vec<String> = Vec::with_capacity(words.len());
1600    for word in &words {
1601        let upper = word.to_ascii_uppercase();
1602        if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
1603            rendered.push(upper);
1604        } else {
1605            rendered.push(word.to_ascii_lowercase());
1606        }
1607    }
1608    rendered.join(" ")
1609}
1610
1611/// Generate a human-readable message for an error variant.
1612///
1613/// Uses the `message_template` if present, otherwise falls back to a
1614/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
1615fn variant_display_message(variant: &ErrorVariant) -> String {
1616    if let Some(tmpl) = &variant.message_template {
1617        let stripped = strip_thiserror_placeholders(tmpl);
1618        if stripped.is_empty() {
1619            return acronym_aware_snake_phrase(&variant.name);
1620        }
1621        // Preserve canonical acronyms but lowercase the first regular word so
1622        // Go's `lowercase first char` convention does not corrupt `IO` → `iO`.
1623        // Heuristic: if the first whitespace-delimited token is *not* already
1624        // a known acronym, downcase its first character.
1625        let mut tokens = stripped.splitn(2, ' ');
1626        let head = tokens.next().unwrap_or("").to_string();
1627        let tail = tokens.next().unwrap_or("");
1628        let head_upper = head.to_ascii_uppercase();
1629        let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
1630            head_upper
1631        } else {
1632            let mut chars = head.chars();
1633            match chars.next() {
1634                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1635                None => head,
1636            }
1637        };
1638        if tail.is_empty() {
1639            head_rendered
1640        } else {
1641            format!("{} {}", head_rendered, tail)
1642        }
1643    } else {
1644        acronym_aware_snake_phrase(&variant.name)
1645    }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650    use super::*;
1651    use alef_core::ir::{ErrorDef, ErrorVariant};
1652
1653    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
1654
1655    /// Helper to create a tuple-style field (e.g. `_0: String`).
1656    fn tuple_field(index: usize) -> FieldDef {
1657        FieldDef {
1658            name: format!("_{index}"),
1659            ty: TypeRef::String,
1660            optional: false,
1661            default: None,
1662            doc: String::new(),
1663            sanitized: false,
1664            is_boxed: false,
1665            type_rust_path: None,
1666            cfg: None,
1667            typed_default: None,
1668            core_wrapper: CoreWrapper::None,
1669            vec_inner_core_wrapper: CoreWrapper::None,
1670            newtype_wrapper: None,
1671            serde_rename: None,
1672            serde_flatten: false,
1673            binding_excluded: false,
1674            binding_exclusion_reason: None,
1675            original_type: None,
1676        }
1677    }
1678
1679    /// Helper to create a named struct field.
1680    fn named_field(name: &str) -> FieldDef {
1681        FieldDef {
1682            name: name.to_string(),
1683            ty: TypeRef::String,
1684            optional: false,
1685            default: None,
1686            doc: String::new(),
1687            sanitized: false,
1688            is_boxed: false,
1689            type_rust_path: None,
1690            cfg: None,
1691            typed_default: None,
1692            core_wrapper: CoreWrapper::None,
1693            vec_inner_core_wrapper: CoreWrapper::None,
1694            newtype_wrapper: None,
1695            serde_rename: None,
1696            serde_flatten: false,
1697            binding_excluded: false,
1698            binding_exclusion_reason: None,
1699            original_type: None,
1700        }
1701    }
1702
1703    fn sample_error() -> ErrorDef {
1704        ErrorDef {
1705            name: "ConversionError".to_string(),
1706            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
1707            original_rust_path: String::new(),
1708            variants: vec![
1709                ErrorVariant {
1710                    name: "ParseError".to_string(),
1711                    message_template: Some("HTML parsing error: {0}".to_string()),
1712                    fields: vec![tuple_field(0)],
1713                    has_source: false,
1714                    has_from: false,
1715                    is_unit: false,
1716                    doc: String::new(),
1717                },
1718                ErrorVariant {
1719                    name: "IoError".to_string(),
1720                    message_template: Some("I/O error: {0}".to_string()),
1721                    fields: vec![tuple_field(0)],
1722                    has_source: false,
1723                    has_from: true,
1724                    is_unit: false,
1725                    doc: String::new(),
1726                },
1727                ErrorVariant {
1728                    name: "Other".to_string(),
1729                    message_template: Some("Conversion error: {0}".to_string()),
1730                    fields: vec![tuple_field(0)],
1731                    has_source: false,
1732                    has_from: false,
1733                    is_unit: false,
1734                    doc: String::new(),
1735                },
1736            ],
1737            doc: "Error type for conversion operations.".to_string(),
1738            methods: vec![],
1739            binding_excluded: false,
1740            binding_exclusion_reason: None,
1741        }
1742    }
1743
1744    #[test]
1745    fn test_gen_error_types() {
1746        let error = sample_error();
1747        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
1748        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
1749        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
1750        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
1751        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
1752    }
1753
1754    #[test]
1755    fn test_gen_error_converter() {
1756        let error = sample_error();
1757        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
1758        assert!(
1759            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
1760        );
1761        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
1762        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
1763    }
1764
1765    #[test]
1766    fn test_gen_error_registration() {
1767        let error = sample_error();
1768        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
1769        assert_eq!(regs.len(), 4); // 3 variants + 1 base
1770        assert!(regs[0].contains("\"ParseError\""));
1771        assert!(regs[3].contains("\"ConversionError\""));
1772    }
1773
1774    #[test]
1775    fn test_unit_variant_pattern() {
1776        let error = ErrorDef {
1777            name: "MyError".to_string(),
1778            rust_path: "my_crate::MyError".to_string(),
1779            original_rust_path: String::new(),
1780            variants: vec![ErrorVariant {
1781                name: "NotFound".to_string(),
1782                message_template: Some("not found".to_string()),
1783                fields: vec![],
1784                has_source: false,
1785                has_from: false,
1786                is_unit: true,
1787                doc: String::new(),
1788            }],
1789            doc: String::new(),
1790            methods: vec![],
1791            binding_excluded: false,
1792            binding_exclusion_reason: None,
1793        };
1794        let output = gen_pyo3_error_converter(&error, "my_crate");
1795        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
1796        // Ensure no (..) for unit variants
1797        assert!(!output.contains("NotFound(..)"));
1798    }
1799
1800    #[test]
1801    fn test_struct_variant_pattern() {
1802        let error = ErrorDef {
1803            name: "MyError".to_string(),
1804            rust_path: "my_crate::MyError".to_string(),
1805            original_rust_path: String::new(),
1806            variants: vec![ErrorVariant {
1807                name: "Parsing".to_string(),
1808                message_template: Some("parsing error: {message}".to_string()),
1809                fields: vec![named_field("message")],
1810                has_source: false,
1811                has_from: false,
1812                is_unit: false,
1813                doc: String::new(),
1814            }],
1815            doc: String::new(),
1816            methods: vec![],
1817            binding_excluded: false,
1818            binding_exclusion_reason: None,
1819        };
1820        let output = gen_pyo3_error_converter(&error, "my_crate");
1821        assert!(
1822            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1823            "Struct variants must use {{ .. }} pattern, got:\n{output}"
1824        );
1825        // Ensure no (..) for struct variants
1826        assert!(!output.contains("Parsing(..)"));
1827    }
1828
1829    // -----------------------------------------------------------------------
1830    // NAPI tests
1831    // -----------------------------------------------------------------------
1832
1833    #[test]
1834    fn test_gen_napi_error_types() {
1835        let error = sample_error();
1836        let output = gen_napi_error_types(&error);
1837        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1838        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1839        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1840    }
1841
1842    #[test]
1843    fn test_gen_napi_error_converter() {
1844        let error = sample_error();
1845        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1846        assert!(
1847            output
1848                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1849        );
1850        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1851        assert!(output.contains("[ParseError]"));
1852        assert!(output.contains("[IoError]"));
1853        assert!(output.contains("#[allow(dead_code)]"));
1854    }
1855
1856    #[test]
1857    fn test_napi_unit_variant() {
1858        let error = ErrorDef {
1859            name: "MyError".to_string(),
1860            rust_path: "my_crate::MyError".to_string(),
1861            original_rust_path: String::new(),
1862            variants: vec![ErrorVariant {
1863                name: "NotFound".to_string(),
1864                message_template: None,
1865                fields: vec![],
1866                has_source: false,
1867                has_from: false,
1868                is_unit: true,
1869                doc: String::new(),
1870            }],
1871            doc: String::new(),
1872            methods: vec![],
1873            binding_excluded: false,
1874            binding_exclusion_reason: None,
1875        };
1876        let output = gen_napi_error_converter(&error, "my_crate");
1877        assert!(output.contains("my_crate::MyError::NotFound =>"));
1878        assert!(!output.contains("NotFound(..)"));
1879    }
1880
1881    // -----------------------------------------------------------------------
1882    // WASM tests
1883    // -----------------------------------------------------------------------
1884
1885    #[test]
1886    fn test_gen_wasm_error_converter() {
1887        let error = sample_error();
1888        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1889        // Main converter function signature
1890        assert!(output.contains(
1891            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1892        ));
1893        // Structured object with code + message
1894        assert!(output.contains("js_sys::Object::new()"));
1895        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1896        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1897        assert!(output.contains("obj.into()"));
1898        // error_code helper
1899        assert!(
1900            output
1901                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1902        );
1903        assert!(output.contains("\"parse_error\""));
1904        assert!(output.contains("\"io_error\""));
1905        assert!(output.contains("\"other\""));
1906        assert!(output.contains("#[allow(dead_code)]"));
1907    }
1908
1909    // -----------------------------------------------------------------------
1910    // PHP tests
1911    // -----------------------------------------------------------------------
1912
1913    #[test]
1914    fn test_gen_php_error_converter() {
1915        let error = sample_error();
1916        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1917        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1918        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1919        assert!(output.contains("#[allow(dead_code)]"));
1920    }
1921
1922    // -----------------------------------------------------------------------
1923    // Magnus tests
1924    // -----------------------------------------------------------------------
1925
1926    #[test]
1927    fn test_gen_magnus_error_converter() {
1928        let error = sample_error();
1929        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1930        assert!(
1931            output.contains(
1932                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1933            )
1934        );
1935        assert!(
1936            output.contains(
1937                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1938            )
1939        );
1940        assert!(output.contains("#[allow(dead_code)]"));
1941    }
1942
1943    // -----------------------------------------------------------------------
1944    // Rustler tests
1945    // -----------------------------------------------------------------------
1946
1947    #[test]
1948    fn test_gen_rustler_error_converter() {
1949        let error = sample_error();
1950        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1951        assert!(
1952            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1953        );
1954        assert!(output.contains("e.to_string()"));
1955        assert!(output.contains("#[allow(dead_code)]"));
1956    }
1957
1958    // -----------------------------------------------------------------------
1959    // Go error struct with methods tests
1960    // -----------------------------------------------------------------------
1961
1962    #[test]
1963    fn test_gen_go_error_struct_with_methods() {
1964        let error = error_with_methods();
1965        let output = gen_go_error_struct(&error, "literllm");
1966        // Stutter-stripped: "LiterLlm" prefix of "LiterLlmError" stripped for "literllm" pkg
1967        assert!(output.contains("type Error struct {"), "struct def: {output}");
1968        // Fields are emitted directly on the struct — no accessor methods (avoids
1969        // field/method name collision that go vet rejects).
1970        assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
1971        assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
1972        assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
1973        // Accessor methods must NOT be emitted — the struct fields are the accessors.
1974        assert!(
1975            !output.contains("func (e Error) StatusCode()"),
1976            "no StatusCode accessor: {output}"
1977        );
1978        assert!(
1979            !output.contains("func (e Error) IsTransient()"),
1980            "no IsTransient accessor: {output}"
1981        );
1982        assert!(
1983            !output.contains("func (e Error) ErrorType()"),
1984            "no ErrorType accessor: {output}"
1985        );
1986    }
1987
1988    #[test]
1989    fn test_gen_go_error_struct_no_field_method_collision() {
1990        // Any property whose PascalCase name would collide as both a struct field and
1991        // a method must produce only the field — go vet rejects the combination.
1992        use alef_core::ir::{ErrorDef, ErrorVariant, PrimitiveType, TypeRef};
1993        let error = ErrorDef {
1994            name: "ApiError".to_string(),
1995            rust_path: String::new(),
1996            original_rust_path: String::new(),
1997            doc: String::new(),
1998            variants: vec![ErrorVariant {
1999                name: "Network".to_string(),
2000                message_template: None,
2001                fields: vec![],
2002                has_source: false,
2003                has_from: false,
2004                is_unit: true,
2005                doc: String::new(),
2006            }],
2007            methods: vec![
2008                sample_method("retry_count", TypeRef::Primitive(PrimitiveType::U32)),
2009                sample_method("permanent", TypeRef::Primitive(PrimitiveType::Bool)),
2010            ],
2011            binding_excluded: false,
2012            binding_exclusion_reason: None,
2013        };
2014        let output = gen_go_error_struct(&error, "mypkg");
2015        // Fields must be present.
2016        assert!(output.contains("RetryCount uint32"), "RetryCount field: {output}");
2017        assert!(output.contains("Permanent bool"), "Permanent field: {output}");
2018        // Accessor methods must NOT be emitted — field name == method name would be
2019        // a go vet error.
2020        assert!(
2021            !output.contains("func (e ApiError) RetryCount()"),
2022            "no RetryCount accessor: {output}"
2023        );
2024        assert!(
2025            !output.contains("func (e ApiError) Permanent()"),
2026            "no Permanent accessor: {output}"
2027        );
2028    }
2029
2030    #[test]
2031    fn test_gen_go_error_struct_no_methods() {
2032        let error = sample_error(); // methods: vec![]
2033        let output = gen_go_error_struct(&error, "mylib");
2034        assert!(output.contains("type ConversionError struct {"), "{output}");
2035        assert!(!output.contains("StatusCode"), "{output}");
2036        assert!(!output.contains("IsTransient"), "{output}");
2037    }
2038
2039    // -----------------------------------------------------------------------
2040    // Java error types with methods tests
2041    // -----------------------------------------------------------------------
2042
2043    #[test]
2044    fn test_gen_java_error_types_with_methods() {
2045        let error = error_with_methods();
2046        let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
2047        assert_eq!(files.len(), 1); // base only, no variants
2048        let base = &files[0].1;
2049        assert!(
2050            base.contains("private final int statusCode;"),
2051            "statusCode field: {base}"
2052        );
2053        assert!(
2054            base.contains("private final boolean isTransient;"),
2055            "isTransient field: {base}"
2056        );
2057        assert!(
2058            base.contains("private final String errorType;"),
2059            "errorType field: {base}"
2060        );
2061        assert!(
2062            base.contains("public int getStatusCode()"),
2063            "getStatusCode getter: {base}"
2064        );
2065        assert!(
2066            base.contains("public boolean isTransient()"),
2067            "isTransient getter: {base}"
2068        );
2069        assert!(
2070            base.contains("public String getErrorType()"),
2071            "getErrorType getter: {base}"
2072        );
2073        // Simple no-args constructor still present
2074        assert!(
2075            base.contains("public LiterLlmErrorException(final String message)"),
2076            "simple ctor: {base}"
2077        );
2078        // Full constructor with introspection params
2079        assert!(
2080            base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
2081            "full ctor: {base}"
2082        );
2083    }
2084
2085    #[test]
2086    fn test_gen_java_error_types_no_methods() {
2087        let error = sample_error(); // methods: vec![]
2088        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2089        let base = &files[0].1;
2090        assert!(!base.contains("private final"), "no fields when no methods: {base}");
2091        assert!(
2092            base.contains("public ConversionErrorException(final String message)"),
2093            "{base}"
2094        );
2095    }
2096
2097    // -----------------------------------------------------------------------
2098    // C# error types with methods tests
2099    // -----------------------------------------------------------------------
2100
2101    #[test]
2102    fn test_gen_csharp_error_types_with_methods() {
2103        let error = error_with_methods();
2104        let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
2105        assert_eq!(files.len(), 1); // base only, no variants
2106        let base = &files[0].1;
2107        assert!(
2108            base.contains("public ushort StatusCode { get; }"),
2109            "StatusCode prop: {base}"
2110        );
2111        assert!(
2112            base.contains("public bool IsTransient { get; }"),
2113            "IsTransient prop: {base}"
2114        );
2115        assert!(
2116            base.contains("public string ErrorType { get; }"),
2117            "ErrorType prop: {base}"
2118        );
2119        // Simple constructor (with defaults)
2120        assert!(
2121            base.contains("public LiterLlmErrorException(string message) : base(message)"),
2122            "simple ctor: {base}"
2123        );
2124        // Full constructor
2125        assert!(
2126            base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
2127            "full ctor: {base}"
2128        );
2129    }
2130
2131    #[test]
2132    fn test_gen_csharp_error_types_no_methods() {
2133        let error = sample_error(); // methods: vec![]
2134        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2135        let base = &files[0].1;
2136        assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
2137        assert!(
2138            base.contains("public ConversionErrorException(string message) : base(message) { }"),
2139            "{base}"
2140        );
2141    }
2142
2143    // -----------------------------------------------------------------------
2144    // Helper tests
2145    // -----------------------------------------------------------------------
2146
2147    #[test]
2148    fn test_to_screaming_snake() {
2149        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
2150        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
2151        assert_eq!(to_screaming_snake("Other"), "OTHER");
2152    }
2153
2154    #[test]
2155    fn test_strip_thiserror_placeholders_struct_field() {
2156        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
2157        assert_eq!(
2158            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
2159            "plugin error in"
2160        );
2161        // Multi-placeholder strings retain the surrounding prose verbatim
2162        // (minus the holes). Critical contract: no `{` / `}` survives.
2163        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
2164        assert!(!result.contains('{'), "no braces: {result}");
2165        assert!(!result.contains('}'), "no braces: {result}");
2166        assert!(result.starts_with("extraction timed out after"), "{result}");
2167    }
2168
2169    #[test]
2170    fn test_strip_thiserror_placeholders_positional() {
2171        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
2172        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
2173    }
2174
2175    #[test]
2176    fn test_strip_thiserror_placeholders_no_placeholder() {
2177        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
2178        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
2179    }
2180
2181    #[test]
2182    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
2183        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
2184        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
2185        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
2186        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
2187        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
2188    }
2189
2190    #[test]
2191    fn test_acronym_aware_snake_phrase_plain_words() {
2192        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
2193        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
2194        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
2195    }
2196
2197    #[test]
2198    fn test_variant_display_message_acronym_first_word() {
2199        let variant = ErrorVariant {
2200            name: "Io".to_string(),
2201            message_template: Some("I/O error: {0}".to_string()),
2202            fields: vec![tuple_field(0)],
2203            has_source: false,
2204            has_from: false,
2205            is_unit: false,
2206            doc: String::new(),
2207        };
2208        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
2209        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
2210        let msg = variant_display_message(&variant);
2211        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
2212    }
2213
2214    #[test]
2215    fn test_variant_display_message_no_template_uses_acronyms() {
2216        let variant = ErrorVariant {
2217            name: "IoError".to_string(),
2218            message_template: None,
2219            fields: vec![],
2220            has_source: false,
2221            has_from: false,
2222            is_unit: false,
2223            doc: String::new(),
2224        };
2225        assert_eq!(variant_display_message(&variant), "IO error");
2226    }
2227
2228    #[test]
2229    fn test_variant_display_message_struct_template_no_leak() {
2230        let variant = ErrorVariant {
2231            name: "Ocr".to_string(),
2232            message_template: Some("OCR error: {message}".to_string()),
2233            fields: vec![named_field("message")],
2234            has_source: false,
2235            has_from: false,
2236            is_unit: false,
2237            doc: String::new(),
2238        };
2239        let msg = variant_display_message(&variant);
2240        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
2241    }
2242
2243    #[test]
2244    fn test_go_sentinels_no_placeholder_leak() {
2245        let error = ErrorDef {
2246            name: "KreuzbergError".to_string(),
2247            rust_path: "kreuzberg::KreuzbergError".to_string(),
2248            original_rust_path: String::new(),
2249            variants: vec![
2250                ErrorVariant {
2251                    name: "Io".to_string(),
2252                    message_template: Some("IO error: {message}".to_string()),
2253                    fields: vec![named_field("message")],
2254                    has_source: false,
2255                    has_from: false,
2256                    is_unit: false,
2257                    doc: String::new(),
2258                },
2259                ErrorVariant {
2260                    name: "Ocr".to_string(),
2261                    message_template: Some("OCR error: {message}".to_string()),
2262                    fields: vec![named_field("message")],
2263                    has_source: false,
2264                    has_from: false,
2265                    is_unit: false,
2266                    doc: String::new(),
2267                },
2268                ErrorVariant {
2269                    name: "Timeout".to_string(),
2270                    message_template: Some(
2271                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
2272                    ),
2273                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
2274                    has_source: false,
2275                    has_from: false,
2276                    is_unit: false,
2277                    doc: String::new(),
2278                },
2279            ],
2280            doc: String::new(),
2281            methods: vec![],
2282            binding_excluded: false,
2283            binding_exclusion_reason: None,
2284        };
2285        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
2286        assert!(
2287            !output.contains('{'),
2288            "Go sentinels must not contain raw placeholders:\n{output}"
2289        );
2290        assert!(
2291            output.contains("ErrIo = errors.New(\"IO error\")"),
2292            "expected acronym-preserving Io sentinel, got:\n{output}"
2293        );
2294        assert!(
2295            output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
2296            "Go sentinel comments must be emitted on separate lines, got:\n{output}"
2297        );
2298        assert!(
2299            output.contains("ErrOcr = errors.New(\"OCR error\")"),
2300            "expected acronym-preserving Ocr sentinel, got:\n{output}"
2301        );
2302        assert!(
2303            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
2304            "expected timeout sentinel to start with the prose, got:\n{output}"
2305        );
2306    }
2307
2308    // -----------------------------------------------------------------------
2309    // FFI (C) tests
2310    // -----------------------------------------------------------------------
2311
2312    #[test]
2313    fn test_gen_ffi_error_codes() {
2314        let error = sample_error();
2315        let output = gen_ffi_error_codes(&error);
2316        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
2317        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
2318        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
2319        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
2320        assert!(output.contains("conversion_error_t;"));
2321        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
2322    }
2323
2324    // -----------------------------------------------------------------------
2325    // Go tests
2326    // -----------------------------------------------------------------------
2327
2328    #[test]
2329    fn test_gen_go_error_types() {
2330        let error = sample_error();
2331        // Package name that does NOT match the error prefix — type name stays unchanged.
2332        let output = gen_go_error_types(&error, "mylib");
2333        assert!(output.contains("ErrParseError = errors.New("));
2334        assert!(output.contains("ErrIoError = errors.New("));
2335        assert!(output.contains("ErrOther = errors.New("));
2336        assert!(output.contains("type ConversionError struct {"));
2337        assert!(output.contains("Code    string"));
2338        assert!(output.contains("func (e ConversionError) Error() string"));
2339        // Each sentinel error var should have a doc comment.
2340        assert!(output.contains("// ErrParseError is returned when"));
2341        assert!(output.contains("// ErrIoError is returned when"));
2342        assert!(output.contains("// ErrOther is returned when"));
2343    }
2344
2345    #[test]
2346    fn test_gen_go_error_types_stutter_strip() {
2347        let error = sample_error();
2348        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
2349        // so the exported Go type should be "Error", not "ConversionError".
2350        let output = gen_go_error_types(&error, "conversion");
2351        assert!(
2352            output.contains("type Error struct {"),
2353            "expected stutter strip, got:\n{output}"
2354        );
2355        assert!(
2356            output.contains("func (e Error) Error() string"),
2357            "expected stutter strip, got:\n{output}"
2358        );
2359        // Sentinel vars are unaffected by stutter stripping.
2360        assert!(output.contains("ErrParseError = errors.New("));
2361    }
2362
2363    // -----------------------------------------------------------------------
2364    // Java tests
2365    // -----------------------------------------------------------------------
2366
2367    #[test]
2368    fn test_gen_java_error_types() {
2369        let error = sample_error();
2370        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2371        // base + 3 variants
2372        assert_eq!(files.len(), 4);
2373        // Base class
2374        assert_eq!(files[0].0, "ConversionErrorException");
2375        assert!(
2376            files[0]
2377                .1
2378                .contains("public class ConversionErrorException extends Exception")
2379        );
2380        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
2381        // Variant classes
2382        assert_eq!(files[1].0, "ParseErrorException");
2383        assert!(
2384            files[1]
2385                .1
2386                .contains("public class ParseErrorException extends ConversionErrorException")
2387        );
2388        assert_eq!(files[2].0, "IoErrorException");
2389        assert_eq!(files[3].0, "OtherException");
2390    }
2391
2392    // -----------------------------------------------------------------------
2393    // C# tests
2394    // -----------------------------------------------------------------------
2395
2396    #[test]
2397    fn test_gen_csharp_error_types() {
2398        let error = sample_error();
2399        // Without fallback class: base inherits from Exception.
2400        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2401        assert_eq!(files.len(), 4);
2402        assert_eq!(files[0].0, "ConversionErrorException");
2403        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
2404        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
2405        assert_eq!(files[1].0, "ParseErrorException");
2406        assert!(
2407            files[1]
2408                .1
2409                .contains("public class ParseErrorException : ConversionErrorException")
2410        );
2411        assert_eq!(files[2].0, "IoErrorException");
2412        assert_eq!(files[3].0, "OtherException");
2413    }
2414
2415    #[test]
2416    fn test_gen_csharp_error_types_with_fallback() {
2417        let error = sample_error();
2418        // With fallback class: base inherits from the generic library exception.
2419        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
2420        assert_eq!(files.len(), 4);
2421        assert!(
2422            files[0]
2423                .1
2424                .contains("public class ConversionErrorException : TestLibException")
2425        );
2426        // Variant classes still inherit from the base error class, not from the fallback directly.
2427        assert!(
2428            files[1]
2429                .1
2430                .contains("public class ParseErrorException : ConversionErrorException")
2431        );
2432    }
2433
2434    // -----------------------------------------------------------------------
2435    // python_exception_name tests
2436    // -----------------------------------------------------------------------
2437
2438    #[test]
2439    fn test_python_exception_name_no_conflict() {
2440        // "ParseError" already ends with "Error" and is not a builtin
2441        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
2442        // "Other" gets "Error" suffix, "OtherError" is not a builtin
2443        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
2444    }
2445
2446    #[test]
2447    fn test_python_exception_name_shadows_builtin() {
2448        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
2449        assert_eq!(
2450            python_exception_name("Connection", "CrawlError"),
2451            "CrawlConnectionError"
2452        );
2453        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
2454        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
2455        // "ConnectionError" already ends with "Error", still shadows -> prefix
2456        assert_eq!(
2457            python_exception_name("ConnectionError", "CrawlError"),
2458            "CrawlConnectionError"
2459        );
2460    }
2461
2462    #[test]
2463    fn test_python_exception_name_no_double_prefix() {
2464        // If variant is already prefixed with the error base, don't double-prefix
2465        assert_eq!(
2466            python_exception_name("CrawlConnectionError", "CrawlError"),
2467            "CrawlConnectionError"
2468        );
2469    }
2470
2471    // -----------------------------------------------------------------------
2472    // WASM error methods tests
2473    // -----------------------------------------------------------------------
2474
2475    fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
2476        alef_core::ir::MethodDef {
2477            name: name.to_string(),
2478            params: vec![],
2479            return_type,
2480            is_async: false,
2481            is_static: false,
2482            error_type: None,
2483            doc: String::new(),
2484            receiver: Some(alef_core::ir::ReceiverKind::Ref),
2485            sanitized: false,
2486            trait_source: None,
2487            returns_ref: false,
2488            returns_cow: false,
2489            return_newtype_wrapper: None,
2490            has_default_impl: false,
2491            binding_excluded: false,
2492            binding_exclusion_reason: None,
2493        }
2494    }
2495
2496    fn error_with_methods() -> ErrorDef {
2497        ErrorDef {
2498            name: "LiterLlmError".to_string(),
2499            rust_path: "liter_llm::error::LiterLlmError".to_string(),
2500            original_rust_path: String::new(),
2501            variants: vec![],
2502            doc: String::new(),
2503            methods: vec![
2504                sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
2505                sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
2506                sample_method("error_type", TypeRef::String),
2507            ],
2508            binding_excluded: false,
2509            binding_exclusion_reason: None,
2510        }
2511    }
2512
2513    #[test]
2514    fn test_gen_wasm_error_methods_empty_when_no_methods() {
2515        let error = sample_error(); // methods: vec![]
2516        let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
2517        assert!(output.is_empty(), "should produce no output when methods is empty");
2518    }
2519
2520    #[test]
2521    fn test_gen_wasm_error_methods_struct_and_impl() {
2522        let error = error_with_methods();
2523        // wasm_prefix is the full type prefix, e.g. "Wasm" — the struct name is
2524        // {wasm_prefix}{ErrorName} = "WasmLiterLlmError".
2525        let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
2526        // Struct definition
2527        assert!(
2528            output.contains("pub struct WasmLiterLlmError"),
2529            "must emit opaque struct: {output}"
2530        );
2531        assert!(
2532            output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
2533            "{output}"
2534        );
2535        // Impl block
2536        assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
2537        // Methods with camelCase js_name
2538        assert!(output.contains("js_name = \"statusCode\""), "{output}");
2539        assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
2540        assert!(output.contains("self.inner.status_code()"), "{output}");
2541        assert!(output.contains("js_name = \"isTransient\""), "{output}");
2542        assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
2543        assert!(output.contains("self.inner.is_transient()"), "{output}");
2544        assert!(output.contains("js_name = \"errorType\""), "{output}");
2545        assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
2546        assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
2547    }
2548
2549    // -----------------------------------------------------------------------
2550    // FFI error methods tests
2551    // -----------------------------------------------------------------------
2552
2553    #[test]
2554    fn test_gen_ffi_error_methods_empty_when_no_methods() {
2555        let error = sample_error(); // methods: vec![]
2556        let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
2557        assert!(output.is_empty(), "should produce no output when methods is empty");
2558    }
2559
2560    #[test]
2561    fn test_gen_ffi_error_methods_status_code() {
2562        let error = error_with_methods();
2563        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2564        assert!(
2565            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
2566            "must emit status_code fn: {output}"
2567        );
2568        assert!(
2569            output.contains("err: *const liter_llm::error::LiterLlmError"),
2570            "{output}"
2571        );
2572        assert!(output.contains("-> u16"), "{output}");
2573        assert!(output.contains("(*err).status_code()"), "{output}");
2574        assert!(output.contains("if err.is_null()"), "{output}");
2575        assert!(output.contains("return 0;"), "{output}");
2576    }
2577
2578    #[test]
2579    fn test_gen_ffi_error_methods_is_transient() {
2580        let error = error_with_methods();
2581        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2582        assert!(
2583            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
2584            "must emit is_transient fn: {output}"
2585        );
2586        assert!(output.contains("-> bool"), "{output}");
2587        assert!(output.contains("(*err).is_transient()"), "{output}");
2588        assert!(output.contains("return false;"), "{output}");
2589    }
2590
2591    #[test]
2592    fn test_gen_ffi_error_methods_error_type_with_free() {
2593        let error = error_with_methods();
2594        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2595        assert!(
2596            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
2597            "must emit error_type fn: {output}"
2598        );
2599        assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
2600        assert!(output.contains("(*err).error_type()"), "{output}");
2601        assert!(output.contains("CString::new(s)"), "{output}");
2602        assert!(output.contains(".into_raw()"), "{output}");
2603        assert!(output.contains("return std::ptr::null_mut();"), "{output}");
2604        // free companion
2605        assert!(
2606            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
2607            "must emit _free companion: {output}"
2608        );
2609        assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
2610    }
2611
2612    #[test]
2613    fn test_gen_ffi_error_methods_safety_comments() {
2614        let error = error_with_methods();
2615        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2616        assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
2617    }
2618}