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