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).
115pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
116    let rust_path = if error.rust_path.is_empty() {
117        format!("{core_import}::{}", error.name)
118    } else {
119        let normalized = error.rust_path.replace('-', "_");
120        // Paths with more than 2 segments (e.g. `mylib_core::di::error::DependencyError`)
121        // reference private internal modules that are not accessible from generated binding code.
122        // Fall back to the public re-export form `{crate}::{ErrorName}` (2 segments).
123        let segments: Vec<&str> = normalized.split("::").collect();
124        if segments.len() > 2 {
125            let crate_name = segments[0];
126            let error_name = segments[segments.len() - 1];
127            format!("{crate_name}::{error_name}")
128        } else {
129            normalized
130        }
131    };
132
133    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
134
135    // Pre-compute variants as (pattern, exc_name) tuples
136    let mut variants = Vec::new();
137    for variant in &error.variants {
138        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
139        let variant_exc_name = python_exception_name(&variant.name, &error.name);
140        variants.push((pattern, variant_exc_name));
141    }
142
143    crate::template_env::render(
144        "error_gen/pyo3_error_converter.jinja",
145        minijinja::context! {
146            rust_path => rust_path.as_str(),
147            fn_name => fn_name.as_str(),
148            error_name => error.name.as_str(),
149            variants => variants,
150        },
151    )
152}
153
154/// Generate `m.add(...)` registration calls for each exception type.
155/// Uses Error-suffixed names for variant exceptions (N818 compliance).
156/// Prefixes names that would shadow Python builtins (A004 compliance).
157pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
158    let mut registrations = Vec::with_capacity(error.variants.len() + 1);
159
160    for variant in &error.variants {
161        let variant_exc_name = python_exception_name(&variant.name, &error.name);
162        if seen_registrations.insert(variant_exc_name.clone()) {
163            registrations.push(format!(
164                "    m.add(\"{}\", m.py().get_type::<{}>())?;",
165                variant_exc_name, variant_exc_name
166            ));
167        }
168    }
169
170    // Base exception
171    if seen_registrations.insert(error.name.clone()) {
172        registrations.push(format!(
173            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
174            error.name, error.name
175        ));
176    }
177
178    registrations
179}
180
181/// Return the converter function name for a given error type.
182pub fn converter_fn_name(error: &ErrorDef) -> String {
183    format!("{}_to_py_err", to_snake_case(&error.name))
184}
185
186/// Simple CamelCase to snake_case conversion.
187fn to_snake_case(s: &str) -> String {
188    let mut result = String::with_capacity(s.len() + 4);
189    for (i, c) in s.chars().enumerate() {
190        if c.is_uppercase() {
191            if i > 0 {
192                result.push('_');
193            }
194            result.push(c.to_ascii_lowercase());
195        } else {
196            result.push(c);
197        }
198    }
199    result
200}
201
202// ---------------------------------------------------------------------------
203// NAPI (Node.js) error generation
204// ---------------------------------------------------------------------------
205
206/// Generate a `JsError` enum with string constants for each error variant name.
207pub fn gen_napi_error_types(error: &ErrorDef) -> String {
208    // Pre-compute (const_name, variant_name) pairs
209    let mut variants = Vec::new();
210    let error_screaming = to_screaming_snake(&error.name);
211    for variant in &error.variants {
212        let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
213        variants.push((variant_const, variant.name.clone()));
214    }
215
216    crate::template_env::render(
217        "error_gen/napi_error_types.jinja",
218        minijinja::context! {
219            variants => variants,
220        },
221    )
222}
223
224/// Generate a converter function that maps a core error to `napi::Error`.
225pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
226    let rust_path = if error.rust_path.is_empty() {
227        format!("{core_import}::{}", error.name)
228    } else {
229        error.rust_path.replace('-', "_")
230    };
231
232    let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
233
234    // Pre-compute (pattern, variant_name) pairs
235    let mut variants = Vec::new();
236    for variant in &error.variants {
237        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
238        variants.push((pattern, variant.name.clone()));
239    }
240
241    crate::template_env::render(
242        "error_gen/napi_error_converter.jinja",
243        minijinja::context! {
244            rust_path => rust_path.as_str(),
245            fn_name => fn_name.as_str(),
246            variants => variants,
247        },
248    )
249}
250
251/// Return the NAPI converter function name for a given error type.
252pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
253    format!("{}_to_napi_err", to_snake_case(&error.name))
254}
255
256// ---------------------------------------------------------------------------
257// WASM (wasm-bindgen) error generation
258// ---------------------------------------------------------------------------
259
260/// Generate a converter function that maps a core error to a `JsValue` object
261/// with `code` (string) and `message` (string) fields, plus a private
262/// `error_code` helper that returns the variant code string.
263pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
264    let rust_path = if error.rust_path.is_empty() {
265        format!("{core_import}::{}", error.name)
266    } else {
267        error.rust_path.replace('-', "_")
268    };
269
270    let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
271    let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
272
273    // Pre-compute variants for error_code helper: (pattern, code) pairs
274    let mut code_variants = Vec::new();
275    for variant in &error.variants {
276        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
277        let code = to_snake_case(&variant.name);
278        code_variants.push((pattern, code));
279    }
280    let default_code = to_snake_case(&error.name);
281
282    let code_fn = crate::template_env::render(
283        "error_gen/wasm_error_code_fn.jinja",
284        minijinja::context! {
285            rust_path => rust_path.as_str(),
286            code_fn_name => code_fn_name.as_str(),
287            variants => code_variants,
288            default_code => default_code.as_str(),
289        },
290    );
291
292    let converter_fn = crate::template_env::render(
293        "error_gen/wasm_error_converter.jinja",
294        minijinja::context! {
295            rust_path => rust_path.as_str(),
296            fn_name => fn_name.as_str(),
297            code_fn_name => code_fn_name.as_str(),
298        },
299    );
300
301    format!("{}\n\n{}", code_fn, converter_fn)
302}
303
304/// Return the WASM converter function name for a given error type.
305pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
306    format!("{}_to_js_value", to_snake_case(&error.name))
307}
308
309// ---------------------------------------------------------------------------
310// PHP (ext-php-rs) error generation
311// ---------------------------------------------------------------------------
312
313/// Generate a converter function that maps a core error to `PhpException`.
314pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
315    let rust_path = if error.rust_path.is_empty() {
316        format!("{core_import}::{}", error.name)
317    } else {
318        error.rust_path.replace('-', "_")
319    };
320
321    let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
322
323    // Pre-compute (pattern, variant_name) pairs
324    let mut variants = Vec::new();
325    for variant in &error.variants {
326        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
327        variants.push((pattern, variant.name.clone()));
328    }
329
330    crate::template_env::render(
331        "error_gen/php_error_converter.jinja",
332        minijinja::context! {
333            rust_path => rust_path.as_str(),
334            fn_name => fn_name.as_str(),
335            variants => variants,
336        },
337    )
338}
339
340/// Return the PHP converter function name for a given error type.
341pub fn php_converter_fn_name(error: &ErrorDef) -> String {
342    format!("{}_to_php_err", to_snake_case(&error.name))
343}
344
345// ---------------------------------------------------------------------------
346// Magnus (Ruby) error generation
347// ---------------------------------------------------------------------------
348
349/// Generate a converter function that maps a core error to `magnus::Error`.
350pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
351    let rust_path = if error.rust_path.is_empty() {
352        format!("{core_import}::{}", error.name)
353    } else {
354        error.rust_path.replace('-', "_")
355    };
356
357    let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
358
359    crate::template_env::render(
360        "error_gen/magnus_error_converter.jinja",
361        minijinja::context! {
362            rust_path => rust_path.as_str(),
363            fn_name => fn_name.as_str(),
364        },
365    )
366}
367
368/// Return the Magnus converter function name for a given error type.
369pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
370    format!("{}_to_magnus_err", to_snake_case(&error.name))
371}
372
373// ---------------------------------------------------------------------------
374// Rustler (Elixir) error generation
375// ---------------------------------------------------------------------------
376
377/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
378pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
379    let rust_path = if error.rust_path.is_empty() {
380        format!("{core_import}::{}", error.name)
381    } else {
382        error.rust_path.replace('-', "_")
383    };
384
385    let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
386
387    crate::template_env::render(
388        "error_gen/rustler_error_converter.jinja",
389        minijinja::context! {
390            rust_path => rust_path.as_str(),
391            fn_name => fn_name.as_str(),
392        },
393    )
394}
395
396/// Return the Rustler converter function name for a given error type.
397pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
398    format!("{}_to_rustler_err", to_snake_case(&error.name))
399}
400
401// ---------------------------------------------------------------------------
402// FFI (C) error code generation
403// ---------------------------------------------------------------------------
404
405/// Generate a C enum of error codes plus an error-message function declaration.
406///
407/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
408/// per variant, plus a function that returns the default message for a given code.
409pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
410    let prefix = to_screaming_snake(&error.name);
411    let prefix_lower = to_snake_case(&error.name);
412
413    // Pre-compute (variant_screaming, index) pairs
414    let mut variant_variants = Vec::new();
415    for (i, variant) in error.variants.iter().enumerate() {
416        let variant_screaming = to_screaming_snake(&variant.name);
417        variant_variants.push((variant_screaming, (i + 1).to_string()));
418    }
419
420    crate::template_env::render(
421        "error_gen/ffi_error_codes.jinja",
422        minijinja::context! {
423            error_name => error.name.as_str(),
424            prefix => prefix.as_str(),
425            prefix_lower => prefix_lower.as_str(),
426            variant_variants => variant_variants,
427        },
428    )
429}
430
431// ---------------------------------------------------------------------------
432// Go error type generation
433// ---------------------------------------------------------------------------
434
435/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
436///
437/// `pkg_name` is the Go package name (e.g. `"literllm"`). When the error struct
438/// name starts with the package name (case-insensitively), the package-name
439/// prefix is stripped to avoid the revive `exported` stutter lint error
440/// (e.g. `LiterLlmError` in package `literllm` → exported as `Error`).
441pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
442    let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
443    let structured = gen_go_error_struct(error, pkg_name);
444    format!("{}\n\n{}", sentinels, structured)
445}
446
447/// Generate a single consolidated `var (...)` block of Go sentinel errors
448/// across multiple `ErrorDef`s.
449///
450/// When the same variant name appears in more than one `ErrorDef` (e.g. both
451/// `GraphQLError` and `SchemaError` define `ValidationError`), the colliding
452/// const names are disambiguated by prefixing with the parent error type's
453/// stripped base name. For example, `GraphQLError::ValidationError` and
454/// `SchemaError::ValidationError` become `ErrGraphQLValidationError` and
455/// `ErrSchemaValidationError`. Variant names that are unique across all
456/// errors are emitted as plain `Err{Variant}` consts.
457pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
458    if errors.is_empty() {
459        return String::new();
460    }
461    let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
462    for err in errors {
463        for v in &err.variants {
464            *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
465        }
466    }
467    let mut seen = std::collections::HashSet::new();
468    let mut sentinels = Vec::new();
469    for err in errors {
470        let parent_base = error_base_prefix(&err.name);
471        for variant in &err.variants {
472            let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
473            let const_name = if collides {
474                format!("Err{}{}", parent_base, variant.name)
475            } else {
476                format!("Err{}", variant.name)
477            };
478            if !seen.insert(const_name.clone()) {
479                continue;
480            }
481            let msg = variant_display_message(variant);
482            sentinels.push((const_name, msg));
483        }
484    }
485
486    crate::template_env::render(
487        "error_gen/go_sentinel_errors.jinja",
488        minijinja::context! {
489            sentinels => sentinels,
490        },
491    )
492}
493
494/// Generate the structured error type (struct + Error() method) for a single
495/// error definition. Sentinel errors are emitted separately by
496/// [`gen_go_sentinel_errors`].
497pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
498    let go_type_name = strip_package_prefix(&error.name, pkg_name);
499
500    crate::template_env::render(
501        "error_gen/go_error_struct.jinja",
502        minijinja::context! {
503            go_type_name => go_type_name.as_str(),
504        },
505    )
506}
507
508/// Strip the package-name prefix from a type name to avoid revive's stutter lint.
509///
510/// Revive reports `exported: type name will be used as pkg.PkgFoo by other packages,
511/// and that stutters` when a type name begins with the package name. This function
512/// removes the prefix when it matches (case-insensitively) so that the exported name
513/// does not repeat the package name.
514///
515/// Examples:
516/// - `("LiterLlmError", "literllm")` → `"Error"` (lowercased `literllm` is a prefix
517///   of lowercased `literllmerror`)
518/// - `("ConversionError", "converter")` → `"ConversionError"` (no match)
519fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
520    let type_lower = type_name.to_lowercase();
521    let pkg_lower = pkg_name.to_lowercase();
522    if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
523        // Retain the original casing for the suffix part.
524        type_name[pkg_lower.len()..].to_string()
525    } else {
526        type_name.to_string()
527    }
528}
529
530// ---------------------------------------------------------------------------
531// Java error type generation
532// ---------------------------------------------------------------------------
533
534/// Generate Java exception sub-classes for each error variant.
535///
536/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
537/// class followed by one per-variant exception.  The caller writes each to a
538/// separate `.java` file.
539pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
540    let mut files = Vec::with_capacity(error.variants.len() + 1);
541
542    // Base exception class
543    let base_name = format!("{}Exception", error.name);
544    let doc_lines: Vec<&str> = error.doc.lines().collect();
545
546    let base = crate::template_env::render(
547        "error_gen/java_error_base.jinja",
548        minijinja::context! {
549            package => package,
550            base_name => base_name.as_str(),
551            doc => !error.doc.is_empty(),
552            doc_lines => doc_lines,
553        },
554    );
555    files.push((base_name.clone(), base));
556
557    // Per-variant exception classes
558    for variant in &error.variants {
559        let class_name = format!("{}Exception", variant.name);
560        let doc_lines: Vec<&str> = variant.doc.lines().collect();
561
562        let content = crate::template_env::render(
563            "error_gen/java_error_variant.jinja",
564            minijinja::context! {
565                package => package,
566                class_name => class_name.as_str(),
567                base_name => base_name.as_str(),
568                doc => !variant.doc.is_empty(),
569                doc_lines => doc_lines,
570            },
571        );
572        files.push((class_name, content));
573    }
574
575    files
576}
577
578// ---------------------------------------------------------------------------
579// C# error type generation
580// ---------------------------------------------------------------------------
581
582/// Generate C# exception sub-classes for each error variant.
583///
584/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
585/// class followed by one per-variant exception.  The caller writes each to a
586/// separate `.cs` file.
587///
588/// `fallback_class` is the name of the generic library exception class (e.g.
589/// `TreeSitterLanguagePackException`) that the base error class should extend so that
590/// callers can `catch` the general library exception and catch all typed errors.
591pub fn gen_csharp_error_types(
592    error: &ErrorDef,
593    namespace: &str,
594    fallback_class: Option<&str>,
595) -> Vec<(String, String)> {
596    let mut files = Vec::with_capacity(error.variants.len() + 1);
597
598    let base_name = format!("{}Exception", error.name);
599    // Inherit from the generic library exception when provided so that
600    // `Assert.ThrowsAny<LibException>()` catches typed errors too.
601    let base_parent = fallback_class.unwrap_or("Exception");
602    let error_doc_lines: Vec<&str> = error.doc.lines().collect();
603
604    // Base exception class
605    {
606        let out = crate::template_env::render(
607            "error_gen/csharp_error_base.jinja",
608            minijinja::context! {
609                namespace => namespace,
610                base_name => base_name.as_str(),
611                base_parent => base_parent,
612                doc => !error.doc.is_empty(),
613                doc_lines => error_doc_lines,
614            },
615        );
616        files.push((base_name.clone(), out));
617    }
618
619    // Per-variant exception classes
620    for variant in &error.variants {
621        let class_name = format!("{}Exception", variant.name);
622        let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
623
624        let out = crate::template_env::render(
625            "error_gen/csharp_error_variant.jinja",
626            minijinja::context! {
627                namespace => namespace,
628                class_name => class_name.as_str(),
629                base_name => base_name.as_str(),
630                doc => !variant.doc.is_empty(),
631                doc_lines => variant_doc_lines,
632            },
633        );
634        files.push((class_name, out));
635    }
636
637    files
638}
639
640// ---------------------------------------------------------------------------
641// Helpers
642// ---------------------------------------------------------------------------
643
644/// Convert CamelCase to SCREAMING_SNAKE_CASE.
645fn to_screaming_snake(s: &str) -> String {
646    let mut result = String::with_capacity(s.len() + 4);
647    for (i, c) in s.chars().enumerate() {
648        if c.is_uppercase() {
649            if i > 0 {
650                result.push('_');
651            }
652            result.push(c.to_ascii_uppercase());
653        } else {
654            result.push(c.to_ascii_uppercase());
655        }
656    }
657    result
658}
659
660/// Well-known acronyms recognised by the doc/error renderers.
661///
662/// When emitting human-readable Display strings (e.g. for Go sentinel
663/// `errors.New("...")`), variant names like `IoError` must render as
664/// "IO error" — not "iO error" (the result of naive `lowercase first
665/// character` after `to_snake_case`).
666const TECHNICAL_ACRONYMS: &[&str] = &[
667    "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
668    "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
669    "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
670    "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
671];
672
673/// Strip `thiserror`-style `{name}` placeholders from a Display template
674/// without leaving stray punctuation.
675///
676/// Examples:
677///
678/// - `"OCR error: {message}"`           → `"OCR error"`
679/// - `"plugin error in '{plugin_name}'"` → `"plugin error"`
680/// - `"timed out after {elapsed_ms}ms (limit: {limit_ms}ms)"` → `"timed out"`
681/// - `"I/O error: {0}"`                  → `"I/O error"`
682///
683/// Used by `variant_display_message` and binding error renderers
684/// (Dart, Go, …) so the literal placeholder string never reaches
685/// the runtime.
686pub fn strip_thiserror_placeholders(template: &str) -> String {
687    // Remove every `{...}` segment.
688    let mut without_placeholders = String::with_capacity(template.len());
689    let mut depth = 0u32;
690    for ch in template.chars() {
691        match ch {
692            '{' => depth = depth.saturating_add(1),
693            '}' => depth = depth.saturating_sub(1),
694            other if depth == 0 => without_placeholders.push(other),
695            _ => {}
696        }
697    }
698    // Remove orphaned punctuation/whitespace immediately around the holes
699    // (collapse runs of whitespace, drop trailing `:`/quote runs, drop
700    // `(...)` shells that wrapped only placeholders).
701    let mut compacted = String::with_capacity(without_placeholders.len());
702    let mut last_was_space = false;
703    for ch in without_placeholders.chars() {
704        if ch.is_whitespace() {
705            if !last_was_space && !compacted.is_empty() {
706                compacted.push(' ');
707            }
708            last_was_space = true;
709        } else {
710            compacted.push(ch);
711            last_was_space = false;
712        }
713    }
714    // Trim trailing punctuation that only made sense before a placeholder.
715    let trimmed = compacted
716        .trim()
717        .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
718        .trim();
719    // If we left e.g. `"limit: ms ms"` artefacts behind, collapse stray
720    // empty parens / paired quotes.
721    let cleaned = trimmed
722        .replace("()", "")
723        .replace("''", "")
724        .replace("\"\"", "")
725        .replace("  ", " ");
726    cleaned.trim().to_string()
727}
728
729/// Convert a PascalCase variant name into a human readable phrase that
730/// preserves canonical acronyms.
731///
732/// Examples:
733/// - `"IoError"`           → `"IO error"`
734/// - `"OcrError"`          → `"OCR error"`
735/// - `"PdfParse"`          → `"PDF parse"`
736/// - `"HttpRequestFailed"` → `"HTTP request failed"`
737/// - `"Other"`             → `"other"`
738pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
739    if variant_name.is_empty() {
740        return String::new();
741    }
742    // Split into PascalCase words (each word starts with an uppercase letter).
743    let bytes = variant_name.as_bytes();
744    let mut words: Vec<&str> = Vec::new();
745    let mut start = 0usize;
746    for i in 1..bytes.len() {
747        if bytes[i].is_ascii_uppercase() {
748            words.push(&variant_name[start..i]);
749            start = i;
750        }
751    }
752    words.push(&variant_name[start..]);
753
754    let mut rendered: Vec<String> = Vec::with_capacity(words.len());
755    for word in &words {
756        let upper = word.to_ascii_uppercase();
757        if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
758            rendered.push(upper);
759        } else {
760            rendered.push(word.to_ascii_lowercase());
761        }
762    }
763    rendered.join(" ")
764}
765
766/// Generate a human-readable message for an error variant.
767///
768/// Uses the `message_template` if present, otherwise falls back to a
769/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
770fn variant_display_message(variant: &ErrorVariant) -> String {
771    if let Some(tmpl) = &variant.message_template {
772        let stripped = strip_thiserror_placeholders(tmpl);
773        if stripped.is_empty() {
774            return acronym_aware_snake_phrase(&variant.name);
775        }
776        // Preserve canonical acronyms but lowercase the first regular word so
777        // Go's `lowercase first char` convention does not corrupt `IO` → `iO`.
778        // Heuristic: if the first whitespace-delimited token is *not* already
779        // a known acronym, downcase its first character.
780        let mut tokens = stripped.splitn(2, ' ');
781        let head = tokens.next().unwrap_or("").to_string();
782        let tail = tokens.next().unwrap_or("");
783        let head_upper = head.to_ascii_uppercase();
784        let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
785            head_upper
786        } else {
787            let mut chars = head.chars();
788            match chars.next() {
789                Some(c) => c.to_lowercase().to_string() + chars.as_str(),
790                None => head,
791            }
792        };
793        if tail.is_empty() {
794            head_rendered
795        } else {
796            format!("{} {}", head_rendered, tail)
797        }
798    } else {
799        acronym_aware_snake_phrase(&variant.name)
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use alef_core::ir::{ErrorDef, ErrorVariant};
807
808    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
809
810    /// Helper to create a tuple-style field (e.g. `_0: String`).
811    fn tuple_field(index: usize) -> FieldDef {
812        FieldDef {
813            name: format!("_{index}"),
814            ty: TypeRef::String,
815            optional: false,
816            default: None,
817            doc: String::new(),
818            sanitized: false,
819            is_boxed: false,
820            type_rust_path: None,
821            cfg: None,
822            typed_default: None,
823            core_wrapper: CoreWrapper::None,
824            vec_inner_core_wrapper: CoreWrapper::None,
825            newtype_wrapper: None,
826            serde_rename: None,
827        }
828    }
829
830    /// Helper to create a named struct field.
831    fn named_field(name: &str) -> FieldDef {
832        FieldDef {
833            name: name.to_string(),
834            ty: TypeRef::String,
835            optional: false,
836            default: None,
837            doc: String::new(),
838            sanitized: false,
839            is_boxed: false,
840            type_rust_path: None,
841            cfg: None,
842            typed_default: None,
843            core_wrapper: CoreWrapper::None,
844            vec_inner_core_wrapper: CoreWrapper::None,
845            newtype_wrapper: None,
846            serde_rename: None,
847        }
848    }
849
850    fn sample_error() -> ErrorDef {
851        ErrorDef {
852            name: "ConversionError".to_string(),
853            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
854            original_rust_path: String::new(),
855            variants: vec![
856                ErrorVariant {
857                    name: "ParseError".to_string(),
858                    message_template: Some("HTML parsing error: {0}".to_string()),
859                    fields: vec![tuple_field(0)],
860                    has_source: false,
861                    has_from: false,
862                    is_unit: false,
863                    doc: String::new(),
864                },
865                ErrorVariant {
866                    name: "IoError".to_string(),
867                    message_template: Some("I/O error: {0}".to_string()),
868                    fields: vec![tuple_field(0)],
869                    has_source: false,
870                    has_from: true,
871                    is_unit: false,
872                    doc: String::new(),
873                },
874                ErrorVariant {
875                    name: "Other".to_string(),
876                    message_template: Some("Conversion error: {0}".to_string()),
877                    fields: vec![tuple_field(0)],
878                    has_source: false,
879                    has_from: false,
880                    is_unit: false,
881                    doc: String::new(),
882                },
883            ],
884            doc: "Error type for conversion operations.".to_string(),
885        }
886    }
887
888    #[test]
889    fn test_gen_error_types() {
890        let error = sample_error();
891        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
892        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
893        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
894        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
895        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
896    }
897
898    #[test]
899    fn test_gen_error_converter() {
900        let error = sample_error();
901        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
902        assert!(
903            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
904        );
905        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
906        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
907    }
908
909    #[test]
910    fn test_gen_error_registration() {
911        let error = sample_error();
912        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
913        assert_eq!(regs.len(), 4); // 3 variants + 1 base
914        assert!(regs[0].contains("\"ParseError\""));
915        assert!(regs[3].contains("\"ConversionError\""));
916    }
917
918    #[test]
919    fn test_unit_variant_pattern() {
920        let error = ErrorDef {
921            name: "MyError".to_string(),
922            rust_path: "my_crate::MyError".to_string(),
923            original_rust_path: String::new(),
924            variants: vec![ErrorVariant {
925                name: "NotFound".to_string(),
926                message_template: Some("not found".to_string()),
927                fields: vec![],
928                has_source: false,
929                has_from: false,
930                is_unit: true,
931                doc: String::new(),
932            }],
933            doc: String::new(),
934        };
935        let output = gen_pyo3_error_converter(&error, "my_crate");
936        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
937        // Ensure no (..) for unit variants
938        assert!(!output.contains("NotFound(..)"));
939    }
940
941    #[test]
942    fn test_struct_variant_pattern() {
943        let error = ErrorDef {
944            name: "MyError".to_string(),
945            rust_path: "my_crate::MyError".to_string(),
946            original_rust_path: String::new(),
947            variants: vec![ErrorVariant {
948                name: "Parsing".to_string(),
949                message_template: Some("parsing error: {message}".to_string()),
950                fields: vec![named_field("message")],
951                has_source: false,
952                has_from: false,
953                is_unit: false,
954                doc: String::new(),
955            }],
956            doc: String::new(),
957        };
958        let output = gen_pyo3_error_converter(&error, "my_crate");
959        assert!(
960            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
961            "Struct variants must use {{ .. }} pattern, got:\n{output}"
962        );
963        // Ensure no (..) for struct variants
964        assert!(!output.contains("Parsing(..)"));
965    }
966
967    // -----------------------------------------------------------------------
968    // NAPI tests
969    // -----------------------------------------------------------------------
970
971    #[test]
972    fn test_gen_napi_error_types() {
973        let error = sample_error();
974        let output = gen_napi_error_types(&error);
975        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
976        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
977        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
978    }
979
980    #[test]
981    fn test_gen_napi_error_converter() {
982        let error = sample_error();
983        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
984        assert!(
985            output
986                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
987        );
988        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
989        assert!(output.contains("[ParseError]"));
990        assert!(output.contains("[IoError]"));
991        assert!(output.contains("#[allow(dead_code)]"));
992    }
993
994    #[test]
995    fn test_napi_unit_variant() {
996        let error = ErrorDef {
997            name: "MyError".to_string(),
998            rust_path: "my_crate::MyError".to_string(),
999            original_rust_path: String::new(),
1000            variants: vec![ErrorVariant {
1001                name: "NotFound".to_string(),
1002                message_template: None,
1003                fields: vec![],
1004                has_source: false,
1005                has_from: false,
1006                is_unit: true,
1007                doc: String::new(),
1008            }],
1009            doc: String::new(),
1010        };
1011        let output = gen_napi_error_converter(&error, "my_crate");
1012        assert!(output.contains("my_crate::MyError::NotFound =>"));
1013        assert!(!output.contains("NotFound(..)"));
1014    }
1015
1016    // -----------------------------------------------------------------------
1017    // WASM tests
1018    // -----------------------------------------------------------------------
1019
1020    #[test]
1021    fn test_gen_wasm_error_converter() {
1022        let error = sample_error();
1023        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1024        // Main converter function signature
1025        assert!(output.contains(
1026            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1027        ));
1028        // Structured object with code + message
1029        assert!(output.contains("js_sys::Object::new()"));
1030        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1031        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1032        assert!(output.contains("obj.into()"));
1033        // error_code helper
1034        assert!(
1035            output
1036                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1037        );
1038        assert!(output.contains("\"parse_error\""));
1039        assert!(output.contains("\"io_error\""));
1040        assert!(output.contains("\"other\""));
1041        assert!(output.contains("#[allow(dead_code)]"));
1042    }
1043
1044    // -----------------------------------------------------------------------
1045    // PHP tests
1046    // -----------------------------------------------------------------------
1047
1048    #[test]
1049    fn test_gen_php_error_converter() {
1050        let error = sample_error();
1051        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1052        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1053        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1054        assert!(output.contains("#[allow(dead_code)]"));
1055    }
1056
1057    // -----------------------------------------------------------------------
1058    // Magnus tests
1059    // -----------------------------------------------------------------------
1060
1061    #[test]
1062    fn test_gen_magnus_error_converter() {
1063        let error = sample_error();
1064        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1065        assert!(
1066            output.contains(
1067                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1068            )
1069        );
1070        assert!(
1071            output.contains(
1072                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1073            )
1074        );
1075        assert!(output.contains("#[allow(dead_code)]"));
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // Rustler tests
1080    // -----------------------------------------------------------------------
1081
1082    #[test]
1083    fn test_gen_rustler_error_converter() {
1084        let error = sample_error();
1085        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1086        assert!(
1087            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1088        );
1089        assert!(output.contains("e.to_string()"));
1090        assert!(output.contains("#[allow(dead_code)]"));
1091    }
1092
1093    // -----------------------------------------------------------------------
1094    // Helper tests
1095    // -----------------------------------------------------------------------
1096
1097    #[test]
1098    fn test_to_screaming_snake() {
1099        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1100        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1101        assert_eq!(to_screaming_snake("Other"), "OTHER");
1102    }
1103
1104    #[test]
1105    fn test_strip_thiserror_placeholders_struct_field() {
1106        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
1107        assert_eq!(
1108            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
1109            "plugin error in"
1110        );
1111        // Multi-placeholder strings retain the surrounding prose verbatim
1112        // (minus the holes). Critical contract: no `{` / `}` survives.
1113        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
1114        assert!(!result.contains('{'), "no braces: {result}");
1115        assert!(!result.contains('}'), "no braces: {result}");
1116        assert!(result.starts_with("extraction timed out after"), "{result}");
1117    }
1118
1119    #[test]
1120    fn test_strip_thiserror_placeholders_positional() {
1121        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
1122        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
1123    }
1124
1125    #[test]
1126    fn test_strip_thiserror_placeholders_no_placeholder() {
1127        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
1128        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
1129    }
1130
1131    #[test]
1132    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
1133        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
1134        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
1135        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
1136        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
1137        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
1138    }
1139
1140    #[test]
1141    fn test_acronym_aware_snake_phrase_plain_words() {
1142        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
1143        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
1144        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
1145    }
1146
1147    #[test]
1148    fn test_variant_display_message_acronym_first_word() {
1149        let variant = ErrorVariant {
1150            name: "Io".to_string(),
1151            message_template: Some("I/O error: {0}".to_string()),
1152            fields: vec![tuple_field(0)],
1153            has_source: false,
1154            has_from: false,
1155            is_unit: false,
1156            doc: String::new(),
1157        };
1158        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
1159        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
1160        let msg = variant_display_message(&variant);
1161        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
1162    }
1163
1164    #[test]
1165    fn test_variant_display_message_no_template_uses_acronyms() {
1166        let variant = ErrorVariant {
1167            name: "IoError".to_string(),
1168            message_template: None,
1169            fields: vec![],
1170            has_source: false,
1171            has_from: false,
1172            is_unit: false,
1173            doc: String::new(),
1174        };
1175        assert_eq!(variant_display_message(&variant), "IO error");
1176    }
1177
1178    #[test]
1179    fn test_variant_display_message_struct_template_no_leak() {
1180        let variant = ErrorVariant {
1181            name: "Ocr".to_string(),
1182            message_template: Some("OCR error: {message}".to_string()),
1183            fields: vec![named_field("message")],
1184            has_source: false,
1185            has_from: false,
1186            is_unit: false,
1187            doc: String::new(),
1188        };
1189        let msg = variant_display_message(&variant);
1190        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
1191    }
1192
1193    #[test]
1194    fn test_go_sentinels_no_placeholder_leak() {
1195        let error = ErrorDef {
1196            name: "KreuzbergError".to_string(),
1197            rust_path: "kreuzberg::KreuzbergError".to_string(),
1198            original_rust_path: String::new(),
1199            variants: vec![
1200                ErrorVariant {
1201                    name: "Io".to_string(),
1202                    message_template: Some("IO error: {message}".to_string()),
1203                    fields: vec![named_field("message")],
1204                    has_source: false,
1205                    has_from: false,
1206                    is_unit: false,
1207                    doc: String::new(),
1208                },
1209                ErrorVariant {
1210                    name: "Ocr".to_string(),
1211                    message_template: Some("OCR error: {message}".to_string()),
1212                    fields: vec![named_field("message")],
1213                    has_source: false,
1214                    has_from: false,
1215                    is_unit: false,
1216                    doc: String::new(),
1217                },
1218                ErrorVariant {
1219                    name: "Timeout".to_string(),
1220                    message_template: Some(
1221                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
1222                    ),
1223                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
1224                    has_source: false,
1225                    has_from: false,
1226                    is_unit: false,
1227                    doc: String::new(),
1228                },
1229            ],
1230            doc: String::new(),
1231        };
1232        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
1233        assert!(
1234            !output.contains('{'),
1235            "Go sentinels must not contain raw placeholders:\n{output}"
1236        );
1237        assert!(
1238            output.contains("ErrIo = errors.New(\"IO error\")"),
1239            "expected acronym-preserving Io sentinel, got:\n{output}"
1240        );
1241        assert!(
1242            output.contains("ErrOcr = errors.New(\"OCR error\")"),
1243            "expected acronym-preserving Ocr sentinel, got:\n{output}"
1244        );
1245        assert!(
1246            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
1247            "expected timeout sentinel to start with the prose, got:\n{output}"
1248        );
1249    }
1250
1251    // -----------------------------------------------------------------------
1252    // FFI (C) tests
1253    // -----------------------------------------------------------------------
1254
1255    #[test]
1256    fn test_gen_ffi_error_codes() {
1257        let error = sample_error();
1258        let output = gen_ffi_error_codes(&error);
1259        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1260        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1261        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1262        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1263        assert!(output.contains("conversion_error_t;"));
1264        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1265    }
1266
1267    // -----------------------------------------------------------------------
1268    // Go tests
1269    // -----------------------------------------------------------------------
1270
1271    #[test]
1272    fn test_gen_go_error_types() {
1273        let error = sample_error();
1274        // Package name that does NOT match the error prefix — type name stays unchanged.
1275        let output = gen_go_error_types(&error, "mylib");
1276        assert!(output.contains("ErrParseError = errors.New("));
1277        assert!(output.contains("ErrIoError = errors.New("));
1278        assert!(output.contains("ErrOther = errors.New("));
1279        assert!(output.contains("type ConversionError struct {"));
1280        assert!(output.contains("Code    string"));
1281        assert!(output.contains("func (e *ConversionError) Error() string"));
1282        // Each sentinel error var should have a doc comment.
1283        assert!(output.contains("// ErrParseError is returned when"));
1284        assert!(output.contains("// ErrIoError is returned when"));
1285        assert!(output.contains("// ErrOther is returned when"));
1286    }
1287
1288    #[test]
1289    fn test_gen_go_error_types_stutter_strip() {
1290        let error = sample_error();
1291        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
1292        // so the exported Go type should be "Error", not "ConversionError".
1293        let output = gen_go_error_types(&error, "conversion");
1294        assert!(
1295            output.contains("type Error struct {"),
1296            "expected stutter strip, got:\n{output}"
1297        );
1298        assert!(
1299            output.contains("func (e *Error) Error() string"),
1300            "expected stutter strip, got:\n{output}"
1301        );
1302        // Sentinel vars are unaffected by stutter stripping.
1303        assert!(output.contains("ErrParseError = errors.New("));
1304    }
1305
1306    // -----------------------------------------------------------------------
1307    // Java tests
1308    // -----------------------------------------------------------------------
1309
1310    #[test]
1311    fn test_gen_java_error_types() {
1312        let error = sample_error();
1313        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1314        // base + 3 variants
1315        assert_eq!(files.len(), 4);
1316        // Base class
1317        assert_eq!(files[0].0, "ConversionErrorException");
1318        assert!(
1319            files[0]
1320                .1
1321                .contains("public class ConversionErrorException extends Exception")
1322        );
1323        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1324        // Variant classes
1325        assert_eq!(files[1].0, "ParseErrorException");
1326        assert!(
1327            files[1]
1328                .1
1329                .contains("public class ParseErrorException extends ConversionErrorException")
1330        );
1331        assert_eq!(files[2].0, "IoErrorException");
1332        assert_eq!(files[3].0, "OtherException");
1333    }
1334
1335    // -----------------------------------------------------------------------
1336    // C# tests
1337    // -----------------------------------------------------------------------
1338
1339    #[test]
1340    fn test_gen_csharp_error_types() {
1341        let error = sample_error();
1342        // Without fallback class: base inherits from Exception.
1343        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
1344        assert_eq!(files.len(), 4);
1345        assert_eq!(files[0].0, "ConversionErrorException");
1346        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1347        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1348        assert_eq!(files[1].0, "ParseErrorException");
1349        assert!(
1350            files[1]
1351                .1
1352                .contains("public class ParseErrorException : ConversionErrorException")
1353        );
1354        assert_eq!(files[2].0, "IoErrorException");
1355        assert_eq!(files[3].0, "OtherException");
1356    }
1357
1358    #[test]
1359    fn test_gen_csharp_error_types_with_fallback() {
1360        let error = sample_error();
1361        // With fallback class: base inherits from the generic library exception.
1362        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
1363        assert_eq!(files.len(), 4);
1364        assert!(
1365            files[0]
1366                .1
1367                .contains("public class ConversionErrorException : TestLibException")
1368        );
1369        // Variant classes still inherit from the base error class, not from the fallback directly.
1370        assert!(
1371            files[1]
1372                .1
1373                .contains("public class ParseErrorException : ConversionErrorException")
1374        );
1375    }
1376
1377    // -----------------------------------------------------------------------
1378    // python_exception_name tests
1379    // -----------------------------------------------------------------------
1380
1381    #[test]
1382    fn test_python_exception_name_no_conflict() {
1383        // "ParseError" already ends with "Error" and is not a builtin
1384        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1385        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1386        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1387    }
1388
1389    #[test]
1390    fn test_python_exception_name_shadows_builtin() {
1391        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1392        assert_eq!(
1393            python_exception_name("Connection", "CrawlError"),
1394            "CrawlConnectionError"
1395        );
1396        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1397        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1398        // "ConnectionError" already ends with "Error", still shadows -> prefix
1399        assert_eq!(
1400            python_exception_name("ConnectionError", "CrawlError"),
1401            "CrawlConnectionError"
1402        );
1403    }
1404
1405    #[test]
1406    fn test_python_exception_name_no_double_prefix() {
1407        // If variant is already prefixed with the error base, don't double-prefix
1408        assert_eq!(
1409            python_exception_name("CrawlConnectionError", "CrawlError"),
1410            "CrawlConnectionError"
1411        );
1412    }
1413}