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            serde_flatten: false,
828        }
829    }
830
831    /// Helper to create a named struct field.
832    fn named_field(name: &str) -> FieldDef {
833        FieldDef {
834            name: name.to_string(),
835            ty: TypeRef::String,
836            optional: false,
837            default: None,
838            doc: String::new(),
839            sanitized: false,
840            is_boxed: false,
841            type_rust_path: None,
842            cfg: None,
843            typed_default: None,
844            core_wrapper: CoreWrapper::None,
845            vec_inner_core_wrapper: CoreWrapper::None,
846            newtype_wrapper: None,
847            serde_rename: None,
848            serde_flatten: false,
849        }
850    }
851
852    fn sample_error() -> ErrorDef {
853        ErrorDef {
854            name: "ConversionError".to_string(),
855            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
856            original_rust_path: String::new(),
857            variants: vec![
858                ErrorVariant {
859                    name: "ParseError".to_string(),
860                    message_template: Some("HTML parsing error: {0}".to_string()),
861                    fields: vec![tuple_field(0)],
862                    has_source: false,
863                    has_from: false,
864                    is_unit: false,
865                    doc: String::new(),
866                },
867                ErrorVariant {
868                    name: "IoError".to_string(),
869                    message_template: Some("I/O error: {0}".to_string()),
870                    fields: vec![tuple_field(0)],
871                    has_source: false,
872                    has_from: true,
873                    is_unit: false,
874                    doc: String::new(),
875                },
876                ErrorVariant {
877                    name: "Other".to_string(),
878                    message_template: Some("Conversion error: {0}".to_string()),
879                    fields: vec![tuple_field(0)],
880                    has_source: false,
881                    has_from: false,
882                    is_unit: false,
883                    doc: String::new(),
884                },
885            ],
886            doc: "Error type for conversion operations.".to_string(),
887        }
888    }
889
890    #[test]
891    fn test_gen_error_types() {
892        let error = sample_error();
893        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
894        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
895        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
896        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
897        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
898    }
899
900    #[test]
901    fn test_gen_error_converter() {
902        let error = sample_error();
903        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
904        assert!(
905            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
906        );
907        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
908        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
909    }
910
911    #[test]
912    fn test_gen_error_registration() {
913        let error = sample_error();
914        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
915        assert_eq!(regs.len(), 4); // 3 variants + 1 base
916        assert!(regs[0].contains("\"ParseError\""));
917        assert!(regs[3].contains("\"ConversionError\""));
918    }
919
920    #[test]
921    fn test_unit_variant_pattern() {
922        let error = ErrorDef {
923            name: "MyError".to_string(),
924            rust_path: "my_crate::MyError".to_string(),
925            original_rust_path: String::new(),
926            variants: vec![ErrorVariant {
927                name: "NotFound".to_string(),
928                message_template: Some("not found".to_string()),
929                fields: vec![],
930                has_source: false,
931                has_from: false,
932                is_unit: true,
933                doc: String::new(),
934            }],
935            doc: String::new(),
936        };
937        let output = gen_pyo3_error_converter(&error, "my_crate");
938        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
939        // Ensure no (..) for unit variants
940        assert!(!output.contains("NotFound(..)"));
941    }
942
943    #[test]
944    fn test_struct_variant_pattern() {
945        let error = ErrorDef {
946            name: "MyError".to_string(),
947            rust_path: "my_crate::MyError".to_string(),
948            original_rust_path: String::new(),
949            variants: vec![ErrorVariant {
950                name: "Parsing".to_string(),
951                message_template: Some("parsing error: {message}".to_string()),
952                fields: vec![named_field("message")],
953                has_source: false,
954                has_from: false,
955                is_unit: false,
956                doc: String::new(),
957            }],
958            doc: String::new(),
959        };
960        let output = gen_pyo3_error_converter(&error, "my_crate");
961        assert!(
962            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
963            "Struct variants must use {{ .. }} pattern, got:\n{output}"
964        );
965        // Ensure no (..) for struct variants
966        assert!(!output.contains("Parsing(..)"));
967    }
968
969    // -----------------------------------------------------------------------
970    // NAPI tests
971    // -----------------------------------------------------------------------
972
973    #[test]
974    fn test_gen_napi_error_types() {
975        let error = sample_error();
976        let output = gen_napi_error_types(&error);
977        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
978        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
979        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
980    }
981
982    #[test]
983    fn test_gen_napi_error_converter() {
984        let error = sample_error();
985        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
986        assert!(
987            output
988                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
989        );
990        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
991        assert!(output.contains("[ParseError]"));
992        assert!(output.contains("[IoError]"));
993        assert!(output.contains("#[allow(dead_code)]"));
994    }
995
996    #[test]
997    fn test_napi_unit_variant() {
998        let error = ErrorDef {
999            name: "MyError".to_string(),
1000            rust_path: "my_crate::MyError".to_string(),
1001            original_rust_path: String::new(),
1002            variants: vec![ErrorVariant {
1003                name: "NotFound".to_string(),
1004                message_template: None,
1005                fields: vec![],
1006                has_source: false,
1007                has_from: false,
1008                is_unit: true,
1009                doc: String::new(),
1010            }],
1011            doc: String::new(),
1012        };
1013        let output = gen_napi_error_converter(&error, "my_crate");
1014        assert!(output.contains("my_crate::MyError::NotFound =>"));
1015        assert!(!output.contains("NotFound(..)"));
1016    }
1017
1018    // -----------------------------------------------------------------------
1019    // WASM tests
1020    // -----------------------------------------------------------------------
1021
1022    #[test]
1023    fn test_gen_wasm_error_converter() {
1024        let error = sample_error();
1025        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1026        // Main converter function signature
1027        assert!(output.contains(
1028            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1029        ));
1030        // Structured object with code + message
1031        assert!(output.contains("js_sys::Object::new()"));
1032        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1033        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1034        assert!(output.contains("obj.into()"));
1035        // error_code helper
1036        assert!(
1037            output
1038                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1039        );
1040        assert!(output.contains("\"parse_error\""));
1041        assert!(output.contains("\"io_error\""));
1042        assert!(output.contains("\"other\""));
1043        assert!(output.contains("#[allow(dead_code)]"));
1044    }
1045
1046    // -----------------------------------------------------------------------
1047    // PHP tests
1048    // -----------------------------------------------------------------------
1049
1050    #[test]
1051    fn test_gen_php_error_converter() {
1052        let error = sample_error();
1053        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1054        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1055        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1056        assert!(output.contains("#[allow(dead_code)]"));
1057    }
1058
1059    // -----------------------------------------------------------------------
1060    // Magnus tests
1061    // -----------------------------------------------------------------------
1062
1063    #[test]
1064    fn test_gen_magnus_error_converter() {
1065        let error = sample_error();
1066        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1067        assert!(
1068            output.contains(
1069                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1070            )
1071        );
1072        assert!(
1073            output.contains(
1074                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1075            )
1076        );
1077        assert!(output.contains("#[allow(dead_code)]"));
1078    }
1079
1080    // -----------------------------------------------------------------------
1081    // Rustler tests
1082    // -----------------------------------------------------------------------
1083
1084    #[test]
1085    fn test_gen_rustler_error_converter() {
1086        let error = sample_error();
1087        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1088        assert!(
1089            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1090        );
1091        assert!(output.contains("e.to_string()"));
1092        assert!(output.contains("#[allow(dead_code)]"));
1093    }
1094
1095    // -----------------------------------------------------------------------
1096    // Helper tests
1097    // -----------------------------------------------------------------------
1098
1099    #[test]
1100    fn test_to_screaming_snake() {
1101        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1102        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1103        assert_eq!(to_screaming_snake("Other"), "OTHER");
1104    }
1105
1106    #[test]
1107    fn test_strip_thiserror_placeholders_struct_field() {
1108        assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
1109        assert_eq!(
1110            strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
1111            "plugin error in"
1112        );
1113        // Multi-placeholder strings retain the surrounding prose verbatim
1114        // (minus the holes). Critical contract: no `{` / `}` survives.
1115        let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
1116        assert!(!result.contains('{'), "no braces: {result}");
1117        assert!(!result.contains('}'), "no braces: {result}");
1118        assert!(result.starts_with("extraction timed out after"), "{result}");
1119    }
1120
1121    #[test]
1122    fn test_strip_thiserror_placeholders_positional() {
1123        assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
1124        assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
1125    }
1126
1127    #[test]
1128    fn test_strip_thiserror_placeholders_no_placeholder() {
1129        assert_eq!(strip_thiserror_placeholders("not found"), "not found");
1130        assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
1131    }
1132
1133    #[test]
1134    fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
1135        assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
1136        assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
1137        assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
1138        assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
1139        assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
1140    }
1141
1142    #[test]
1143    fn test_acronym_aware_snake_phrase_plain_words() {
1144        assert_eq!(acronym_aware_snake_phrase("Other"), "other");
1145        assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
1146        assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
1147    }
1148
1149    #[test]
1150    fn test_variant_display_message_acronym_first_word() {
1151        let variant = ErrorVariant {
1152            name: "Io".to_string(),
1153            message_template: Some("I/O error: {0}".to_string()),
1154            fields: vec![tuple_field(0)],
1155            has_source: false,
1156            has_from: false,
1157            is_unit: false,
1158            doc: String::new(),
1159        };
1160        // Template "I/O error: {0}" → strip → "I/O error" → first token "I/O" not an acronym (with `/`),
1161        // so falls back to lowercase first char → "i/O error". Acceptable: at least no `{0}` leak.
1162        let msg = variant_display_message(&variant);
1163        assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
1164    }
1165
1166    #[test]
1167    fn test_variant_display_message_no_template_uses_acronyms() {
1168        let variant = ErrorVariant {
1169            name: "IoError".to_string(),
1170            message_template: None,
1171            fields: vec![],
1172            has_source: false,
1173            has_from: false,
1174            is_unit: false,
1175            doc: String::new(),
1176        };
1177        assert_eq!(variant_display_message(&variant), "IO error");
1178    }
1179
1180    #[test]
1181    fn test_variant_display_message_struct_template_no_leak() {
1182        let variant = ErrorVariant {
1183            name: "Ocr".to_string(),
1184            message_template: Some("OCR error: {message}".to_string()),
1185            fields: vec![named_field("message")],
1186            has_source: false,
1187            has_from: false,
1188            is_unit: false,
1189            doc: String::new(),
1190        };
1191        let msg = variant_display_message(&variant);
1192        assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
1193    }
1194
1195    #[test]
1196    fn test_go_sentinels_no_placeholder_leak() {
1197        let error = ErrorDef {
1198            name: "KreuzbergError".to_string(),
1199            rust_path: "kreuzberg::KreuzbergError".to_string(),
1200            original_rust_path: String::new(),
1201            variants: vec![
1202                ErrorVariant {
1203                    name: "Io".to_string(),
1204                    message_template: Some("IO error: {message}".to_string()),
1205                    fields: vec![named_field("message")],
1206                    has_source: false,
1207                    has_from: false,
1208                    is_unit: false,
1209                    doc: String::new(),
1210                },
1211                ErrorVariant {
1212                    name: "Ocr".to_string(),
1213                    message_template: Some("OCR error: {message}".to_string()),
1214                    fields: vec![named_field("message")],
1215                    has_source: false,
1216                    has_from: false,
1217                    is_unit: false,
1218                    doc: String::new(),
1219                },
1220                ErrorVariant {
1221                    name: "Timeout".to_string(),
1222                    message_template: Some(
1223                        "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
1224                    ),
1225                    fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
1226                    has_source: false,
1227                    has_from: false,
1228                    is_unit: false,
1229                    doc: String::new(),
1230                },
1231            ],
1232            doc: String::new(),
1233        };
1234        let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
1235        assert!(
1236            !output.contains('{'),
1237            "Go sentinels must not contain raw placeholders:\n{output}"
1238        );
1239        assert!(
1240            output.contains("ErrIo = errors.New(\"IO error\")"),
1241            "expected acronym-preserving Io sentinel, got:\n{output}"
1242        );
1243        assert!(
1244            output.contains("ErrOcr = errors.New(\"OCR error\")"),
1245            "expected acronym-preserving Ocr sentinel, got:\n{output}"
1246        );
1247        assert!(
1248            output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
1249            "expected timeout sentinel to start with the prose, got:\n{output}"
1250        );
1251    }
1252
1253    // -----------------------------------------------------------------------
1254    // FFI (C) tests
1255    // -----------------------------------------------------------------------
1256
1257    #[test]
1258    fn test_gen_ffi_error_codes() {
1259        let error = sample_error();
1260        let output = gen_ffi_error_codes(&error);
1261        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1262        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1263        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1264        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1265        assert!(output.contains("conversion_error_t;"));
1266        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1267    }
1268
1269    // -----------------------------------------------------------------------
1270    // Go tests
1271    // -----------------------------------------------------------------------
1272
1273    #[test]
1274    fn test_gen_go_error_types() {
1275        let error = sample_error();
1276        // Package name that does NOT match the error prefix — type name stays unchanged.
1277        let output = gen_go_error_types(&error, "mylib");
1278        assert!(output.contains("ErrParseError = errors.New("));
1279        assert!(output.contains("ErrIoError = errors.New("));
1280        assert!(output.contains("ErrOther = errors.New("));
1281        assert!(output.contains("type ConversionError struct {"));
1282        assert!(output.contains("Code    string"));
1283        assert!(output.contains("func (e *ConversionError) Error() string"));
1284        // Each sentinel error var should have a doc comment.
1285        assert!(output.contains("// ErrParseError is returned when"));
1286        assert!(output.contains("// ErrIoError is returned when"));
1287        assert!(output.contains("// ErrOther is returned when"));
1288    }
1289
1290    #[test]
1291    fn test_gen_go_error_types_stutter_strip() {
1292        let error = sample_error();
1293        // "conversion" package — "ConversionError" starts with "conversion" (case-insensitive)
1294        // so the exported Go type should be "Error", not "ConversionError".
1295        let output = gen_go_error_types(&error, "conversion");
1296        assert!(
1297            output.contains("type Error struct {"),
1298            "expected stutter strip, got:\n{output}"
1299        );
1300        assert!(
1301            output.contains("func (e *Error) Error() string"),
1302            "expected stutter strip, got:\n{output}"
1303        );
1304        // Sentinel vars are unaffected by stutter stripping.
1305        assert!(output.contains("ErrParseError = errors.New("));
1306    }
1307
1308    // -----------------------------------------------------------------------
1309    // Java tests
1310    // -----------------------------------------------------------------------
1311
1312    #[test]
1313    fn test_gen_java_error_types() {
1314        let error = sample_error();
1315        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1316        // base + 3 variants
1317        assert_eq!(files.len(), 4);
1318        // Base class
1319        assert_eq!(files[0].0, "ConversionErrorException");
1320        assert!(
1321            files[0]
1322                .1
1323                .contains("public class ConversionErrorException extends Exception")
1324        );
1325        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1326        // Variant classes
1327        assert_eq!(files[1].0, "ParseErrorException");
1328        assert!(
1329            files[1]
1330                .1
1331                .contains("public class ParseErrorException extends ConversionErrorException")
1332        );
1333        assert_eq!(files[2].0, "IoErrorException");
1334        assert_eq!(files[3].0, "OtherException");
1335    }
1336
1337    // -----------------------------------------------------------------------
1338    // C# tests
1339    // -----------------------------------------------------------------------
1340
1341    #[test]
1342    fn test_gen_csharp_error_types() {
1343        let error = sample_error();
1344        // Without fallback class: base inherits from Exception.
1345        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
1346        assert_eq!(files.len(), 4);
1347        assert_eq!(files[0].0, "ConversionErrorException");
1348        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1349        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1350        assert_eq!(files[1].0, "ParseErrorException");
1351        assert!(
1352            files[1]
1353                .1
1354                .contains("public class ParseErrorException : ConversionErrorException")
1355        );
1356        assert_eq!(files[2].0, "IoErrorException");
1357        assert_eq!(files[3].0, "OtherException");
1358    }
1359
1360    #[test]
1361    fn test_gen_csharp_error_types_with_fallback() {
1362        let error = sample_error();
1363        // With fallback class: base inherits from the generic library exception.
1364        let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
1365        assert_eq!(files.len(), 4);
1366        assert!(
1367            files[0]
1368                .1
1369                .contains("public class ConversionErrorException : TestLibException")
1370        );
1371        // Variant classes still inherit from the base error class, not from the fallback directly.
1372        assert!(
1373            files[1]
1374                .1
1375                .contains("public class ParseErrorException : ConversionErrorException")
1376        );
1377    }
1378
1379    // -----------------------------------------------------------------------
1380    // python_exception_name tests
1381    // -----------------------------------------------------------------------
1382
1383    #[test]
1384    fn test_python_exception_name_no_conflict() {
1385        // "ParseError" already ends with "Error" and is not a builtin
1386        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1387        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1388        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1389    }
1390
1391    #[test]
1392    fn test_python_exception_name_shadows_builtin() {
1393        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1394        assert_eq!(
1395            python_exception_name("Connection", "CrawlError"),
1396            "CrawlConnectionError"
1397        );
1398        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1399        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1400        // "ConnectionError" already ends with "Error", still shadows -> prefix
1401        assert_eq!(
1402            python_exception_name("ConnectionError", "CrawlError"),
1403            "CrawlConnectionError"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_python_exception_name_no_double_prefix() {
1409        // If variant is already prefixed with the error base, don't double-prefix
1410        assert_eq!(
1411            python_exception_name("CrawlConnectionError", "CrawlError"),
1412            "CrawlConnectionError"
1413        );
1414    }
1415}