Skip to main content

alef_codegen/
error_gen.rs

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