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            variants: vec![
699                ErrorVariant {
700                    name: "ParseError".to_string(),
701                    message_template: Some("HTML parsing error: {0}".to_string()),
702                    fields: vec![tuple_field(0)],
703                    has_source: false,
704                    has_from: false,
705                    is_unit: false,
706                    doc: String::new(),
707                },
708                ErrorVariant {
709                    name: "IoError".to_string(),
710                    message_template: Some("I/O error: {0}".to_string()),
711                    fields: vec![tuple_field(0)],
712                    has_source: false,
713                    has_from: true,
714                    is_unit: false,
715                    doc: String::new(),
716                },
717                ErrorVariant {
718                    name: "Other".to_string(),
719                    message_template: Some("Conversion error: {0}".to_string()),
720                    fields: vec![tuple_field(0)],
721                    has_source: false,
722                    has_from: false,
723                    is_unit: false,
724                    doc: String::new(),
725                },
726            ],
727            doc: "Error type for conversion operations.".to_string(),
728        }
729    }
730
731    #[test]
732    fn test_gen_error_types() {
733        let error = sample_error();
734        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
735        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
736        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
737        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
738        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
739    }
740
741    #[test]
742    fn test_gen_error_converter() {
743        let error = sample_error();
744        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
745        assert!(
746            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
747        );
748        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
749        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
750    }
751
752    #[test]
753    fn test_gen_error_registration() {
754        let error = sample_error();
755        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
756        assert_eq!(regs.len(), 4); // 3 variants + 1 base
757        assert!(regs[0].contains("\"ParseError\""));
758        assert!(regs[3].contains("\"ConversionError\""));
759    }
760
761    #[test]
762    fn test_unit_variant_pattern() {
763        let error = ErrorDef {
764            name: "MyError".to_string(),
765            rust_path: "my_crate::MyError".to_string(),
766            variants: vec![ErrorVariant {
767                name: "NotFound".to_string(),
768                message_template: Some("not found".to_string()),
769                fields: vec![],
770                has_source: false,
771                has_from: false,
772                is_unit: true,
773                doc: String::new(),
774            }],
775            doc: String::new(),
776        };
777        let output = gen_pyo3_error_converter(&error, "my_crate");
778        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
779        // Ensure no (..) for unit variants
780        assert!(!output.contains("NotFound(..)"));
781    }
782
783    #[test]
784    fn test_struct_variant_pattern() {
785        let error = ErrorDef {
786            name: "MyError".to_string(),
787            rust_path: "my_crate::MyError".to_string(),
788            variants: vec![ErrorVariant {
789                name: "Parsing".to_string(),
790                message_template: Some("parsing error: {message}".to_string()),
791                fields: vec![named_field("message")],
792                has_source: false,
793                has_from: false,
794                is_unit: false,
795                doc: String::new(),
796            }],
797            doc: String::new(),
798        };
799        let output = gen_pyo3_error_converter(&error, "my_crate");
800        assert!(
801            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
802            "Struct variants must use {{ .. }} pattern, got:\n{output}"
803        );
804        // Ensure no (..) for struct variants
805        assert!(!output.contains("Parsing(..)"));
806    }
807
808    // -----------------------------------------------------------------------
809    // NAPI tests
810    // -----------------------------------------------------------------------
811
812    #[test]
813    fn test_gen_napi_error_types() {
814        let error = sample_error();
815        let output = gen_napi_error_types(&error);
816        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
817        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
818        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
819    }
820
821    #[test]
822    fn test_gen_napi_error_converter() {
823        let error = sample_error();
824        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
825        assert!(
826            output
827                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
828        );
829        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
830        assert!(output.contains("[ParseError]"));
831        assert!(output.contains("[IoError]"));
832        assert!(output.contains("#[allow(dead_code)]"));
833    }
834
835    #[test]
836    fn test_napi_unit_variant() {
837        let error = ErrorDef {
838            name: "MyError".to_string(),
839            rust_path: "my_crate::MyError".to_string(),
840            variants: vec![ErrorVariant {
841                name: "NotFound".to_string(),
842                message_template: None,
843                fields: vec![],
844                has_source: false,
845                has_from: false,
846                is_unit: true,
847                doc: String::new(),
848            }],
849            doc: String::new(),
850        };
851        let output = gen_napi_error_converter(&error, "my_crate");
852        assert!(output.contains("my_crate::MyError::NotFound =>"));
853        assert!(!output.contains("NotFound(..)"));
854    }
855
856    // -----------------------------------------------------------------------
857    // WASM tests
858    // -----------------------------------------------------------------------
859
860    #[test]
861    fn test_gen_wasm_error_converter() {
862        let error = sample_error();
863        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
864        // Main converter function signature
865        assert!(output.contains(
866            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
867        ));
868        // Structured object with code + message
869        assert!(output.contains("js_sys::Object::new()"));
870        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
871        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
872        assert!(output.contains("obj.into()"));
873        // error_code helper
874        assert!(
875            output
876                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
877        );
878        assert!(output.contains("\"parse_error\""));
879        assert!(output.contains("\"io_error\""));
880        assert!(output.contains("\"other\""));
881        assert!(output.contains("#[allow(dead_code)]"));
882    }
883
884    // -----------------------------------------------------------------------
885    // PHP tests
886    // -----------------------------------------------------------------------
887
888    #[test]
889    fn test_gen_php_error_converter() {
890        let error = sample_error();
891        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
892        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
893        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
894        assert!(output.contains("#[allow(dead_code)]"));
895    }
896
897    // -----------------------------------------------------------------------
898    // Magnus tests
899    // -----------------------------------------------------------------------
900
901    #[test]
902    fn test_gen_magnus_error_converter() {
903        let error = sample_error();
904        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
905        assert!(
906            output.contains(
907                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
908            )
909        );
910        assert!(
911            output.contains(
912                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
913            )
914        );
915        assert!(output.contains("#[allow(dead_code)]"));
916    }
917
918    // -----------------------------------------------------------------------
919    // Rustler tests
920    // -----------------------------------------------------------------------
921
922    #[test]
923    fn test_gen_rustler_error_converter() {
924        let error = sample_error();
925        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
926        assert!(
927            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
928        );
929        assert!(output.contains("e.to_string()"));
930        assert!(output.contains("#[allow(dead_code)]"));
931    }
932
933    // -----------------------------------------------------------------------
934    // Helper tests
935    // -----------------------------------------------------------------------
936
937    #[test]
938    fn test_to_screaming_snake() {
939        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
940        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
941        assert_eq!(to_screaming_snake("Other"), "OTHER");
942    }
943
944    // -----------------------------------------------------------------------
945    // FFI (C) tests
946    // -----------------------------------------------------------------------
947
948    #[test]
949    fn test_gen_ffi_error_codes() {
950        let error = sample_error();
951        let output = gen_ffi_error_codes(&error);
952        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
953        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
954        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
955        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
956        assert!(output.contains("conversion_error_t;"));
957        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
958    }
959
960    // -----------------------------------------------------------------------
961    // Go tests
962    // -----------------------------------------------------------------------
963
964    #[test]
965    fn test_gen_go_error_types() {
966        let error = sample_error();
967        let output = gen_go_error_types(&error);
968        assert!(output.contains("ErrParseError = errors.New("));
969        assert!(output.contains("ErrIoError = errors.New("));
970        assert!(output.contains("ErrOther = errors.New("));
971        assert!(output.contains("type ConversionError struct {"));
972        assert!(output.contains("Code    string"));
973        assert!(output.contains("func (e *ConversionError) Error() string"));
974    }
975
976    // -----------------------------------------------------------------------
977    // Java tests
978    // -----------------------------------------------------------------------
979
980    #[test]
981    fn test_gen_java_error_types() {
982        let error = sample_error();
983        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
984        // base + 3 variants
985        assert_eq!(files.len(), 4);
986        // Base class
987        assert_eq!(files[0].0, "ConversionErrorException");
988        assert!(
989            files[0]
990                .1
991                .contains("public class ConversionErrorException extends Exception")
992        );
993        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
994        // Variant classes
995        assert_eq!(files[1].0, "ParseErrorException");
996        assert!(
997            files[1]
998                .1
999                .contains("public class ParseErrorException extends ConversionErrorException")
1000        );
1001        assert_eq!(files[2].0, "IoErrorException");
1002        assert_eq!(files[3].0, "OtherException");
1003    }
1004
1005    // -----------------------------------------------------------------------
1006    // C# tests
1007    // -----------------------------------------------------------------------
1008
1009    #[test]
1010    fn test_gen_csharp_error_types() {
1011        let error = sample_error();
1012        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
1013        // base + 3 variants
1014        assert_eq!(files.len(), 4);
1015        // Base class
1016        assert_eq!(files[0].0, "ConversionErrorException");
1017        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1018        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1019        // Variant classes
1020        assert_eq!(files[1].0, "ParseErrorException");
1021        assert!(
1022            files[1]
1023                .1
1024                .contains("public class ParseErrorException : ConversionErrorException")
1025        );
1026        assert_eq!(files[2].0, "IoErrorException");
1027        assert_eq!(files[3].0, "OtherException");
1028    }
1029
1030    // -----------------------------------------------------------------------
1031    // python_exception_name tests
1032    // -----------------------------------------------------------------------
1033
1034    #[test]
1035    fn test_python_exception_name_no_conflict() {
1036        // "ParseError" already ends with "Error" and is not a builtin
1037        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1038        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1039        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1040    }
1041
1042    #[test]
1043    fn test_python_exception_name_shadows_builtin() {
1044        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1045        assert_eq!(
1046            python_exception_name("Connection", "CrawlError"),
1047            "CrawlConnectionError"
1048        );
1049        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1050        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1051        // "ConnectionError" already ends with "Error", still shadows -> prefix
1052        assert_eq!(
1053            python_exception_name("ConnectionError", "CrawlError"),
1054            "CrawlConnectionError"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_python_exception_name_no_double_prefix() {
1060        // If variant is already prefixed with the error base, don't double-prefix
1061        assert_eq!(
1062            python_exception_name("CrawlConnectionError", "CrawlError"),
1063            "CrawlConnectionError"
1064        );
1065    }
1066}