Skip to main content

alef_codegen/
error_gen.rs

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