Skip to main content

alef_codegen/
error_gen.rs

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