Skip to main content

alef_codegen/
error_gen.rs

1use alef_core::ir::{ErrorDef, ErrorVariant};
2
3use crate::conversions::is_tuple_variant;
4
5/// Generate a wildcard match pattern for an error variant.
6/// Struct variants use `{ .. }`, tuple variants use `(..)`, unit variants have no suffix.
7fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
8    if variant.is_unit {
9        format!("{rust_path}::{}", variant.name)
10    } else if is_tuple_variant(&variant.fields) {
11        format!("{rust_path}::{}(..)", variant.name)
12    } else {
13        format!("{rust_path}::{} {{ .. }}", variant.name)
14    }
15}
16
17/// Python builtin exception names that must not be shadowed (A004 compliance).
18const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
19    "ConnectionError",
20    "TimeoutError",
21    "PermissionError",
22    "FileNotFoundError",
23    "ValueError",
24    "TypeError",
25    "RuntimeError",
26    "OSError",
27    "IOError",
28    "KeyError",
29    "IndexError",
30    "AttributeError",
31    "ImportError",
32    "MemoryError",
33    "OverflowError",
34    "StopIteration",
35    "RecursionError",
36    "SystemError",
37    "ReferenceError",
38    "BufferError",
39    "EOFError",
40    "LookupError",
41    "ArithmeticError",
42    "AssertionError",
43    "BlockingIOError",
44    "BrokenPipeError",
45    "ChildProcessError",
46    "FileExistsError",
47    "InterruptedError",
48    "IsADirectoryError",
49    "NotADirectoryError",
50    "ProcessLookupError",
51    "UnicodeError",
52];
53
54/// Compute a prefix from the error type name by stripping a trailing "Error" suffix.
55/// E.g. `"CrawlError"` -> `"Crawl"`, `"MyException"` -> `"MyException"`.
56fn error_base_prefix(error_name: &str) -> &str {
57    error_name.strip_suffix("Error").unwrap_or(error_name)
58}
59
60/// Return the Python exception name for a variant, avoiding shadowing of Python builtins.
61///
62/// 1. Appends `"Error"` suffix if not already present (N818 compliance).
63/// 2. If the resulting name shadows a Python builtin, prefixes it with the error type's base
64///    name. E.g. for `CrawlError::Connection` -> `ConnectionError` (shadowed) -> `CrawlConnectionError`.
65pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
66    let candidate = if variant_name.ends_with("Error") {
67        variant_name.to_string()
68    } else {
69        format!("{}Error", variant_name)
70    };
71
72    if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
73        let prefix = error_base_prefix(error_name);
74        // Avoid double-prefixing if the candidate already starts with the prefix
75        if candidate.starts_with(prefix) {
76            candidate
77        } else {
78            format!("{}{}", prefix, candidate)
79        }
80    } else {
81        candidate
82    }
83}
84
85/// Generate `pyo3::create_exception!` macros for each error variant plus the base error type.
86/// Appends "Error" suffix to variant names that don't already have it (N818 compliance).
87/// Prefixes names that would shadow Python builtins (A004 compliance).
88pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str) -> String {
89    let mut lines = Vec::with_capacity(error.variants.len() + 2);
90    lines.push("// Error types".to_string());
91
92    // One exception per variant (with Error suffix if needed, prefixed if shadowing builtins)
93    for variant in &error.variants {
94        let variant_name = python_exception_name(&variant.name, &error.name);
95        lines.push(format!(
96            "pyo3::create_exception!({module_name}, {}, pyo3::exceptions::PyException);",
97            variant_name
98        ));
99    }
100
101    // Base exception for the enum itself
102    lines.push(format!(
103        "pyo3::create_exception!({module_name}, {}, pyo3::exceptions::PyException);",
104        error.name
105    ));
106
107    lines.join("\n")
108}
109
110/// Generate a `to_py_err` converter function that maps each Rust error variant to a Python exception.
111/// Uses Error-suffixed names for variant exceptions (N818 compliance).
112pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
113    let rust_path = if error.rust_path.is_empty() {
114        format!("{core_import}::{}", error.name)
115    } else {
116        error.rust_path.replace('-', "_")
117    };
118
119    let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
120
121    let mut lines = Vec::new();
122    lines.push(format!("/// Convert a `{rust_path}` error to a Python exception."));
123    lines.push(format!("fn {fn_name}(e: {rust_path}) -> pyo3::PyErr {{"));
124    lines.push("    let msg = e.to_string();".to_string());
125    lines.push("    #[allow(unreachable_patterns)]".to_string());
126    lines.push("    match &e {".to_string());
127
128    for variant in &error.variants {
129        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
130        let variant_exc_name = python_exception_name(&variant.name, &error.name);
131        lines.push(format!("        {pattern} => {}::new_err(msg),", variant_exc_name));
132    }
133
134    // Catch-all for cfg-gated variants not in the IR
135    lines.push(format!("        _ => {}::new_err(msg),", error.name));
136    lines.push("    }".to_string());
137    lines.push("}".to_string());
138    lines.join("\n")
139}
140
141/// Generate `m.add(...)` registration calls for each exception type.
142/// Uses Error-suffixed names for variant exceptions (N818 compliance).
143/// Prefixes names that would shadow Python builtins (A004 compliance).
144pub fn gen_pyo3_error_registration(error: &ErrorDef) -> Vec<String> {
145    let mut registrations = Vec::with_capacity(error.variants.len() + 1);
146
147    for variant in &error.variants {
148        let variant_exc_name = python_exception_name(&variant.name, &error.name);
149        registrations.push(format!(
150            "    m.add(\"{}\", m.py().get_type::<{}>())?;",
151            variant_exc_name, variant_exc_name
152        ));
153    }
154
155    // Base exception
156    registrations.push(format!(
157        "    m.add(\"{}\", m.py().get_type::<{}>())?;",
158        error.name, error.name
159    ));
160
161    registrations
162}
163
164/// Return the converter function name for a given error type.
165pub fn converter_fn_name(error: &ErrorDef) -> String {
166    format!("{}_to_py_err", to_snake_case(&error.name))
167}
168
169/// Simple CamelCase to snake_case conversion.
170fn to_snake_case(s: &str) -> String {
171    let mut result = String::with_capacity(s.len() + 4);
172    for (i, c) in s.chars().enumerate() {
173        if c.is_uppercase() {
174            if i > 0 {
175                result.push('_');
176            }
177            result.push(c.to_ascii_lowercase());
178        } else {
179            result.push(c);
180        }
181    }
182    result
183}
184
185// ---------------------------------------------------------------------------
186// NAPI (Node.js) error generation
187// ---------------------------------------------------------------------------
188
189/// Generate a `JsError` enum with string constants for each error variant name.
190pub fn gen_napi_error_types(error: &ErrorDef) -> String {
191    let mut lines = Vec::with_capacity(error.variants.len() + 4);
192    lines.push("// Error variant name constants".to_string());
193    for variant in &error.variants {
194        lines.push(format!(
195            "pub const {}_ERROR_{}: &str = \"{}\";",
196            to_screaming_snake(&error.name),
197            to_screaming_snake(&variant.name),
198            variant.name,
199        ));
200    }
201    lines.join("\n")
202}
203
204/// Generate a converter function that maps a core error to `napi::Error`.
205pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
206    let rust_path = if error.rust_path.is_empty() {
207        format!("{core_import}::{}", error.name)
208    } else {
209        error.rust_path.replace('-', "_")
210    };
211
212    let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
213
214    let mut lines = Vec::new();
215    lines.push(format!("/// Convert a `{rust_path}` error to a NAPI error."));
216    lines.push("#[allow(dead_code)]".to_string());
217    lines.push(format!("fn {fn_name}(e: {rust_path}) -> napi::Error {{"));
218    lines.push("    let msg = e.to_string();".to_string());
219    lines.push("    #[allow(unreachable_patterns)]".to_string());
220    lines.push("    match &e {".to_string());
221
222    for variant in &error.variants {
223        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
224        lines.push(format!(
225            "        {pattern} => napi::Error::new(napi::Status::GenericFailure, format!(\"[{}] {{}}\", msg)),",
226            variant.name,
227        ));
228    }
229
230    // Catch-all for cfg-gated variants not in the IR
231    lines.push("        _ => napi::Error::new(napi::Status::GenericFailure, msg),".to_string());
232    lines.push("    }".to_string());
233    lines.push("}".to_string());
234    lines.join("\n")
235}
236
237/// Return the NAPI converter function name for a given error type.
238pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
239    format!("{}_to_napi_err", to_snake_case(&error.name))
240}
241
242// ---------------------------------------------------------------------------
243// WASM (wasm-bindgen) error generation
244// ---------------------------------------------------------------------------
245
246/// Generate a converter function that maps a core error to `JsValue`.
247pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
248    let rust_path = if error.rust_path.is_empty() {
249        format!("{core_import}::{}", error.name)
250    } else {
251        error.rust_path.replace('-', "_")
252    };
253
254    let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
255
256    let mut lines = Vec::new();
257    lines.push(format!("/// Convert a `{rust_path}` error to a `JsValue` string."));
258    lines.push("#[allow(dead_code)]".to_string());
259    lines.push(format!("fn {fn_name}(e: {rust_path}) -> wasm_bindgen::JsValue {{"));
260    lines.push("    wasm_bindgen::JsValue::from_str(&e.to_string())".to_string());
261    lines.push("}".to_string());
262    lines.join("\n")
263}
264
265/// Return the WASM converter function name for a given error type.
266pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
267    format!("{}_to_js_value", to_snake_case(&error.name))
268}
269
270// ---------------------------------------------------------------------------
271// PHP (ext-php-rs) error generation
272// ---------------------------------------------------------------------------
273
274/// Generate a converter function that maps a core error to `PhpException`.
275pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
276    let rust_path = if error.rust_path.is_empty() {
277        format!("{core_import}::{}", error.name)
278    } else {
279        error.rust_path.replace('-', "_")
280    };
281
282    let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
283
284    let mut lines = Vec::new();
285    lines.push(format!("/// Convert a `{rust_path}` error to a PHP exception."));
286    lines.push("#[allow(dead_code)]".to_string());
287    lines.push(format!(
288        "fn {fn_name}(e: {rust_path}) -> ext_php_rs::exception::PhpException {{"
289    ));
290    lines.push("    let msg = e.to_string();".to_string());
291    lines.push("    #[allow(unreachable_patterns)]".to_string());
292    lines.push("    match &e {".to_string());
293
294    for variant in &error.variants {
295        let pattern = error_variant_wildcard_pattern(&rust_path, variant);
296        lines.push(format!(
297            "        {pattern} => ext_php_rs::exception::PhpException::default(format!(\"[{}] {{}}\", msg)),",
298            variant.name,
299        ));
300    }
301
302    // Catch-all for cfg-gated variants not in the IR
303    lines.push("        _ => ext_php_rs::exception::PhpException::default(msg),".to_string());
304    lines.push("    }".to_string());
305    lines.push("}".to_string());
306    lines.join("\n")
307}
308
309/// Return the PHP converter function name for a given error type.
310pub fn php_converter_fn_name(error: &ErrorDef) -> String {
311    format!("{}_to_php_err", to_snake_case(&error.name))
312}
313
314// ---------------------------------------------------------------------------
315// Magnus (Ruby) error generation
316// ---------------------------------------------------------------------------
317
318/// Generate a converter function that maps a core error to `magnus::Error`.
319pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
320    let rust_path = if error.rust_path.is_empty() {
321        format!("{core_import}::{}", error.name)
322    } else {
323        error.rust_path.replace('-', "_")
324    };
325
326    let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
327
328    let mut lines = Vec::new();
329    lines.push(format!("/// Convert a `{rust_path}` error to a Magnus runtime error."));
330    lines.push("#[allow(dead_code)]".to_string());
331    lines.push(format!("fn {fn_name}(e: {rust_path}) -> magnus::Error {{"));
332    lines.push("    let msg = e.to_string();".to_string());
333    lines.push(
334        "    magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)".to_string(),
335    );
336    lines.push("}".to_string());
337    lines.join("\n")
338}
339
340/// Return the Magnus converter function name for a given error type.
341pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
342    format!("{}_to_magnus_err", to_snake_case(&error.name))
343}
344
345// ---------------------------------------------------------------------------
346// Rustler (Elixir) error generation
347// ---------------------------------------------------------------------------
348
349/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
350pub fn gen_rustler_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_rustler_err", to_snake_case(&error.name));
358
359    let mut lines = Vec::new();
360    lines.push(format!("/// Convert a `{rust_path}` error to a Rustler error string."));
361    lines.push("#[allow(dead_code)]".to_string());
362    lines.push(format!("fn {fn_name}(e: {rust_path}) -> String {{"));
363    lines.push("    e.to_string()".to_string());
364    lines.push("}".to_string());
365    lines.join("\n")
366}
367
368/// Return the Rustler converter function name for a given error type.
369pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
370    format!("{}_to_rustler_err", to_snake_case(&error.name))
371}
372
373// ---------------------------------------------------------------------------
374// FFI (C) error code generation
375// ---------------------------------------------------------------------------
376
377/// Generate a C enum of error codes plus an error-message function declaration.
378///
379/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
380/// per variant, plus a function that returns the default message for a given code.
381pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
382    let prefix = to_screaming_snake(&error.name);
383    let prefix_lower = to_snake_case(&error.name);
384
385    let mut lines = Vec::new();
386    lines.push(format!("/// Error codes for `{}`.", error.name));
387    lines.push("typedef enum {".to_string());
388    lines.push(format!("    {}_NONE = 0,", prefix));
389
390    for (i, variant) in error.variants.iter().enumerate() {
391        let variant_screaming = to_screaming_snake(&variant.name);
392        lines.push(format!("    {}_{} = {},", prefix, variant_screaming, i + 1));
393    }
394
395    lines.push(format!("}} {}_t;\n", prefix_lower));
396
397    // Error message function
398    lines.push(format!(
399        "/// Return a static string describing the error code.\nconst char* {}_error_message({}_t code);",
400        prefix_lower, prefix_lower
401    ));
402
403    lines.join("\n")
404}
405
406// ---------------------------------------------------------------------------
407// Go error type generation
408// ---------------------------------------------------------------------------
409
410/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
411pub fn gen_go_error_types(error: &ErrorDef) -> String {
412    let mut lines = Vec::new();
413
414    // Sentinel errors
415    lines.push("var (".to_string());
416    for variant in &error.variants {
417        let err_name = format!("Err{}", variant.name);
418        let msg = variant_display_message(variant);
419        lines.push(format!("    {} = errors.New(\"{}\")", err_name, msg));
420    }
421    lines.push(")\n".to_string());
422
423    // Structured error type
424    lines.push(format!("// {} is a structured error type.", error.name));
425    lines.push(format!("type {} struct {{", error.name));
426    lines.push("    Code    string".to_string());
427    lines.push("    Message string".to_string());
428    lines.push("}\n".to_string());
429
430    lines.push(format!(
431        "func (e *{}) Error() string {{ return e.Message }}",
432        error.name
433    ));
434
435    lines.join("\n")
436}
437
438// ---------------------------------------------------------------------------
439// Java error type generation
440// ---------------------------------------------------------------------------
441
442/// Generate Java exception sub-classes for each error variant.
443///
444/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
445/// class followed by one per-variant exception.  The caller writes each to a
446/// separate `.java` file.
447pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
448    let mut files = Vec::with_capacity(error.variants.len() + 1);
449
450    // Base exception class
451    let base_name = format!("{}Exception", error.name);
452    let mut base = String::with_capacity(512);
453    base.push_str(&format!(
454        "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
455        package
456    ));
457    if !error.doc.is_empty() {
458        base.push_str(&format!("/** {} */\n", error.doc));
459    }
460    base.push_str(&format!("public class {} extends Exception {{\n", base_name));
461    base.push_str(&format!(
462        "    public {}(String message) {{\n        super(message);\n    }}\n\n",
463        base_name
464    ));
465    base.push_str(&format!(
466        "    public {}(String message, Throwable cause) {{\n        super(message, cause);\n    }}\n",
467        base_name
468    ));
469    base.push_str("}\n");
470    files.push((base_name.clone(), base));
471
472    // Per-variant exception classes
473    for variant in &error.variants {
474        let class_name = format!("{}Exception", variant.name);
475        let mut content = String::with_capacity(512);
476        content.push_str(&format!(
477            "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
478            package
479        ));
480        if !variant.doc.is_empty() {
481            content.push_str(&format!("/** {} */\n", variant.doc));
482        }
483        content.push_str(&format!("public class {} extends {} {{\n", class_name, base_name));
484        content.push_str(&format!(
485            "    public {}(String message) {{\n        super(message);\n    }}\n\n",
486            class_name
487        ));
488        content.push_str(&format!(
489            "    public {}(String message, Throwable cause) {{\n        super(message, cause);\n    }}\n",
490            class_name
491        ));
492        content.push_str("}\n");
493        files.push((class_name, content));
494    }
495
496    files
497}
498
499// ---------------------------------------------------------------------------
500// C# error type generation
501// ---------------------------------------------------------------------------
502
503/// Generate C# exception sub-classes for each error variant.
504///
505/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
506/// class followed by one per-variant exception.  The caller writes each to a
507/// separate `.cs` file.
508pub fn gen_csharp_error_types(error: &ErrorDef, namespace: &str) -> Vec<(String, String)> {
509    let mut files = Vec::with_capacity(error.variants.len() + 1);
510
511    let base_name = format!("{}Exception", error.name);
512
513    // Base exception class
514    {
515        let mut out = String::with_capacity(512);
516        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
517        out.push_str(&format!("namespace {};\n\n", namespace));
518        if !error.doc.is_empty() {
519            out.push_str("/// <summary>\n");
520            for line in error.doc.lines() {
521                out.push_str(&format!("/// {}\n", line));
522            }
523            out.push_str("/// </summary>\n");
524        }
525        out.push_str(&format!("public class {} : Exception\n{{\n", base_name));
526        out.push_str(&format!(
527            "    public {}(string message) : base(message) {{ }}\n\n",
528            base_name
529        ));
530        out.push_str(&format!(
531            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
532            base_name
533        ));
534        out.push_str("}\n");
535        files.push((base_name.clone(), out));
536    }
537
538    // Per-variant exception classes
539    for variant in &error.variants {
540        let class_name = format!("{}Exception", variant.name);
541        let mut out = String::with_capacity(512);
542        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
543        out.push_str(&format!("namespace {};\n\n", namespace));
544        if !variant.doc.is_empty() {
545            out.push_str("/// <summary>\n");
546            for line in variant.doc.lines() {
547                out.push_str(&format!("/// {}\n", line));
548            }
549            out.push_str("/// </summary>\n");
550        }
551        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
552        out.push_str(&format!(
553            "    public {}(string message) : base(message) {{ }}\n\n",
554            class_name
555        ));
556        out.push_str(&format!(
557            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
558            class_name
559        ));
560        out.push_str("}\n");
561        files.push((class_name, out));
562    }
563
564    files
565}
566
567// ---------------------------------------------------------------------------
568// Helpers
569// ---------------------------------------------------------------------------
570
571/// Convert CamelCase to SCREAMING_SNAKE_CASE.
572fn to_screaming_snake(s: &str) -> String {
573    let mut result = String::with_capacity(s.len() + 4);
574    for (i, c) in s.chars().enumerate() {
575        if c.is_uppercase() {
576            if i > 0 {
577                result.push('_');
578            }
579            result.push(c.to_ascii_uppercase());
580        } else {
581            result.push(c.to_ascii_uppercase());
582        }
583    }
584    result
585}
586
587/// Generate a human-readable message for an error variant.
588///
589/// Uses the `message_template` if present, otherwise falls back to a
590/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
591fn variant_display_message(variant: &ErrorVariant) -> String {
592    if let Some(tmpl) = &variant.message_template {
593        // Strip format placeholders like {0}, {source}, etc.
594        let msg = tmpl
595            .replace("{0}", "")
596            .replace("{source}", "")
597            .trim_end_matches(": ")
598            .trim()
599            .to_string();
600        if msg.is_empty() {
601            to_snake_case(&variant.name).replace('_', " ")
602        } else {
603            msg
604        }
605    } else {
606        to_snake_case(&variant.name).replace('_', " ")
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use alef_core::ir::{ErrorDef, ErrorVariant};
614
615    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
616
617    /// Helper to create a tuple-style field (e.g. `_0: String`).
618    fn tuple_field(index: usize) -> FieldDef {
619        FieldDef {
620            name: format!("_{index}"),
621            ty: TypeRef::String,
622            optional: false,
623            default: None,
624            doc: String::new(),
625            sanitized: false,
626            is_boxed: false,
627            type_rust_path: None,
628            cfg: None,
629            typed_default: None,
630            core_wrapper: CoreWrapper::None,
631            vec_inner_core_wrapper: CoreWrapper::None,
632            newtype_wrapper: None,
633        }
634    }
635
636    /// Helper to create a named struct field.
637    fn named_field(name: &str) -> FieldDef {
638        FieldDef {
639            name: name.to_string(),
640            ty: TypeRef::String,
641            optional: false,
642            default: None,
643            doc: String::new(),
644            sanitized: false,
645            is_boxed: false,
646            type_rust_path: None,
647            cfg: None,
648            typed_default: None,
649            core_wrapper: CoreWrapper::None,
650            vec_inner_core_wrapper: CoreWrapper::None,
651            newtype_wrapper: None,
652        }
653    }
654
655    fn sample_error() -> ErrorDef {
656        ErrorDef {
657            name: "ConversionError".to_string(),
658            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
659            variants: vec![
660                ErrorVariant {
661                    name: "ParseError".to_string(),
662                    message_template: Some("HTML parsing error: {0}".to_string()),
663                    fields: vec![tuple_field(0)],
664                    has_source: false,
665                    has_from: false,
666                    is_unit: false,
667                    doc: String::new(),
668                },
669                ErrorVariant {
670                    name: "IoError".to_string(),
671                    message_template: Some("I/O error: {0}".to_string()),
672                    fields: vec![tuple_field(0)],
673                    has_source: false,
674                    has_from: true,
675                    is_unit: false,
676                    doc: String::new(),
677                },
678                ErrorVariant {
679                    name: "Other".to_string(),
680                    message_template: Some("Conversion error: {0}".to_string()),
681                    fields: vec![tuple_field(0)],
682                    has_source: false,
683                    has_from: false,
684                    is_unit: false,
685                    doc: String::new(),
686                },
687            ],
688            doc: "Error type for conversion operations.".to_string(),
689        }
690    }
691
692    #[test]
693    fn test_gen_error_types() {
694        let error = sample_error();
695        let output = gen_pyo3_error_types(&error, "_module");
696        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
697        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
698        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
699        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
700    }
701
702    #[test]
703    fn test_gen_error_converter() {
704        let error = sample_error();
705        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
706        assert!(
707            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
708        );
709        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
710        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
711    }
712
713    #[test]
714    fn test_gen_error_registration() {
715        let error = sample_error();
716        let regs = gen_pyo3_error_registration(&error);
717        assert_eq!(regs.len(), 4); // 3 variants + 1 base
718        assert!(regs[0].contains("\"ParseError\""));
719        assert!(regs[3].contains("\"ConversionError\""));
720    }
721
722    #[test]
723    fn test_unit_variant_pattern() {
724        let error = ErrorDef {
725            name: "MyError".to_string(),
726            rust_path: "my_crate::MyError".to_string(),
727            variants: vec![ErrorVariant {
728                name: "NotFound".to_string(),
729                message_template: Some("not found".to_string()),
730                fields: vec![],
731                has_source: false,
732                has_from: false,
733                is_unit: true,
734                doc: String::new(),
735            }],
736            doc: String::new(),
737        };
738        let output = gen_pyo3_error_converter(&error, "my_crate");
739        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
740        // Ensure no (..) for unit variants
741        assert!(!output.contains("NotFound(..)"));
742    }
743
744    #[test]
745    fn test_struct_variant_pattern() {
746        let error = ErrorDef {
747            name: "MyError".to_string(),
748            rust_path: "my_crate::MyError".to_string(),
749            variants: vec![ErrorVariant {
750                name: "Parsing".to_string(),
751                message_template: Some("parsing error: {message}".to_string()),
752                fields: vec![named_field("message")],
753                has_source: false,
754                has_from: false,
755                is_unit: false,
756                doc: String::new(),
757            }],
758            doc: String::new(),
759        };
760        let output = gen_pyo3_error_converter(&error, "my_crate");
761        assert!(
762            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
763            "Struct variants must use {{ .. }} pattern, got:\n{output}"
764        );
765        // Ensure no (..) for struct variants
766        assert!(!output.contains("Parsing(..)"));
767    }
768
769    // -----------------------------------------------------------------------
770    // NAPI tests
771    // -----------------------------------------------------------------------
772
773    #[test]
774    fn test_gen_napi_error_types() {
775        let error = sample_error();
776        let output = gen_napi_error_types(&error);
777        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
778        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
779        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
780    }
781
782    #[test]
783    fn test_gen_napi_error_converter() {
784        let error = sample_error();
785        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
786        assert!(
787            output
788                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
789        );
790        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
791        assert!(output.contains("[ParseError]"));
792        assert!(output.contains("[IoError]"));
793        assert!(output.contains("#[allow(dead_code)]"));
794    }
795
796    #[test]
797    fn test_napi_unit_variant() {
798        let error = ErrorDef {
799            name: "MyError".to_string(),
800            rust_path: "my_crate::MyError".to_string(),
801            variants: vec![ErrorVariant {
802                name: "NotFound".to_string(),
803                message_template: None,
804                fields: vec![],
805                has_source: false,
806                has_from: false,
807                is_unit: true,
808                doc: String::new(),
809            }],
810            doc: String::new(),
811        };
812        let output = gen_napi_error_converter(&error, "my_crate");
813        assert!(output.contains("my_crate::MyError::NotFound =>"));
814        assert!(!output.contains("NotFound(..)"));
815    }
816
817    // -----------------------------------------------------------------------
818    // WASM tests
819    // -----------------------------------------------------------------------
820
821    #[test]
822    fn test_gen_wasm_error_converter() {
823        let error = sample_error();
824        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
825        assert!(output.contains(
826            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
827        ));
828        assert!(output.contains("JsValue::from_str(&e.to_string())"));
829        assert!(output.contains("#[allow(dead_code)]"));
830    }
831
832    // -----------------------------------------------------------------------
833    // PHP tests
834    // -----------------------------------------------------------------------
835
836    #[test]
837    fn test_gen_php_error_converter() {
838        let error = sample_error();
839        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
840        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
841        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
842        assert!(output.contains("#[allow(dead_code)]"));
843    }
844
845    // -----------------------------------------------------------------------
846    // Magnus tests
847    // -----------------------------------------------------------------------
848
849    #[test]
850    fn test_gen_magnus_error_converter() {
851        let error = sample_error();
852        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
853        assert!(
854            output.contains(
855                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
856            )
857        );
858        assert!(
859            output.contains(
860                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
861            )
862        );
863        assert!(output.contains("#[allow(dead_code)]"));
864    }
865
866    // -----------------------------------------------------------------------
867    // Rustler tests
868    // -----------------------------------------------------------------------
869
870    #[test]
871    fn test_gen_rustler_error_converter() {
872        let error = sample_error();
873        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
874        assert!(
875            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
876        );
877        assert!(output.contains("e.to_string()"));
878        assert!(output.contains("#[allow(dead_code)]"));
879    }
880
881    // -----------------------------------------------------------------------
882    // Helper tests
883    // -----------------------------------------------------------------------
884
885    #[test]
886    fn test_to_screaming_snake() {
887        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
888        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
889        assert_eq!(to_screaming_snake("Other"), "OTHER");
890    }
891
892    // -----------------------------------------------------------------------
893    // FFI (C) tests
894    // -----------------------------------------------------------------------
895
896    #[test]
897    fn test_gen_ffi_error_codes() {
898        let error = sample_error();
899        let output = gen_ffi_error_codes(&error);
900        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
901        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
902        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
903        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
904        assert!(output.contains("conversion_error_t;"));
905        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
906    }
907
908    // -----------------------------------------------------------------------
909    // Go tests
910    // -----------------------------------------------------------------------
911
912    #[test]
913    fn test_gen_go_error_types() {
914        let error = sample_error();
915        let output = gen_go_error_types(&error);
916        assert!(output.contains("ErrParseError = errors.New("));
917        assert!(output.contains("ErrIoError = errors.New("));
918        assert!(output.contains("ErrOther = errors.New("));
919        assert!(output.contains("type ConversionError struct {"));
920        assert!(output.contains("Code    string"));
921        assert!(output.contains("func (e *ConversionError) Error() string"));
922    }
923
924    // -----------------------------------------------------------------------
925    // Java tests
926    // -----------------------------------------------------------------------
927
928    #[test]
929    fn test_gen_java_error_types() {
930        let error = sample_error();
931        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
932        // base + 3 variants
933        assert_eq!(files.len(), 4);
934        // Base class
935        assert_eq!(files[0].0, "ConversionErrorException");
936        assert!(
937            files[0]
938                .1
939                .contains("public class ConversionErrorException extends Exception")
940        );
941        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
942        // Variant classes
943        assert_eq!(files[1].0, "ParseErrorException");
944        assert!(
945            files[1]
946                .1
947                .contains("public class ParseErrorException extends ConversionErrorException")
948        );
949        assert_eq!(files[2].0, "IoErrorException");
950        assert_eq!(files[3].0, "OtherException");
951    }
952
953    // -----------------------------------------------------------------------
954    // C# tests
955    // -----------------------------------------------------------------------
956
957    #[test]
958    fn test_gen_csharp_error_types() {
959        let error = sample_error();
960        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
961        // base + 3 variants
962        assert_eq!(files.len(), 4);
963        // Base class
964        assert_eq!(files[0].0, "ConversionErrorException");
965        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
966        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
967        // Variant classes
968        assert_eq!(files[1].0, "ParseErrorException");
969        assert!(
970            files[1]
971                .1
972                .contains("public class ParseErrorException : ConversionErrorException")
973        );
974        assert_eq!(files[2].0, "IoErrorException");
975        assert_eq!(files[3].0, "OtherException");
976    }
977
978    // -----------------------------------------------------------------------
979    // python_exception_name tests
980    // -----------------------------------------------------------------------
981
982    #[test]
983    fn test_python_exception_name_no_conflict() {
984        // "ParseError" already ends with "Error" and is not a builtin
985        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
986        // "Other" gets "Error" suffix, "OtherError" is not a builtin
987        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
988    }
989
990    #[test]
991    fn test_python_exception_name_shadows_builtin() {
992        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
993        assert_eq!(
994            python_exception_name("Connection", "CrawlError"),
995            "CrawlConnectionError"
996        );
997        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
998        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
999        // "ConnectionError" already ends with "Error", still shadows -> prefix
1000        assert_eq!(
1001            python_exception_name("ConnectionError", "CrawlError"),
1002            "CrawlConnectionError"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_python_exception_name_no_double_prefix() {
1008        // If variant is already prefixed with the error base, don't double-prefix
1009        assert_eq!(
1010            python_exception_name("CrawlConnectionError", "CrawlError"),
1011            "CrawlConnectionError"
1012        );
1013    }
1014}