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            serde_json::json!({
1137                "field_name": method_name,
1138                "go_type": go_type,
1139                "method_name": method_name,
1140                "doc": m.doc,
1141            })
1142        })
1143        .collect();
1144    let has_methods = !methods.is_empty();
1145
1146    crate::template_env::render(
1147        "error_gen/go_error_struct.jinja",
1148        minijinja::context! {
1149            go_type_name => go_type_name.as_str(),
1150            methods => methods,
1151            has_methods => has_methods,
1152        },
1153    )
1154}
1155
1156/// Map an IR `TypeRef` to a Go type string for error introspection method returns.
1157/// Only the primitive subset needed for the whitelisted methods is handled;
1158/// everything else falls back to `string`.
1159fn typeref_to_go_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1160    use alef_core::ir::{PrimitiveType, TypeRef};
1161    match ty {
1162        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1163        TypeRef::Primitive(PrimitiveType::U8) => "uint8",
1164        TypeRef::Primitive(PrimitiveType::U16) => "uint16",
1165        TypeRef::Primitive(PrimitiveType::U32) => "uint32",
1166        TypeRef::Primitive(PrimitiveType::U64) => "uint64",
1167        TypeRef::Primitive(PrimitiveType::I8) => "int8",
1168        TypeRef::Primitive(PrimitiveType::I16) => "int16",
1169        TypeRef::Primitive(PrimitiveType::I32) => "int32",
1170        TypeRef::Primitive(PrimitiveType::I64) => "int64",
1171        TypeRef::Primitive(PrimitiveType::F32) => "float32",
1172        TypeRef::Primitive(PrimitiveType::F64) => "float64",
1173        TypeRef::String => "string",
1174        _ => "string",
1175    }
1176}
1177
1178/// Convert a snake_case or camelCase name to PascalCase.
1179fn to_pascal_case(s: &str) -> String {
1180    s.split('_')
1181        .map(|word| {
1182            let mut chars = word.chars();
1183            match chars.next() {
1184                None => String::new(),
1185                Some(first) => first.to_uppercase().to_string() + chars.as_str(),
1186            }
1187        })
1188        .collect()
1189}
1190
1191/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
1192///
1193/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
1194/// and that stutters` when a type name begins with the package name. This function
1195/// removes the prefix when it matches (case-insensitively) so that the exported name
1196/// does not repeat the package name.
1197///
1198/// Examples:
1199/// - `("LiterLlmError", "literllm")` → `"Error"` (lowercased `literllm` is a prefix
1200///   of lowercased `literllmerror`)
1201/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
1202fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
1203    let type_lower = type_name.to_lowercase();
1204    let pkg_lower = pkg_name.to_lowercase();
1205    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
1206        // Retain the original casing for the suffix part.
1207        type_name[pkg_lower.len()..].to_string()
1208    } else {
1209        type_name.to_string()
1210    }
1211}
1212
1213// ---------------------------------------------------------------------------
1214// Java error type generation
1215// ---------------------------------------------------------------------------
1216
1217/// Generate Java exception sub-classes for each error variant.
1218///
1219/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
1220/// class followed by one per-variant exception.  The caller writes each to a
1221/// separate `.java` file.
1222///
1223/// When `error.methods` is non-empty, the base exception class gains private
1224/// final fields, an extended constructor, and public getter methods for each
1225/// whitelisted introspection method.  Variant classes delegate via `super(…)`.
1226pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
1227    let mut files = Vec::with_capacity(error.variants.len() + 1);
1228
1229    // Base exception class
1230    let base_name = format!("{}Exception", error.name);
1231    let doc_lines: Vec<&str> = error.doc.lines().collect();
1232
1233    // Build per-method info for the template.
1234    // Each entry: { field_name, java_type, getter_name, doc }
1235    let method_infos: Vec<serde_json::Value> = error
1236        .methods
1237        .iter()
1238        .map(|m| {
1239            let java_type = typeref_to_java_type(&m.return_type);
1240            let getter_name = java_getter_name(&m.name);
1241            let field_name = java_field_name(&m.name);
1242            let default_value = java_default_value(&m.return_type);
1243            serde_json::json!({
1244                "field_name": field_name,
1245                "java_type": java_type,
1246                "getter_name": getter_name,
1247                "default_value": default_value,
1248                "doc": m.doc,
1249            })
1250        })
1251        .collect();
1252    let has_methods = !method_infos.is_empty();
1253
1254    let base = crate::template_env::render(
1255        "error_gen/java_error_base.jinja",
1256        minijinja::context! {
1257            package => package,
1258            base_name => base_name.as_str(),
1259            doc => !error.doc.is_empty(),
1260            doc_lines => doc_lines,
1261            methods => method_infos,
1262            has_methods => has_methods,
1263        },
1264    );
1265    files.push((base_name.clone(), base));
1266
1267    // Per-variant exception classes
1268    for variant in &error.variants {
1269        let class_name = format!("{}Exception", variant.name);
1270        let doc_lines: Vec<&str> = variant.doc.lines().collect();
1271
1272        let content = crate::template_env::render(
1273            "error_gen/java_error_variant.jinja",
1274            minijinja::context! {
1275                package => package,
1276                class_name => class_name.as_str(),
1277                base_name => base_name.as_str(),
1278                doc => !variant.doc.is_empty(),
1279                doc_lines => doc_lines,
1280                has_methods => has_methods,
1281            },
1282        );
1283        files.push((class_name, content));
1284    }
1285
1286    files
1287}
1288
1289/// Map an IR `TypeRef` to a Java type string for error introspection getters.
1290fn typeref_to_java_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1291    use alef_core::ir::{PrimitiveType, TypeRef};
1292    match ty {
1293        TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
1294        TypeRef::Primitive(
1295            PrimitiveType::U8
1296            | PrimitiveType::I8
1297            | PrimitiveType::I16
1298            | PrimitiveType::U16
1299            | PrimitiveType::I32
1300            | PrimitiveType::U32,
1301        ) => "int",
1302        TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
1303        TypeRef::Primitive(PrimitiveType::F32) => "float",
1304        TypeRef::Primitive(PrimitiveType::F64) => "double",
1305        TypeRef::String => "String",
1306        _ => "String",
1307    }
1308}
1309
1310/// Convert a snake_case method name to a Java getter name.
1311/// E.g. `status_code` → `getStatusCode`, `is_transient` → `isTransient`.
1312fn java_getter_name(snake: &str) -> String {
1313    if let Some(rest) = snake.strip_prefix("is_") {
1314        // is_transient → isTransient
1315        let pascal = to_pascal_case(rest);
1316        format!("is{pascal}")
1317    } else {
1318        // status_code → getStatusCode, error_type → getErrorType
1319        let pascal = to_pascal_case(snake);
1320        format!("get{pascal}")
1321    }
1322}
1323
1324/// Convert a snake_case method name to a Java field name (camelCase).
1325/// E.g. `status_code` → `statusCode`, `is_transient` → `isTransient`.
1326fn java_field_name(snake: &str) -> String {
1327    let parts: Vec<&str> = snake.split('_').collect();
1328    if parts.is_empty() {
1329        return snake.to_string();
1330    }
1331    let mut out = parts[0].to_string();
1332    for part in &parts[1..] {
1333        let mut chars = part.chars();
1334        match chars.next() {
1335            None => {}
1336            Some(first) => {
1337                out.push_str(&first.to_uppercase().to_string());
1338                out.push_str(chars.as_str());
1339            }
1340        }
1341    }
1342    out
1343}
1344
1345/// Return the Java zero-value literal for a type (used in the no-args default constructor).
1346fn java_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1347    use alef_core::ir::{PrimitiveType, TypeRef};
1348    match ty {
1349        TypeRef::Primitive(PrimitiveType::Bool) => "false",
1350        TypeRef::String => "\"\"",
1351        _ => "0",
1352    }
1353}
1354
1355// ---------------------------------------------------------------------------
1356// C# error type generation
1357// ---------------------------------------------------------------------------
1358
1359/// Generate C# exception sub-classes for each error variant.
1360///
1361/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
1362/// class followed by one per-variant exception.  The caller writes each to a
1363/// separate `.cs` file.
1364///
1365/// `fallback_class` is the name of the generic library exception class (e.g.
1366/// `TreeSitterLanguagePackException`) that the base error class should extend so that
1367/// callers can `catch` the general library exception and catch all typed errors.
1368///
1369/// When `error.methods` is non-empty, the base exception class gains get-only
1370/// properties for each whitelisted introspection method.  Variant classes
1371/// delegate via `base(…)` and inherit the properties.
1372pub fn gen_csharp_error_types(
1373    error: &ErrorDef,
1374    namespace: &str,
1375    fallback_class: Option<&str>,
1376) -> Vec<(String, String)> {
1377    let mut files = Vec::with_capacity(error.variants.len() + 1);
1378
1379    let base_name = format!("{}Exception", error.name);
1380    // Inherit from the generic library exception when provided so that
1381    // `Assert.ThrowsAny<LibException>()` catches typed errors too.
1382    let base_parent = fallback_class.unwrap_or("Exception");
1383    let error_doc_lines: Vec<&str> = error.doc.lines().collect();
1384
1385    // Build per-method info for the template.
1386    // Each entry: { prop_name, cs_type, param_name, doc }
1387    let method_infos: Vec<serde_json::Value> = error
1388        .methods
1389        .iter()
1390        .map(|m| {
1391            let cs_type = typeref_to_csharp_type(&m.return_type);
1392            let prop_name = to_pascal_case(&m.name);
1393            let param_name = java_field_name(&m.name); // camelCase ctor parameter
1394            let default_value = csharp_default_value(&m.return_type);
1395            serde_json::json!({
1396                "prop_name": prop_name,
1397                "cs_type": cs_type,
1398                "param_name": param_name,
1399                "default_value": default_value,
1400                "doc": m.doc,
1401            })
1402        })
1403        .collect();
1404    let has_methods = !method_infos.is_empty();
1405
1406    // Base exception class
1407    {
1408        let out = crate::template_env::render(
1409            "error_gen/csharp_error_base.jinja",
1410            minijinja::context! {
1411                namespace => namespace,
1412                base_name => base_name.as_str(),
1413                base_parent => base_parent,
1414                doc => !error.doc.is_empty(),
1415                doc_lines => error_doc_lines,
1416                methods => method_infos,
1417                has_methods => has_methods,
1418            },
1419        );
1420        files.push((base_name.clone(), out));
1421    }
1422
1423    // Per-variant exception classes
1424    for variant in &error.variants {
1425        let class_name = format!("{}Exception", variant.name);
1426        let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
1427
1428        let out = crate::template_env::render(
1429            "error_gen/csharp_error_variant.jinja",
1430            minijinja::context! {
1431                namespace => namespace,
1432                class_name => class_name.as_str(),
1433                base_name => base_name.as_str(),
1434                doc => !variant.doc.is_empty(),
1435                doc_lines => variant_doc_lines,
1436                has_methods => has_methods,
1437            },
1438        );
1439        files.push((class_name, out));
1440    }
1441
1442    files
1443}
1444
1445/// Map an IR `TypeRef` to a C# type string for error introspection properties.
1446fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1447    use alef_core::ir::{PrimitiveType, TypeRef};
1448    match ty {
1449        TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1450        TypeRef::Primitive(PrimitiveType::U8) => "byte",
1451        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
1452        TypeRef::Primitive(PrimitiveType::I16) => "short",
1453        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
1454        TypeRef::Primitive(PrimitiveType::I32) => "int",
1455        TypeRef::Primitive(PrimitiveType::U32) => "uint",
1456        TypeRef::Primitive(PrimitiveType::I64) => "long",
1457        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
1458        TypeRef::Primitive(PrimitiveType::F32) => "float",
1459        TypeRef::Primitive(PrimitiveType::F64) => "double",
1460        TypeRef::String => "string",
1461        _ => "string",
1462    }
1463}
1464
1465/// Return the C# zero-value literal for a type (used in the default constructor).
1466fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1467    use alef_core::ir::{PrimitiveType, TypeRef};
1468    match ty {
1469        TypeRef::Primitive(PrimitiveType::Bool) => "false",
1470        TypeRef::String => "string.Empty",
1471        _ => "0",
1472    }
1473}
1474
1475// ---------------------------------------------------------------------------
1476// Helpers
1477// ---------------------------------------------------------------------------
1478
1479/// Convert CamelCase to SCREAMING_SNAKE_CASE.
1480fn to_screaming_snake(s: &str) -> String {
1481    let mut result = String::with_capacity(s.len() + 4);
1482    for (i, c) in s.chars().enumerate() {
1483        if c.is_uppercase() {
1484            if i > 0 {
1485                result.push('_');
1486            }
1487            result.push(c.to_ascii_uppercase());
1488        } else {
1489            result.push(c.to_ascii_uppercase());
1490        }
1491    }
1492    result
1493}
1494
1495/// Well-known acronyms recognised by the doc/error renderers.
1496///
1497/// When emitting human-readable Display strings (e.g. for Go sentinel
1498/// `errors.New("...")`), variant names like `IoError` must render as
1499/// "IO error" — not "iO error" (the result of naive `lowercase first
1500/// character` after `to_snake_case`).
1501const TECHNICAL_ACRONYMS: &[&str] = &[
1502    "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
1503    "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
1504    "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
1505    "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
1506];
1507
1508/// Strip `thiserror`-style `{name}` placeholders from a Display template
1509/// without leaving stray punctuation.
1510///
1511/// Examples:
1512///
1513/// - `"OCR error: {message}"`           → `"OCR error"`
1514/// - `"plugin error in '{plugin_name}'"` → `"plugin error"`
1515/// - `"timed out after {elapsed_ms}ms (limit: {limit_ms}ms)"` → `"timed out"`
1516/// - `"I/O error: {0}"`                  → `"I/O error"`
1517///
1518/// Used by `variant_display_message` and binding error renderers
1519/// (Dart, Go, …) so the literal placeholder string never reaches
1520/// the runtime.
1521pub fn strip_thiserror_placeholders(template: &str) -> String {
1522    // Remove every `{...}` segment.
1523    let mut without_placeholders = String::with_capacity(template.len());
1524    let mut depth = 0u32;
1525    for ch in template.chars() {
1526        match ch {
1527            '{' => depth = depth.saturating_add(1),
1528            '}' => depth = depth.saturating_sub(1),
1529            other if depth == 0 => without_placeholders.push(other),
1530            _ => {}
1531        }
1532    }
1533    // Remove orphaned punctuation/whitespace immediately around the holes
1534    // (collapse runs of whitespace, drop trailing `:`/quote runs, drop
1535    // `(...)` shells that wrapped only placeholders).
1536    let mut compacted = String::with_capacity(without_placeholders.len());
1537    let mut last_was_space = false;
1538    for ch in without_placeholders.chars() {
1539        if ch.is_whitespace() {
1540            if !last_was_space && !compacted.is_empty() {
1541                compacted.push(' ');
1542            }
1543            last_was_space = true;
1544        } else {
1545            compacted.push(ch);
1546            last_was_space = false;
1547        }
1548    }
1549    // Trim trailing punctuation that only made sense before a placeholder.
1550    let trimmed = compacted
1551        .trim()
1552        .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
1553        .trim();
1554    // If we left e.g. `"limit: ms ms"` artefacts behind, collapse stray
1555    // empty parens / paired quotes.
1556    let cleaned = trimmed
1557        .replace("()", "")
1558        .replace("''", "")
1559        .replace("\"\"", "")
1560        .replace("  ", " ");
1561    cleaned.trim().to_string()
1562}
1563
1564/// Convert a PascalCase variant name into a human readable phrase that
1565/// preserves canonical acronyms.
1566///
1567/// Examples:
1568/// - `"IoError"`           → `"IO error"`
1569/// - `"OcrError"`          → `"OCR error"`
1570/// - `"PdfParse"`          → `"PDF parse"`
1571/// - `"HttpRequestFailed"` → `"HTTP request failed"`
1572/// - `"Other"`             → `"other"`
1573pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
1574    if variant_name.is_empty() {
1575        return String::new();
1576    }
1577    // Split into PascalCase words (each word starts with an uppercase letter).
1578    let bytes = variant_name.as_bytes();
1579    let mut words: Vec<&str> = Vec::new();
1580    let mut start = 0usize;
1581    for i in 1..bytes.len() {
1582        if bytes[i].is_ascii_uppercase() {
1583            words.push(&variant_name[start..i]);
1584            start = i;
1585        }
1586    }
1587    words.push(&variant_name[start..]);
1588
1589    let mut rendered: Vec<String> = Vec::with_capacity(words.len());
1590    for word in &words {
1591        let upper = word.to_ascii_uppercase();
1592        if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
1593            rendered.push(upper);
1594        } else {
1595            rendered.push(word.to_ascii_lowercase());
1596        }
1597    }
1598    rendered.join(" ")
1599}
1600
1601/// Generate a human-readable message for an error variant.
1602///
1603/// Uses the `message_template` if present, otherwise falls back to a
1604/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
1605fn variant_display_message(variant: &ErrorVariant) -> String {
1606    if let Some(tmpl) = &variant.message_template {
1607        let stripped = strip_thiserror_placeholders(tmpl);
1608        if stripped.is_empty() {
1609            return acronym_aware_snake_phrase(&variant.name);
1610        }
1611        // Preserve canonical acronyms but lowercase the first regular word so
1612        // Go's `lowercase first char` convention does not corrupt `IO` → `iO`.
1613        // Heuristic: if the first whitespace-delimited token is *not* already
1614        // a known acronym, downcase its first character.
1615        let mut tokens = stripped.splitn(2, ' ');
1616        let head = tokens.next().unwrap_or("").to_string();
1617        let tail = tokens.next().unwrap_or("");
1618        let head_upper = head.to_ascii_uppercase();
1619        let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
1620            head_upper
1621        } else {
1622            let mut chars = head.chars();
1623            match chars.next() {
1624                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1625                None => head,
1626            }
1627        };
1628        if tail.is_empty() {
1629            head_rendered
1630        } else {
1631            format!("{} {}", head_rendered, tail)
1632        }
1633    } else {
1634        acronym_aware_snake_phrase(&variant.name)
1635    }
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640    use super::*;
1641    use alef_core::ir::{ErrorDef, ErrorVariant};
1642
1643    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
1644
1645    /// Helper to create a tuple-style field (e.g. `_0: String`).
1646    fn tuple_field(index: usize) -> FieldDef {
1647        FieldDef {
1648            name: format!("_{index}"),
1649            ty: TypeRef::String,
1650            optional: false,
1651            default: None,
1652            doc: String::new(),
1653            sanitized: false,
1654            is_boxed: false,
1655            type_rust_path: None,
1656            cfg: None,
1657            typed_default: None,
1658            core_wrapper: CoreWrapper::None,
1659            vec_inner_core_wrapper: CoreWrapper::None,
1660            newtype_wrapper: None,
1661            serde_rename: None,
1662            serde_flatten: false,
1663            binding_excluded: false,
1664            binding_exclusion_reason: None,
1665            original_type: None,
1666        }
1667    }
1668
1669    /// Helper to create a named struct field.
1670    fn named_field(name: &str) -> FieldDef {
1671        FieldDef {
1672            name: name.to_string(),
1673            ty: TypeRef::String,
1674            optional: false,
1675            default: None,
1676            doc: String::new(),
1677            sanitized: false,
1678            is_boxed: false,
1679            type_rust_path: None,
1680            cfg: None,
1681            typed_default: None,
1682            core_wrapper: CoreWrapper::None,
1683            vec_inner_core_wrapper: CoreWrapper::None,
1684            newtype_wrapper: None,
1685            serde_rename: None,
1686            serde_flatten: false,
1687            binding_excluded: false,
1688            binding_exclusion_reason: None,
1689            original_type: None,
1690        }
1691    }
1692
1693    fn sample_error() -> ErrorDef {
1694        ErrorDef {
1695            name: "ConversionError".to_string(),
1696            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
1697            original_rust_path: String::new(),
1698            variants: vec![
1699                ErrorVariant {
1700                    name: "ParseError".to_string(),
1701                    message_template: Some("HTML parsing error: {0}".to_string()),
1702                    fields: vec![tuple_field(0)],
1703                    has_source: false,
1704                    has_from: false,
1705                    is_unit: false,
1706                    doc: String::new(),
1707                },
1708                ErrorVariant {
1709                    name: "IoError".to_string(),
1710                    message_template: Some("I/O error: {0}".to_string()),
1711                    fields: vec![tuple_field(0)],
1712                    has_source: false,
1713                    has_from: true,
1714                    is_unit: false,
1715                    doc: String::new(),
1716                },
1717                ErrorVariant {
1718                    name: "Other".to_string(),
1719                    message_template: Some("Conversion error: {0}".to_string()),
1720                    fields: vec![tuple_field(0)],
1721                    has_source: false,
1722                    has_from: false,
1723                    is_unit: false,
1724                    doc: String::new(),
1725                },
1726            ],
1727            doc: "Error type for conversion operations.".to_string(),
1728            methods: vec![],
1729            binding_excluded: false,
1730            binding_exclusion_reason: None,
1731        }
1732    }
1733
1734    #[test]
1735    fn test_gen_error_types() {
1736        let error = sample_error();
1737        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
1738        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
1739        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
1740        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
1741        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
1742    }
1743
1744    #[test]
1745    fn test_gen_error_converter() {
1746        let error = sample_error();
1747        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
1748        assert!(
1749            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
1750        );
1751        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
1752        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
1753    }
1754
1755    #[test]
1756    fn test_gen_error_registration() {
1757        let error = sample_error();
1758        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
1759        assert_eq!(regs.len(), 4); // 3 variants + 1 base
1760        assert!(regs[0].contains("\"ParseError\""));
1761        assert!(regs[3].contains("\"ConversionError\""));
1762    }
1763
1764    #[test]
1765    fn test_unit_variant_pattern() {
1766        let error = ErrorDef {
1767            name: "MyError".to_string(),
1768            rust_path: "my_crate::MyError".to_string(),
1769            original_rust_path: String::new(),
1770            variants: vec![ErrorVariant {
1771                name: "NotFound".to_string(),
1772                message_template: Some("not found".to_string()),
1773                fields: vec![],
1774                has_source: false,
1775                has_from: false,
1776                is_unit: true,
1777                doc: String::new(),
1778            }],
1779            doc: String::new(),
1780            methods: vec![],
1781            binding_excluded: false,
1782            binding_exclusion_reason: None,
1783        };
1784        let output = gen_pyo3_error_converter(&error, "my_crate");
1785        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
1786        // Ensure no (..) for unit variants
1787        assert!(!output.contains("NotFound(..)"));
1788    }
1789
1790    #[test]
1791    fn test_struct_variant_pattern() {
1792        let error = ErrorDef {
1793            name: "MyError".to_string(),
1794            rust_path: "my_crate::MyError".to_string(),
1795            original_rust_path: String::new(),
1796            variants: vec![ErrorVariant {
1797                name: "Parsing".to_string(),
1798                message_template: Some("parsing error: {message}".to_string()),
1799                fields: vec![named_field("message")],
1800                has_source: false,
1801                has_from: false,
1802                is_unit: false,
1803                doc: String::new(),
1804            }],
1805            doc: String::new(),
1806            methods: vec![],
1807            binding_excluded: false,
1808            binding_exclusion_reason: None,
1809        };
1810        let output = gen_pyo3_error_converter(&error, "my_crate");
1811        assert!(
1812            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1813            "Struct variants must use {{ .. }} pattern, got:\n{output}"
1814        );
1815        // Ensure no (..) for struct variants
1816        assert!(!output.contains("Parsing(..)"));
1817    }
1818
1819    // -----------------------------------------------------------------------
1820    // NAPI tests
1821    // -----------------------------------------------------------------------
1822
1823    #[test]
1824    fn test_gen_napi_error_types() {
1825        let error = sample_error();
1826        let output = gen_napi_error_types(&error);
1827        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1828        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1829        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1830    }
1831
1832    #[test]
1833    fn test_gen_napi_error_converter() {
1834        let error = sample_error();
1835        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1836        assert!(
1837            output
1838                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1839        );
1840        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1841        assert!(output.contains("[ParseError]"));
1842        assert!(output.contains("[IoError]"));
1843        assert!(output.contains("#[allow(dead_code)]"));
1844    }
1845
1846    #[test]
1847    fn test_napi_unit_variant() {
1848        let error = ErrorDef {
1849            name: "MyError".to_string(),
1850            rust_path: "my_crate::MyError".to_string(),
1851            original_rust_path: String::new(),
1852            variants: vec![ErrorVariant {
1853                name: "NotFound".to_string(),
1854                message_template: None,
1855                fields: vec![],
1856                has_source: false,
1857                has_from: false,
1858                is_unit: true,
1859                doc: String::new(),
1860            }],
1861            doc: String::new(),
1862            methods: vec![],
1863            binding_excluded: false,
1864            binding_exclusion_reason: None,
1865        };
1866        let output = gen_napi_error_converter(&error, "my_crate");
1867        assert!(output.contains("my_crate::MyError::NotFound =>"));
1868        assert!(!output.contains("NotFound(..)"));
1869    }
1870
1871    // -----------------------------------------------------------------------
1872    // WASM tests
1873    // -----------------------------------------------------------------------
1874
1875    #[test]
1876    fn test_gen_wasm_error_converter() {
1877        let error = sample_error();
1878        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1879        // Main converter function signature
1880        assert!(output.contains(
1881            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1882        ));
1883        // Structured object with code + message
1884        assert!(output.contains("js_sys::Object::new()"));
1885        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1886        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1887        assert!(output.contains("obj.into()"));
1888        // error_code helper
1889        assert!(
1890            output
1891                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1892        );
1893        assert!(output.contains("\"parse_error\""));
1894        assert!(output.contains("\"io_error\""));
1895        assert!(output.contains("\"other\""));
1896        assert!(output.contains("#[allow(dead_code)]"));
1897    }
1898
1899    // -----------------------------------------------------------------------
1900    // PHP tests
1901    // -----------------------------------------------------------------------
1902
1903    #[test]
1904    fn test_gen_php_error_converter() {
1905        let error = sample_error();
1906        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1907        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1908        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1909        assert!(output.contains("#[allow(dead_code)]"));
1910    }
1911
1912    // -----------------------------------------------------------------------
1913    // Magnus tests
1914    // -----------------------------------------------------------------------
1915
1916    #[test]
1917    fn test_gen_magnus_error_converter() {
1918        let error = sample_error();
1919        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1920        assert!(
1921            output.contains(
1922                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1923            )
1924        );
1925        assert!(
1926            output.contains(
1927                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1928            )
1929        );
1930        assert!(output.contains("#[allow(dead_code)]"));
1931    }
1932
1933    // -----------------------------------------------------------------------
1934    // Rustler tests
1935    // -----------------------------------------------------------------------
1936
1937    #[test]
1938    fn test_gen_rustler_error_converter() {
1939        let error = sample_error();
1940        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1941        assert!(
1942            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1943        );
1944        assert!(output.contains("e.to_string()"));
1945        assert!(output.contains("#[allow(dead_code)]"));
1946    }
1947
1948    // -----------------------------------------------------------------------
1949    // Go error struct with methods tests
1950    // -----------------------------------------------------------------------
1951
1952    #[test]
1953    fn test_gen_go_error_struct_with_methods() {
1954        let error = error_with_methods();
1955        let output = gen_go_error_struct(&error, "literllm");
1956        // Stutter-stripped: "LiterLlm" prefix of "LiterLlmError" stripped for "literllm" pkg
1957        assert!(output.contains("type Error struct {"), "struct def: {output}");
1958        assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
1959        assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
1960        assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
1961        assert!(
1962            output.contains("func (e Error) StatusCode() uint16 { return e.StatusCode }"),
1963            "{output}"
1964        );
1965        assert!(
1966            output.contains("func (e Error) IsTransient() bool { return e.IsTransient }"),
1967            "{output}"
1968        );
1969        assert!(
1970            output.contains("func (e Error) ErrorType() string { return e.ErrorType }"),
1971            "{output}"
1972        );
1973    }
1974
1975    #[test]
1976    fn test_gen_go_error_struct_no_methods() {
1977        let error = sample_error(); // methods: vec![]
1978        let output = gen_go_error_struct(&error, "mylib");
1979        assert!(output.contains("type ConversionError struct {"), "{output}");
1980        assert!(!output.contains("StatusCode"), "{output}");
1981        assert!(!output.contains("IsTransient"), "{output}");
1982    }
1983
1984    // -----------------------------------------------------------------------
1985    // Java error types with methods tests
1986    // -----------------------------------------------------------------------
1987
1988    #[test]
1989    fn test_gen_java_error_types_with_methods() {
1990        let error = error_with_methods();
1991        let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
1992        assert_eq!(files.len(), 1); // base only, no variants
1993        let base = &files[0].1;
1994        assert!(
1995            base.contains("private final int statusCode;"),
1996            "statusCode field: {base}"
1997        );
1998        assert!(
1999            base.contains("private final boolean isTransient;"),
2000            "isTransient field: {base}"
2001        );
2002        assert!(
2003            base.contains("private final String errorType;"),
2004            "errorType field: {base}"
2005        );
2006        assert!(
2007            base.contains("public int getStatusCode()"),
2008            "getStatusCode getter: {base}"
2009        );
2010        assert!(
2011            base.contains("public boolean isTransient()"),
2012            "isTransient getter: {base}"
2013        );
2014        assert!(
2015            base.contains("public String getErrorType()"),
2016            "getErrorType getter: {base}"
2017        );
2018        // Simple no-args constructor still present
2019        assert!(
2020            base.contains("public LiterLlmErrorException(final String message)"),
2021            "simple ctor: {base}"
2022        );
2023        // Full constructor with introspection params
2024        assert!(
2025            base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
2026            "full ctor: {base}"
2027        );
2028    }
2029
2030    #[test]
2031    fn test_gen_java_error_types_no_methods() {
2032        let error = sample_error(); // methods: vec![]
2033        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2034        let base = &files[0].1;
2035        assert!(!base.contains("private final"), "no fields when no methods: {base}");
2036        assert!(
2037            base.contains("public ConversionErrorException(final String message)"),
2038            "{base}"
2039        );
2040    }
2041
2042    // -----------------------------------------------------------------------
2043    // C# error types with methods tests
2044    // -----------------------------------------------------------------------
2045
2046    #[test]
2047    fn test_gen_csharp_error_types_with_methods() {
2048        let error = error_with_methods();
2049        let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
2050        assert_eq!(files.len(), 1); // base only, no variants
2051        let base = &files[0].1;
2052        assert!(
2053            base.contains("public ushort StatusCode { get; }"),
2054            "StatusCode prop: {base}"
2055        );
2056        assert!(
2057            base.contains("public bool IsTransient { get; }"),
2058            "IsTransient prop: {base}"
2059        );
2060        assert!(
2061            base.contains("public string ErrorType { get; }"),
2062            "ErrorType prop: {base}"
2063        );
2064        // Simple constructor (with defaults)
2065        assert!(
2066            base.contains("public LiterLlmErrorException(string message) : base(message)"),
2067            "simple ctor: {base}"
2068        );
2069        // Full constructor
2070        assert!(
2071            base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
2072            "full ctor: {base}"
2073        );
2074    }
2075
2076    #[test]
2077    fn test_gen_csharp_error_types_no_methods() {
2078        let error = sample_error(); // methods: vec![]
2079        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2080        let base = &files[0].1;
2081        assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
2082        assert!(
2083            base.contains("public ConversionErrorException(string message) : base(message) { }"),
2084            "{base}"
2085        );
2086    }
2087
2088    // -----------------------------------------------------------------------
2089    // Helper tests
2090    // -----------------------------------------------------------------------
2091
2092    #[test]
2093    fn test_to_screaming_snake() {
2094        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
2095        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
2096        assert_eq!(to_screaming_snake("Other"), "OTHER");
2097    }
2098
2099    #[test]
2100    fn test_strip_thiserror_placeholders_struct_field() {
2101        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
2102        assert_eq!(
2103            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
2104            "plugin error in"
2105        );
2106        // Multi-placeholder strings retain the surrounding prose verbatim
2107        // (minus the holes). Critical contract: no `{` / `}` survives.
2108        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
2109        assert!(!result.contains('{'), "no braces: {result}");
2110        assert!(!result.contains('}'), "no braces: {result}");
2111        assert!(result.starts_with("extraction timed out after"), "{result}");
2112    }
2113
2114    #[test]
2115    fn test_strip_thiserror_placeholders_positional() {
2116        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
2117        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
2118    }
2119
2120    #[test]
2121    fn test_strip_thiserror_placeholders_no_placeholder() {
2122        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
2123        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
2124    }
2125
2126    #[test]
2127    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
2128        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
2129        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
2130        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
2131        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
2132        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
2133    }
2134
2135    #[test]
2136    fn test_acronym_aware_snake_phrase_plain_words() {
2137        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
2138        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
2139        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
2140    }
2141
2142    #[test]
2143    fn test_variant_display_message_acronym_first_word() {
2144        let variant = ErrorVariant {
2145            name: "Io".to_string(),
2146            message_template: Some("I/O error: {0}".to_string()),
2147            fields: vec![tuple_field(0)],
2148            has_source: false,
2149            has_from: false,
2150            is_unit: false,
2151            doc: String::new(),
2152        };
2153        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
2154        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
2155        let msg = variant_display_message(&variant);
2156        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
2157    }
2158
2159    #[test]
2160    fn test_variant_display_message_no_template_uses_acronyms() {
2161        let variant = ErrorVariant {
2162            name: "IoError".to_string(),
2163            message_template: None,
2164            fields: vec![],
2165            has_source: false,
2166            has_from: false,
2167            is_unit: false,
2168            doc: String::new(),
2169        };
2170        assert_eq!(variant_display_message(&variant), "IO error");
2171    }
2172
2173    #[test]
2174    fn test_variant_display_message_struct_template_no_leak() {
2175        let variant = ErrorVariant {
2176            name: "Ocr".to_string(),
2177            message_template: Some("OCR error: {message}".to_string()),
2178            fields: vec![named_field("message")],
2179            has_source: false,
2180            has_from: false,
2181            is_unit: false,
2182            doc: String::new(),
2183        };
2184        let msg = variant_display_message(&variant);
2185        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
2186    }
2187
2188    #[test]
2189    fn test_go_sentinels_no_placeholder_leak() {
2190        let error = ErrorDef {
2191            name: "KreuzbergError".to_string(),
2192            rust_path: "kreuzberg::KreuzbergError".to_string(),
2193            original_rust_path: String::new(),
2194            variants: vec![
2195                ErrorVariant {
2196                    name: "Io".to_string(),
2197                    message_template: Some("IO error: {message}".to_string()),
2198                    fields: vec![named_field("message")],
2199                    has_source: false,
2200                    has_from: false,
2201                    is_unit: false,
2202                    doc: String::new(),
2203                },
2204                ErrorVariant {
2205                    name: "Ocr".to_string(),
2206                    message_template: Some("OCR error: {message}".to_string()),
2207                    fields: vec![named_field("message")],
2208                    has_source: false,
2209                    has_from: false,
2210                    is_unit: false,
2211                    doc: String::new(),
2212                },
2213                ErrorVariant {
2214                    name: "Timeout".to_string(),
2215                    message_template: Some(
2216                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
2217                    ),
2218                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
2219                    has_source: false,
2220                    has_from: false,
2221                    is_unit: false,
2222                    doc: String::new(),
2223                },
2224            ],
2225            doc: String::new(),
2226            methods: vec![],
2227            binding_excluded: false,
2228            binding_exclusion_reason: None,
2229        };
2230        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
2231        assert!(
2232            !output.contains('{'),
2233            "Go sentinels must not contain raw placeholders:\n{output}"
2234        );
2235        assert!(
2236            output.contains("ErrIo = errors.New(\"IO error\")"),
2237            "expected acronym-preserving Io sentinel, got:\n{output}"
2238        );
2239        assert!(
2240            output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
2241            "Go sentinel comments must be emitted on separate lines, got:\n{output}"
2242        );
2243        assert!(
2244            output.contains("ErrOcr = errors.New(\"OCR error\")"),
2245            "expected acronym-preserving Ocr sentinel, got:\n{output}"
2246        );
2247        assert!(
2248            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
2249            "expected timeout sentinel to start with the prose, got:\n{output}"
2250        );
2251    }
2252
2253    // -----------------------------------------------------------------------
2254    // FFI (C) tests
2255    // -----------------------------------------------------------------------
2256
2257    #[test]
2258    fn test_gen_ffi_error_codes() {
2259        let error = sample_error();
2260        let output = gen_ffi_error_codes(&error);
2261        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
2262        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
2263        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
2264        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
2265        assert!(output.contains("conversion_error_t;"));
2266        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
2267    }
2268
2269    // -----------------------------------------------------------------------
2270    // Go tests
2271    // -----------------------------------------------------------------------
2272
2273    #[test]
2274    fn test_gen_go_error_types() {
2275        let error = sample_error();
2276        // Package name that does NOT match the error prefix — type name stays unchanged.
2277        let output = gen_go_error_types(&error, "mylib");
2278        assert!(output.contains("ErrParseError = errors.New("));
2279        assert!(output.contains("ErrIoError = errors.New("));
2280        assert!(output.contains("ErrOther = errors.New("));
2281        assert!(output.contains("type ConversionError struct {"));
2282        assert!(output.contains("Code    string"));
2283        assert!(output.contains("func (e ConversionError) Error() string"));
2284        // Each sentinel error var should have a doc comment.
2285        assert!(output.contains("// ErrParseError is returned when"));
2286        assert!(output.contains("// ErrIoError is returned when"));
2287        assert!(output.contains("// ErrOther is returned when"));
2288    }
2289
2290    #[test]
2291    fn test_gen_go_error_types_stutter_strip() {
2292        let error = sample_error();
2293        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
2294        // so the exported Go type should be "Error", not "ConversionError".
2295        let output = gen_go_error_types(&error, "conversion");
2296        assert!(
2297            output.contains("type Error struct {"),
2298            "expected stutter strip, got:\n{output}"
2299        );
2300        assert!(
2301            output.contains("func (e Error) Error() string"),
2302            "expected stutter strip, got:\n{output}"
2303        );
2304        // Sentinel vars are unaffected by stutter stripping.
2305        assert!(output.contains("ErrParseError = errors.New("));
2306    }
2307
2308    // -----------------------------------------------------------------------
2309    // Java tests
2310    // -----------------------------------------------------------------------
2311
2312    #[test]
2313    fn test_gen_java_error_types() {
2314        let error = sample_error();
2315        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2316        // base + 3 variants
2317        assert_eq!(files.len(), 4);
2318        // Base class
2319        assert_eq!(files[0].0, "ConversionErrorException");
2320        assert!(
2321            files[0]
2322                .1
2323                .contains("public class ConversionErrorException extends Exception")
2324        );
2325        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
2326        // Variant classes
2327        assert_eq!(files[1].0, "ParseErrorException");
2328        assert!(
2329            files[1]
2330                .1
2331                .contains("public class ParseErrorException extends ConversionErrorException")
2332        );
2333        assert_eq!(files[2].0, "IoErrorException");
2334        assert_eq!(files[3].0, "OtherException");
2335    }
2336
2337    // -----------------------------------------------------------------------
2338    // C# tests
2339    // -----------------------------------------------------------------------
2340
2341    #[test]
2342    fn test_gen_csharp_error_types() {
2343        let error = sample_error();
2344        // Without fallback class: base inherits from Exception.
2345        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2346        assert_eq!(files.len(), 4);
2347        assert_eq!(files[0].0, "ConversionErrorException");
2348        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
2349        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
2350        assert_eq!(files[1].0, "ParseErrorException");
2351        assert!(
2352            files[1]
2353                .1
2354                .contains("public class ParseErrorException : ConversionErrorException")
2355        );
2356        assert_eq!(files[2].0, "IoErrorException");
2357        assert_eq!(files[3].0, "OtherException");
2358    }
2359
2360    #[test]
2361    fn test_gen_csharp_error_types_with_fallback() {
2362        let error = sample_error();
2363        // With fallback class: base inherits from the generic library exception.
2364        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
2365        assert_eq!(files.len(), 4);
2366        assert!(
2367            files[0]
2368                .1
2369                .contains("public class ConversionErrorException : TestLibException")
2370        );
2371        // Variant classes still inherit from the base error class, not from the fallback directly.
2372        assert!(
2373            files[1]
2374                .1
2375                .contains("public class ParseErrorException : ConversionErrorException")
2376        );
2377    }
2378
2379    // -----------------------------------------------------------------------
2380    // python_exception_name tests
2381    // -----------------------------------------------------------------------
2382
2383    #[test]
2384    fn test_python_exception_name_no_conflict() {
2385        // "ParseError" already ends with "Error" and is not a builtin
2386        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
2387        // "Other" gets "Error" suffix, "OtherError" is not a builtin
2388        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
2389    }
2390
2391    #[test]
2392    fn test_python_exception_name_shadows_builtin() {
2393        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
2394        assert_eq!(
2395            python_exception_name("Connection", "CrawlError"),
2396            "CrawlConnectionError"
2397        );
2398        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
2399        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
2400        // "ConnectionError" already ends with "Error", still shadows -> prefix
2401        assert_eq!(
2402            python_exception_name("ConnectionError", "CrawlError"),
2403            "CrawlConnectionError"
2404        );
2405    }
2406
2407    #[test]
2408    fn test_python_exception_name_no_double_prefix() {
2409        // If variant is already prefixed with the error base, don't double-prefix
2410        assert_eq!(
2411            python_exception_name("CrawlConnectionError", "CrawlError"),
2412            "CrawlConnectionError"
2413        );
2414    }
2415
2416    // -----------------------------------------------------------------------
2417    // WASM error methods tests
2418    // -----------------------------------------------------------------------
2419
2420    fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
2421        alef_core::ir::MethodDef {
2422            name: name.to_string(),
2423            params: vec![],
2424            return_type,
2425            is_async: false,
2426            is_static: false,
2427            error_type: None,
2428            doc: String::new(),
2429            receiver: Some(alef_core::ir::ReceiverKind::Ref),
2430            sanitized: false,
2431            trait_source: None,
2432            returns_ref: false,
2433            returns_cow: false,
2434            return_newtype_wrapper: None,
2435            has_default_impl: false,
2436            binding_excluded: false,
2437            binding_exclusion_reason: None,
2438        }
2439    }
2440
2441    fn error_with_methods() -> ErrorDef {
2442        ErrorDef {
2443            name: "LiterLlmError".to_string(),
2444            rust_path: "liter_llm::error::LiterLlmError".to_string(),
2445            original_rust_path: String::new(),
2446            variants: vec![],
2447            doc: String::new(),
2448            methods: vec![
2449                sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
2450                sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
2451                sample_method("error_type", TypeRef::String),
2452            ],
2453            binding_excluded: false,
2454            binding_exclusion_reason: None,
2455        }
2456    }
2457
2458    #[test]
2459    fn test_gen_wasm_error_methods_empty_when_no_methods() {
2460        let error = sample_error(); // methods: vec![]
2461        let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
2462        assert!(output.is_empty(), "should produce no output when methods is empty");
2463    }
2464
2465    #[test]
2466    fn test_gen_wasm_error_methods_struct_and_impl() {
2467        let error = error_with_methods();
2468        // wasm_prefix is the full type prefix, e.g. "Wasm" — the struct name is
2469        // {wasm_prefix}{ErrorName} = "WasmLiterLlmError".
2470        let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
2471        // Struct definition
2472        assert!(
2473            output.contains("pub struct WasmLiterLlmError"),
2474            "must emit opaque struct: {output}"
2475        );
2476        assert!(
2477            output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
2478            "{output}"
2479        );
2480        // Impl block
2481        assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
2482        // Methods with camelCase js_name
2483        assert!(output.contains("js_name = \"statusCode\""), "{output}");
2484        assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
2485        assert!(output.contains("self.inner.status_code()"), "{output}");
2486        assert!(output.contains("js_name = \"isTransient\""), "{output}");
2487        assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
2488        assert!(output.contains("self.inner.is_transient()"), "{output}");
2489        assert!(output.contains("js_name = \"errorType\""), "{output}");
2490        assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
2491        assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
2492    }
2493
2494    // -----------------------------------------------------------------------
2495    // FFI error methods tests
2496    // -----------------------------------------------------------------------
2497
2498    #[test]
2499    fn test_gen_ffi_error_methods_empty_when_no_methods() {
2500        let error = sample_error(); // methods: vec![]
2501        let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
2502        assert!(output.is_empty(), "should produce no output when methods is empty");
2503    }
2504
2505    #[test]
2506    fn test_gen_ffi_error_methods_status_code() {
2507        let error = error_with_methods();
2508        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2509        assert!(
2510            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
2511            "must emit status_code fn: {output}"
2512        );
2513        assert!(
2514            output.contains("err: *const liter_llm::error::LiterLlmError"),
2515            "{output}"
2516        );
2517        assert!(output.contains("-> u16"), "{output}");
2518        assert!(output.contains("(*err).status_code()"), "{output}");
2519        assert!(output.contains("if err.is_null()"), "{output}");
2520        assert!(output.contains("return 0;"), "{output}");
2521    }
2522
2523    #[test]
2524    fn test_gen_ffi_error_methods_is_transient() {
2525        let error = error_with_methods();
2526        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2527        assert!(
2528            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
2529            "must emit is_transient fn: {output}"
2530        );
2531        assert!(output.contains("-> bool"), "{output}");
2532        assert!(output.contains("(*err).is_transient()"), "{output}");
2533        assert!(output.contains("return false;"), "{output}");
2534    }
2535
2536    #[test]
2537    fn test_gen_ffi_error_methods_error_type_with_free() {
2538        let error = error_with_methods();
2539        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2540        assert!(
2541            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
2542            "must emit error_type fn: {output}"
2543        );
2544        assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
2545        assert!(output.contains("(*err).error_type()"), "{output}");
2546        assert!(output.contains("CString::new(s)"), "{output}");
2547        assert!(output.contains(".into_raw()"), "{output}");
2548        assert!(output.contains("return std::ptr::null_mut();"), "{output}");
2549        // free companion
2550        assert!(
2551            output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
2552            "must emit _free companion: {output}"
2553        );
2554        assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
2555    }
2556
2557    #[test]
2558    fn test_gen_ffi_error_methods_safety_comments() {
2559        let error = error_with_methods();
2560        let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2561        assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
2562    }
2563}