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