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