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("    magnus::Error::new(magnus::exception::runtime_error(), msg)".to_string());
334    lines.push("}".to_string());
335    lines.join("\n")
336}
337
338/// Return the Magnus converter function name for a given error type.
339pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
340    format!("{}_to_magnus_err", to_snake_case(&error.name))
341}
342
343// ---------------------------------------------------------------------------
344// Rustler (Elixir) error generation
345// ---------------------------------------------------------------------------
346
347/// Generate a converter function that maps a core error to a Rustler error tuple `{:error, reason}`.
348pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
349    let rust_path = if error.rust_path.is_empty() {
350        format!("{core_import}::{}", error.name)
351    } else {
352        error.rust_path.replace('-', "_")
353    };
354
355    let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
356
357    let mut lines = Vec::new();
358    lines.push(format!("/// Convert a `{rust_path}` error to a Rustler error string."));
359    lines.push("#[allow(dead_code)]".to_string());
360    lines.push(format!("fn {fn_name}(e: {rust_path}) -> String {{"));
361    lines.push("    e.to_string()".to_string());
362    lines.push("}".to_string());
363    lines.join("\n")
364}
365
366/// Return the Rustler converter function name for a given error type.
367pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
368    format!("{}_to_rustler_err", to_snake_case(&error.name))
369}
370
371// ---------------------------------------------------------------------------
372// FFI (C) error code generation
373// ---------------------------------------------------------------------------
374
375/// Generate a C enum of error codes plus an error-message function declaration.
376///
377/// Produces a `typedef enum` with `PREFIX_ERROR_NONE = 0` followed by one entry
378/// per variant, plus a function that returns the default message for a given code.
379pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
380    let prefix = to_screaming_snake(&error.name);
381    let prefix_lower = to_snake_case(&error.name);
382
383    let mut lines = Vec::new();
384    lines.push(format!("/// Error codes for `{}`.", error.name));
385    lines.push("typedef enum {".to_string());
386    lines.push(format!("    {}_NONE = 0,", prefix));
387
388    for (i, variant) in error.variants.iter().enumerate() {
389        let variant_screaming = to_screaming_snake(&variant.name);
390        lines.push(format!("    {}_{} = {},", prefix, variant_screaming, i + 1));
391    }
392
393    lines.push(format!("}} {}_t;\n", prefix_lower));
394
395    // Error message function
396    lines.push(format!(
397        "/// Return a static string describing the error code.\nconst char* {}_error_message({}_t code);",
398        prefix_lower, prefix_lower
399    ));
400
401    lines.join("\n")
402}
403
404// ---------------------------------------------------------------------------
405// Go error type generation
406// ---------------------------------------------------------------------------
407
408/// Generate Go sentinel errors and a structured error type for an `ErrorDef`.
409pub fn gen_go_error_types(error: &ErrorDef) -> String {
410    let mut lines = Vec::new();
411
412    // Sentinel errors
413    lines.push("var (".to_string());
414    for variant in &error.variants {
415        let err_name = format!("Err{}", variant.name);
416        let msg = variant_display_message(variant);
417        lines.push(format!("    {} = errors.New(\"{}\")", err_name, msg));
418    }
419    lines.push(")\n".to_string());
420
421    // Structured error type
422    lines.push(format!("// {} is a structured error type.", error.name));
423    lines.push(format!("type {} struct {{", error.name));
424    lines.push("    Code    string".to_string());
425    lines.push("    Message string".to_string());
426    lines.push("}\n".to_string());
427
428    lines.push(format!(
429        "func (e *{}) Error() string {{ return e.Message }}",
430        error.name
431    ));
432
433    lines.join("\n")
434}
435
436// ---------------------------------------------------------------------------
437// Java error type generation
438// ---------------------------------------------------------------------------
439
440/// Generate Java exception sub-classes for each error variant.
441///
442/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
443/// class followed by one per-variant exception.  The caller writes each to a
444/// separate `.java` file.
445pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
446    let mut files = Vec::with_capacity(error.variants.len() + 1);
447
448    // Base exception class
449    let base_name = format!("{}Exception", error.name);
450    let mut base = String::with_capacity(512);
451    base.push_str(&format!(
452        "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
453        package
454    ));
455    if !error.doc.is_empty() {
456        base.push_str(&format!("/** {} */\n", error.doc));
457    }
458    base.push_str(&format!("public class {} extends Exception {{\n", base_name));
459    base.push_str(&format!(
460        "    public {}(String message) {{\n        super(message);\n    }}\n\n",
461        base_name
462    ));
463    base.push_str(&format!(
464        "    public {}(String message, Throwable cause) {{\n        super(message, cause);\n    }}\n",
465        base_name
466    ));
467    base.push_str("}\n");
468    files.push((base_name.clone(), base));
469
470    // Per-variant exception classes
471    for variant in &error.variants {
472        let class_name = format!("{}Exception", variant.name);
473        let mut content = String::with_capacity(512);
474        content.push_str(&format!(
475            "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
476            package
477        ));
478        if !variant.doc.is_empty() {
479            content.push_str(&format!("/** {} */\n", variant.doc));
480        }
481        content.push_str(&format!("public class {} extends {} {{\n", class_name, base_name));
482        content.push_str(&format!(
483            "    public {}(String message) {{\n        super(message);\n    }}\n\n",
484            class_name
485        ));
486        content.push_str(&format!(
487            "    public {}(String message, Throwable cause) {{\n        super(message, cause);\n    }}\n",
488            class_name
489        ));
490        content.push_str("}\n");
491        files.push((class_name, content));
492    }
493
494    files
495}
496
497// ---------------------------------------------------------------------------
498// C# error type generation
499// ---------------------------------------------------------------------------
500
501/// Generate C# exception sub-classes for each error variant.
502///
503/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
504/// class followed by one per-variant exception.  The caller writes each to a
505/// separate `.cs` file.
506pub fn gen_csharp_error_types(error: &ErrorDef, namespace: &str) -> Vec<(String, String)> {
507    let mut files = Vec::with_capacity(error.variants.len() + 1);
508
509    let base_name = format!("{}Exception", error.name);
510
511    // Base exception class
512    {
513        let mut out = String::with_capacity(512);
514        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
515        out.push_str(&format!("namespace {};\n\n", namespace));
516        if !error.doc.is_empty() {
517            out.push_str("/// <summary>\n");
518            for line in error.doc.lines() {
519                out.push_str(&format!("/// {}\n", line));
520            }
521            out.push_str("/// </summary>\n");
522        }
523        out.push_str(&format!("public class {} : Exception\n{{\n", base_name));
524        out.push_str(&format!(
525            "    public {}(string message) : base(message) {{ }}\n\n",
526            base_name
527        ));
528        out.push_str(&format!(
529            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
530            base_name
531        ));
532        out.push_str("}\n");
533        files.push((base_name.clone(), out));
534    }
535
536    // Per-variant exception classes
537    for variant in &error.variants {
538        let class_name = format!("{}Exception", variant.name);
539        let mut out = String::with_capacity(512);
540        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
541        out.push_str(&format!("namespace {};\n\n", namespace));
542        if !variant.doc.is_empty() {
543            out.push_str("/// <summary>\n");
544            for line in variant.doc.lines() {
545                out.push_str(&format!("/// {}\n", line));
546            }
547            out.push_str("/// </summary>\n");
548        }
549        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
550        out.push_str(&format!(
551            "    public {}(string message) : base(message) {{ }}\n\n",
552            class_name
553        ));
554        out.push_str(&format!(
555            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
556            class_name
557        ));
558        out.push_str("}\n");
559        files.push((class_name, out));
560    }
561
562    files
563}
564
565// ---------------------------------------------------------------------------
566// Helpers
567// ---------------------------------------------------------------------------
568
569/// Convert CamelCase to SCREAMING_SNAKE_CASE.
570fn to_screaming_snake(s: &str) -> String {
571    let mut result = String::with_capacity(s.len() + 4);
572    for (i, c) in s.chars().enumerate() {
573        if c.is_uppercase() {
574            if i > 0 {
575                result.push('_');
576            }
577            result.push(c.to_ascii_uppercase());
578        } else {
579            result.push(c.to_ascii_uppercase());
580        }
581    }
582    result
583}
584
585/// Generate a human-readable message for an error variant.
586///
587/// Uses the `message_template` if present, otherwise falls back to a
588/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
589fn variant_display_message(variant: &ErrorVariant) -> String {
590    if let Some(tmpl) = &variant.message_template {
591        // Strip format placeholders like {0}, {source}, etc.
592        let msg = tmpl
593            .replace("{0}", "")
594            .replace("{source}", "")
595            .trim_end_matches(": ")
596            .trim()
597            .to_string();
598        if msg.is_empty() {
599            to_snake_case(&variant.name).replace('_', " ")
600        } else {
601            msg
602        }
603    } else {
604        to_snake_case(&variant.name).replace('_', " ")
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use alef_core::ir::{ErrorDef, ErrorVariant};
612
613    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
614
615    /// Helper to create a tuple-style field (e.g. `_0: String`).
616    fn tuple_field(index: usize) -> FieldDef {
617        FieldDef {
618            name: format!("_{index}"),
619            ty: TypeRef::String,
620            optional: false,
621            default: None,
622            doc: String::new(),
623            sanitized: false,
624            is_boxed: false,
625            type_rust_path: None,
626            cfg: None,
627            typed_default: None,
628            core_wrapper: CoreWrapper::None,
629            vec_inner_core_wrapper: CoreWrapper::None,
630            newtype_wrapper: None,
631        }
632    }
633
634    /// Helper to create a named struct field.
635    fn named_field(name: &str) -> FieldDef {
636        FieldDef {
637            name: name.to_string(),
638            ty: TypeRef::String,
639            optional: false,
640            default: None,
641            doc: String::new(),
642            sanitized: false,
643            is_boxed: false,
644            type_rust_path: None,
645            cfg: None,
646            typed_default: None,
647            core_wrapper: CoreWrapper::None,
648            vec_inner_core_wrapper: CoreWrapper::None,
649            newtype_wrapper: None,
650        }
651    }
652
653    fn sample_error() -> ErrorDef {
654        ErrorDef {
655            name: "ConversionError".to_string(),
656            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
657            variants: vec![
658                ErrorVariant {
659                    name: "ParseError".to_string(),
660                    message_template: Some("HTML parsing error: {0}".to_string()),
661                    fields: vec![tuple_field(0)],
662                    has_source: false,
663                    has_from: false,
664                    is_unit: false,
665                    doc: String::new(),
666                },
667                ErrorVariant {
668                    name: "IoError".to_string(),
669                    message_template: Some("I/O error: {0}".to_string()),
670                    fields: vec![tuple_field(0)],
671                    has_source: false,
672                    has_from: true,
673                    is_unit: false,
674                    doc: String::new(),
675                },
676                ErrorVariant {
677                    name: "Other".to_string(),
678                    message_template: Some("Conversion error: {0}".to_string()),
679                    fields: vec![tuple_field(0)],
680                    has_source: false,
681                    has_from: false,
682                    is_unit: false,
683                    doc: String::new(),
684                },
685            ],
686            doc: "Error type for conversion operations.".to_string(),
687        }
688    }
689
690    #[test]
691    fn test_gen_error_types() {
692        let error = sample_error();
693        let output = gen_pyo3_error_types(&error, "_module");
694        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
695        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
696        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
697        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
698    }
699
700    #[test]
701    fn test_gen_error_converter() {
702        let error = sample_error();
703        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
704        assert!(
705            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
706        );
707        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
708        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
709    }
710
711    #[test]
712    fn test_gen_error_registration() {
713        let error = sample_error();
714        let regs = gen_pyo3_error_registration(&error);
715        assert_eq!(regs.len(), 4); // 3 variants + 1 base
716        assert!(regs[0].contains("\"ParseError\""));
717        assert!(regs[3].contains("\"ConversionError\""));
718    }
719
720    #[test]
721    fn test_unit_variant_pattern() {
722        let error = ErrorDef {
723            name: "MyError".to_string(),
724            rust_path: "my_crate::MyError".to_string(),
725            variants: vec![ErrorVariant {
726                name: "NotFound".to_string(),
727                message_template: Some("not found".to_string()),
728                fields: vec![],
729                has_source: false,
730                has_from: false,
731                is_unit: true,
732                doc: String::new(),
733            }],
734            doc: String::new(),
735        };
736        let output = gen_pyo3_error_converter(&error, "my_crate");
737        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
738        // Ensure no (..) for unit variants
739        assert!(!output.contains("NotFound(..)"));
740    }
741
742    #[test]
743    fn test_struct_variant_pattern() {
744        let error = ErrorDef {
745            name: "MyError".to_string(),
746            rust_path: "my_crate::MyError".to_string(),
747            variants: vec![ErrorVariant {
748                name: "Parsing".to_string(),
749                message_template: Some("parsing error: {message}".to_string()),
750                fields: vec![named_field("message")],
751                has_source: false,
752                has_from: false,
753                is_unit: false,
754                doc: String::new(),
755            }],
756            doc: String::new(),
757        };
758        let output = gen_pyo3_error_converter(&error, "my_crate");
759        assert!(
760            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
761            "Struct variants must use {{ .. }} pattern, got:\n{output}"
762        );
763        // Ensure no (..) for struct variants
764        assert!(!output.contains("Parsing(..)"));
765    }
766
767    // -----------------------------------------------------------------------
768    // NAPI tests
769    // -----------------------------------------------------------------------
770
771    #[test]
772    fn test_gen_napi_error_types() {
773        let error = sample_error();
774        let output = gen_napi_error_types(&error);
775        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
776        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
777        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
778    }
779
780    #[test]
781    fn test_gen_napi_error_converter() {
782        let error = sample_error();
783        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
784        assert!(
785            output
786                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
787        );
788        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
789        assert!(output.contains("[ParseError]"));
790        assert!(output.contains("[IoError]"));
791        assert!(output.contains("#[allow(dead_code)]"));
792    }
793
794    #[test]
795    fn test_napi_unit_variant() {
796        let error = ErrorDef {
797            name: "MyError".to_string(),
798            rust_path: "my_crate::MyError".to_string(),
799            variants: vec![ErrorVariant {
800                name: "NotFound".to_string(),
801                message_template: None,
802                fields: vec![],
803                has_source: false,
804                has_from: false,
805                is_unit: true,
806                doc: String::new(),
807            }],
808            doc: String::new(),
809        };
810        let output = gen_napi_error_converter(&error, "my_crate");
811        assert!(output.contains("my_crate::MyError::NotFound =>"));
812        assert!(!output.contains("NotFound(..)"));
813    }
814
815    // -----------------------------------------------------------------------
816    // WASM tests
817    // -----------------------------------------------------------------------
818
819    #[test]
820    fn test_gen_wasm_error_converter() {
821        let error = sample_error();
822        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
823        assert!(output.contains(
824            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
825        ));
826        assert!(output.contains("JsValue::from_str(&e.to_string())"));
827        assert!(output.contains("#[allow(dead_code)]"));
828    }
829
830    // -----------------------------------------------------------------------
831    // PHP tests
832    // -----------------------------------------------------------------------
833
834    #[test]
835    fn test_gen_php_error_converter() {
836        let error = sample_error();
837        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
838        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
839        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
840        assert!(output.contains("#[allow(dead_code)]"));
841    }
842
843    // -----------------------------------------------------------------------
844    // Magnus tests
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn test_gen_magnus_error_converter() {
849        let error = sample_error();
850        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
851        assert!(
852            output.contains(
853                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
854            )
855        );
856        assert!(output.contains("magnus::Error::new(magnus::exception::runtime_error(), msg)"));
857        assert!(output.contains("#[allow(dead_code)]"));
858    }
859
860    // -----------------------------------------------------------------------
861    // Rustler tests
862    // -----------------------------------------------------------------------
863
864    #[test]
865    fn test_gen_rustler_error_converter() {
866        let error = sample_error();
867        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
868        assert!(
869            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
870        );
871        assert!(output.contains("e.to_string()"));
872        assert!(output.contains("#[allow(dead_code)]"));
873    }
874
875    // -----------------------------------------------------------------------
876    // Helper tests
877    // -----------------------------------------------------------------------
878
879    #[test]
880    fn test_to_screaming_snake() {
881        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
882        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
883        assert_eq!(to_screaming_snake("Other"), "OTHER");
884    }
885
886    // -----------------------------------------------------------------------
887    // FFI (C) tests
888    // -----------------------------------------------------------------------
889
890    #[test]
891    fn test_gen_ffi_error_codes() {
892        let error = sample_error();
893        let output = gen_ffi_error_codes(&error);
894        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
895        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
896        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
897        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
898        assert!(output.contains("conversion_error_t;"));
899        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
900    }
901
902    // -----------------------------------------------------------------------
903    // Go tests
904    // -----------------------------------------------------------------------
905
906    #[test]
907    fn test_gen_go_error_types() {
908        let error = sample_error();
909        let output = gen_go_error_types(&error);
910        assert!(output.contains("ErrParseError = errors.New("));
911        assert!(output.contains("ErrIoError = errors.New("));
912        assert!(output.contains("ErrOther = errors.New("));
913        assert!(output.contains("type ConversionError struct {"));
914        assert!(output.contains("Code    string"));
915        assert!(output.contains("func (e *ConversionError) Error() string"));
916    }
917
918    // -----------------------------------------------------------------------
919    // Java tests
920    // -----------------------------------------------------------------------
921
922    #[test]
923    fn test_gen_java_error_types() {
924        let error = sample_error();
925        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
926        // base + 3 variants
927        assert_eq!(files.len(), 4);
928        // Base class
929        assert_eq!(files[0].0, "ConversionErrorException");
930        assert!(
931            files[0]
932                .1
933                .contains("public class ConversionErrorException extends Exception")
934        );
935        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
936        // Variant classes
937        assert_eq!(files[1].0, "ParseErrorException");
938        assert!(
939            files[1]
940                .1
941                .contains("public class ParseErrorException extends ConversionErrorException")
942        );
943        assert_eq!(files[2].0, "IoErrorException");
944        assert_eq!(files[3].0, "OtherException");
945    }
946
947    // -----------------------------------------------------------------------
948    // C# tests
949    // -----------------------------------------------------------------------
950
951    #[test]
952    fn test_gen_csharp_error_types() {
953        let error = sample_error();
954        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
955        // base + 3 variants
956        assert_eq!(files.len(), 4);
957        // Base class
958        assert_eq!(files[0].0, "ConversionErrorException");
959        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
960        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
961        // Variant classes
962        assert_eq!(files[1].0, "ParseErrorException");
963        assert!(
964            files[1]
965                .1
966                .contains("public class ParseErrorException : ConversionErrorException")
967        );
968        assert_eq!(files[2].0, "IoErrorException");
969        assert_eq!(files[3].0, "OtherException");
970    }
971
972    // -----------------------------------------------------------------------
973    // python_exception_name tests
974    // -----------------------------------------------------------------------
975
976    #[test]
977    fn test_python_exception_name_no_conflict() {
978        // "ParseError" already ends with "Error" and is not a builtin
979        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
980        // "Other" gets "Error" suffix, "OtherError" is not a builtin
981        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
982    }
983
984    #[test]
985    fn test_python_exception_name_shadows_builtin() {
986        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
987        assert_eq!(
988            python_exception_name("Connection", "CrawlError"),
989            "CrawlConnectionError"
990        );
991        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
992        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
993        // "ConnectionError" already ends with "Error", still shadows -> prefix
994        assert_eq!(
995            python_exception_name("ConnectionError", "CrawlError"),
996            "CrawlConnectionError"
997        );
998    }
999
1000    #[test]
1001    fn test_python_exception_name_no_double_prefix() {
1002        // If variant is already prefixed with the error base, don't double-prefix
1003        assert_eq!(
1004            python_exception_name("CrawlConnectionError", "CrawlError"),
1005            "CrawlConnectionError"
1006        );
1007    }
1008}