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