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!("\t{} = 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("\tCode    string".to_string());
477    lines.push("\tMessage 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        let doc = error.doc.trim_end_matches('.');
509        base.push_str(&format!("/** {}. */\n", doc));
510    }
511    base.push_str(&format!("public class {} extends Exception {{\n", base_name));
512    base.push_str(&format!(
513        "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
514        base_name, base_name
515    ));
516    base.push_str(&format!(
517        "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
518        base_name, base_name
519    ));
520    base.push_str("}\n");
521    files.push((base_name.clone(), base));
522
523    // Per-variant exception classes
524    for variant in &error.variants {
525        let class_name = format!("{}Exception", variant.name);
526        let mut content = String::with_capacity(512);
527        content.push_str(&format!(
528            "// DO NOT EDIT - auto-generated by alef\npackage {};\n\n",
529            package
530        ));
531        if !variant.doc.is_empty() {
532            let doc = variant.doc.trim_end_matches('.');
533            content.push_str(&format!("/** {}. */\n", doc));
534        }
535        content.push_str(&format!("public class {} extends {} {{\n", class_name, base_name));
536        content.push_str(&format!(
537            "    /** Creates a new {} with the given message. */\n    public {}(final String message) {{\n        super(message);\n    }}\n\n",
538            class_name, class_name
539        ));
540        content.push_str(&format!(
541            "    /** Creates a new {} with the given message and cause. */\n    public {}(final String message, final Throwable cause) {{\n        super(message, cause);\n    }}\n",
542            class_name, class_name
543        ));
544        content.push_str("}\n");
545        files.push((class_name, content));
546    }
547
548    files
549}
550
551// ---------------------------------------------------------------------------
552// C# error type generation
553// ---------------------------------------------------------------------------
554
555/// Generate C# exception sub-classes for each error variant.
556///
557/// Returns a `Vec` of `(class_name, file_content)` tuples: the base exception
558/// class followed by one per-variant exception.  The caller writes each to a
559/// separate `.cs` file.
560pub fn gen_csharp_error_types(error: &ErrorDef, namespace: &str) -> Vec<(String, String)> {
561    let mut files = Vec::with_capacity(error.variants.len() + 1);
562
563    let base_name = format!("{}Exception", error.name);
564
565    // Base exception class
566    {
567        let mut out = String::with_capacity(512);
568        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
569        out.push_str(&format!("namespace {};\n\n", namespace));
570        if !error.doc.is_empty() {
571            out.push_str("/// <summary>\n");
572            for line in error.doc.lines() {
573                out.push_str(&format!("/// {}\n", line));
574            }
575            out.push_str("/// </summary>\n");
576        }
577        out.push_str(&format!("public class {} : Exception\n{{\n", base_name));
578        out.push_str(&format!(
579            "    public {}(string message) : base(message) {{ }}\n\n",
580            base_name
581        ));
582        out.push_str(&format!(
583            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
584            base_name
585        ));
586        out.push_str("}\n");
587        files.push((base_name.clone(), out));
588    }
589
590    // Per-variant exception classes
591    for variant in &error.variants {
592        let class_name = format!("{}Exception", variant.name);
593        let mut out = String::with_capacity(512);
594        out.push_str("// This file is auto-generated by alef. DO NOT EDIT.\nusing System;\n\n");
595        out.push_str(&format!("namespace {};\n\n", namespace));
596        if !variant.doc.is_empty() {
597            out.push_str("/// <summary>\n");
598            for line in variant.doc.lines() {
599                out.push_str(&format!("/// {}\n", line));
600            }
601            out.push_str("/// </summary>\n");
602        }
603        out.push_str(&format!("public class {} : {}\n{{\n", class_name, base_name));
604        out.push_str(&format!(
605            "    public {}(string message) : base(message) {{ }}\n\n",
606            class_name
607        ));
608        out.push_str(&format!(
609            "    public {}(string message, Exception innerException) : base(message, innerException) {{ }}\n",
610            class_name
611        ));
612        out.push_str("}\n");
613        files.push((class_name, out));
614    }
615
616    files
617}
618
619// ---------------------------------------------------------------------------
620// Helpers
621// ---------------------------------------------------------------------------
622
623/// Convert CamelCase to SCREAMING_SNAKE_CASE.
624fn to_screaming_snake(s: &str) -> String {
625    let mut result = String::with_capacity(s.len() + 4);
626    for (i, c) in s.chars().enumerate() {
627        if c.is_uppercase() {
628            if i > 0 {
629                result.push('_');
630            }
631            result.push(c.to_ascii_uppercase());
632        } else {
633            result.push(c.to_ascii_uppercase());
634        }
635    }
636    result
637}
638
639/// Generate a human-readable message for an error variant.
640///
641/// Uses the `message_template` if present, otherwise falls back to a
642/// space-separated version of the variant name (e.g. "ParseError" -> "parse error").
643fn variant_display_message(variant: &ErrorVariant) -> String {
644    if let Some(tmpl) = &variant.message_template {
645        // Strip format placeholders like {0}, {source}, etc.
646        let msg = tmpl
647            .replace("{0}", "")
648            .replace("{source}", "")
649            .trim_end_matches(": ")
650            .trim()
651            .to_string();
652        if msg.is_empty() {
653            to_snake_case(&variant.name).replace('_', " ")
654        } else {
655            msg
656        }
657    } else {
658        to_snake_case(&variant.name).replace('_', " ")
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use alef_core::ir::{ErrorDef, ErrorVariant};
666
667    use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
668
669    /// Helper to create a tuple-style field (e.g. `_0: String`).
670    fn tuple_field(index: usize) -> FieldDef {
671        FieldDef {
672            name: format!("_{index}"),
673            ty: TypeRef::String,
674            optional: false,
675            default: None,
676            doc: String::new(),
677            sanitized: false,
678            is_boxed: false,
679            type_rust_path: None,
680            cfg: None,
681            typed_default: None,
682            core_wrapper: CoreWrapper::None,
683            vec_inner_core_wrapper: CoreWrapper::None,
684            newtype_wrapper: None,
685        }
686    }
687
688    /// Helper to create a named struct field.
689    fn named_field(name: &str) -> FieldDef {
690        FieldDef {
691            name: name.to_string(),
692            ty: TypeRef::String,
693            optional: false,
694            default: None,
695            doc: String::new(),
696            sanitized: false,
697            is_boxed: false,
698            type_rust_path: None,
699            cfg: None,
700            typed_default: None,
701            core_wrapper: CoreWrapper::None,
702            vec_inner_core_wrapper: CoreWrapper::None,
703            newtype_wrapper: None,
704        }
705    }
706
707    fn sample_error() -> ErrorDef {
708        ErrorDef {
709            name: "ConversionError".to_string(),
710            rust_path: "html_to_markdown_rs::ConversionError".to_string(),
711            original_rust_path: String::new(),
712            variants: vec![
713                ErrorVariant {
714                    name: "ParseError".to_string(),
715                    message_template: Some("HTML parsing error: {0}".to_string()),
716                    fields: vec![tuple_field(0)],
717                    has_source: false,
718                    has_from: false,
719                    is_unit: false,
720                    doc: String::new(),
721                },
722                ErrorVariant {
723                    name: "IoError".to_string(),
724                    message_template: Some("I/O error: {0}".to_string()),
725                    fields: vec![tuple_field(0)],
726                    has_source: false,
727                    has_from: true,
728                    is_unit: false,
729                    doc: String::new(),
730                },
731                ErrorVariant {
732                    name: "Other".to_string(),
733                    message_template: Some("Conversion error: {0}".to_string()),
734                    fields: vec![tuple_field(0)],
735                    has_source: false,
736                    has_from: false,
737                    is_unit: false,
738                    doc: String::new(),
739                },
740            ],
741            doc: "Error type for conversion operations.".to_string(),
742        }
743    }
744
745    #[test]
746    fn test_gen_error_types() {
747        let error = sample_error();
748        let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
749        assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
750        assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
751        assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
752        assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
753    }
754
755    #[test]
756    fn test_gen_error_converter() {
757        let error = sample_error();
758        let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
759        assert!(
760            output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
761        );
762        assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
763        assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
764    }
765
766    #[test]
767    fn test_gen_error_registration() {
768        let error = sample_error();
769        let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
770        assert_eq!(regs.len(), 4); // 3 variants + 1 base
771        assert!(regs[0].contains("\"ParseError\""));
772        assert!(regs[3].contains("\"ConversionError\""));
773    }
774
775    #[test]
776    fn test_unit_variant_pattern() {
777        let error = ErrorDef {
778            name: "MyError".to_string(),
779            rust_path: "my_crate::MyError".to_string(),
780            original_rust_path: String::new(),
781            variants: vec![ErrorVariant {
782                name: "NotFound".to_string(),
783                message_template: Some("not found".to_string()),
784                fields: vec![],
785                has_source: false,
786                has_from: false,
787                is_unit: true,
788                doc: String::new(),
789            }],
790            doc: String::new(),
791        };
792        let output = gen_pyo3_error_converter(&error, "my_crate");
793        assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
794        // Ensure no (..) for unit variants
795        assert!(!output.contains("NotFound(..)"));
796    }
797
798    #[test]
799    fn test_struct_variant_pattern() {
800        let error = ErrorDef {
801            name: "MyError".to_string(),
802            rust_path: "my_crate::MyError".to_string(),
803            original_rust_path: String::new(),
804            variants: vec![ErrorVariant {
805                name: "Parsing".to_string(),
806                message_template: Some("parsing error: {message}".to_string()),
807                fields: vec![named_field("message")],
808                has_source: false,
809                has_from: false,
810                is_unit: false,
811                doc: String::new(),
812            }],
813            doc: String::new(),
814        };
815        let output = gen_pyo3_error_converter(&error, "my_crate");
816        assert!(
817            output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
818            "Struct variants must use {{ .. }} pattern, got:\n{output}"
819        );
820        // Ensure no (..) for struct variants
821        assert!(!output.contains("Parsing(..)"));
822    }
823
824    // -----------------------------------------------------------------------
825    // NAPI tests
826    // -----------------------------------------------------------------------
827
828    #[test]
829    fn test_gen_napi_error_types() {
830        let error = sample_error();
831        let output = gen_napi_error_types(&error);
832        assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
833        assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
834        assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
835    }
836
837    #[test]
838    fn test_gen_napi_error_converter() {
839        let error = sample_error();
840        let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
841        assert!(
842            output
843                .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
844        );
845        assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
846        assert!(output.contains("[ParseError]"));
847        assert!(output.contains("[IoError]"));
848        assert!(output.contains("#[allow(dead_code)]"));
849    }
850
851    #[test]
852    fn test_napi_unit_variant() {
853        let error = ErrorDef {
854            name: "MyError".to_string(),
855            rust_path: "my_crate::MyError".to_string(),
856            original_rust_path: String::new(),
857            variants: vec![ErrorVariant {
858                name: "NotFound".to_string(),
859                message_template: None,
860                fields: vec![],
861                has_source: false,
862                has_from: false,
863                is_unit: true,
864                doc: String::new(),
865            }],
866            doc: String::new(),
867        };
868        let output = gen_napi_error_converter(&error, "my_crate");
869        assert!(output.contains("my_crate::MyError::NotFound =>"));
870        assert!(!output.contains("NotFound(..)"));
871    }
872
873    // -----------------------------------------------------------------------
874    // WASM tests
875    // -----------------------------------------------------------------------
876
877    #[test]
878    fn test_gen_wasm_error_converter() {
879        let error = sample_error();
880        let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
881        // Main converter function signature
882        assert!(output.contains(
883            "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
884        ));
885        // Structured object with code + message
886        assert!(output.contains("js_sys::Object::new()"));
887        assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
888        assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
889        assert!(output.contains("obj.into()"));
890        // error_code helper
891        assert!(
892            output
893                .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
894        );
895        assert!(output.contains("\"parse_error\""));
896        assert!(output.contains("\"io_error\""));
897        assert!(output.contains("\"other\""));
898        assert!(output.contains("#[allow(dead_code)]"));
899    }
900
901    // -----------------------------------------------------------------------
902    // PHP tests
903    // -----------------------------------------------------------------------
904
905    #[test]
906    fn test_gen_php_error_converter() {
907        let error = sample_error();
908        let output = gen_php_error_converter(&error, "html_to_markdown_rs");
909        assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
910        assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
911        assert!(output.contains("#[allow(dead_code)]"));
912    }
913
914    // -----------------------------------------------------------------------
915    // Magnus tests
916    // -----------------------------------------------------------------------
917
918    #[test]
919    fn test_gen_magnus_error_converter() {
920        let error = sample_error();
921        let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
922        assert!(
923            output.contains(
924                "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
925            )
926        );
927        assert!(
928            output.contains(
929                "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
930            )
931        );
932        assert!(output.contains("#[allow(dead_code)]"));
933    }
934
935    // -----------------------------------------------------------------------
936    // Rustler tests
937    // -----------------------------------------------------------------------
938
939    #[test]
940    fn test_gen_rustler_error_converter() {
941        let error = sample_error();
942        let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
943        assert!(
944            output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
945        );
946        assert!(output.contains("e.to_string()"));
947        assert!(output.contains("#[allow(dead_code)]"));
948    }
949
950    // -----------------------------------------------------------------------
951    // Helper tests
952    // -----------------------------------------------------------------------
953
954    #[test]
955    fn test_to_screaming_snake() {
956        assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
957        assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
958        assert_eq!(to_screaming_snake("Other"), "OTHER");
959    }
960
961    // -----------------------------------------------------------------------
962    // FFI (C) tests
963    // -----------------------------------------------------------------------
964
965    #[test]
966    fn test_gen_ffi_error_codes() {
967        let error = sample_error();
968        let output = gen_ffi_error_codes(&error);
969        assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
970        assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
971        assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
972        assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
973        assert!(output.contains("conversion_error_t;"));
974        assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
975    }
976
977    // -----------------------------------------------------------------------
978    // Go tests
979    // -----------------------------------------------------------------------
980
981    #[test]
982    fn test_gen_go_error_types() {
983        let error = sample_error();
984        let output = gen_go_error_types(&error);
985        assert!(output.contains("ErrParseError = errors.New("));
986        assert!(output.contains("ErrIoError = errors.New("));
987        assert!(output.contains("ErrOther = errors.New("));
988        assert!(output.contains("type ConversionError struct {"));
989        assert!(output.contains("Code    string"));
990        assert!(output.contains("func (e *ConversionError) Error() string"));
991    }
992
993    // -----------------------------------------------------------------------
994    // Java tests
995    // -----------------------------------------------------------------------
996
997    #[test]
998    fn test_gen_java_error_types() {
999        let error = sample_error();
1000        let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1001        // base + 3 variants
1002        assert_eq!(files.len(), 4);
1003        // Base class
1004        assert_eq!(files[0].0, "ConversionErrorException");
1005        assert!(
1006            files[0]
1007                .1
1008                .contains("public class ConversionErrorException extends Exception")
1009        );
1010        assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1011        // Variant classes
1012        assert_eq!(files[1].0, "ParseErrorException");
1013        assert!(
1014            files[1]
1015                .1
1016                .contains("public class ParseErrorException extends ConversionErrorException")
1017        );
1018        assert_eq!(files[2].0, "IoErrorException");
1019        assert_eq!(files[3].0, "OtherException");
1020    }
1021
1022    // -----------------------------------------------------------------------
1023    // C# tests
1024    // -----------------------------------------------------------------------
1025
1026    #[test]
1027    fn test_gen_csharp_error_types() {
1028        let error = sample_error();
1029        let files = gen_csharp_error_types(&error, "Kreuzberg.Test");
1030        // base + 3 variants
1031        assert_eq!(files.len(), 4);
1032        // Base class
1033        assert_eq!(files[0].0, "ConversionErrorException");
1034        assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1035        assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1036        // Variant classes
1037        assert_eq!(files[1].0, "ParseErrorException");
1038        assert!(
1039            files[1]
1040                .1
1041                .contains("public class ParseErrorException : ConversionErrorException")
1042        );
1043        assert_eq!(files[2].0, "IoErrorException");
1044        assert_eq!(files[3].0, "OtherException");
1045    }
1046
1047    // -----------------------------------------------------------------------
1048    // python_exception_name tests
1049    // -----------------------------------------------------------------------
1050
1051    #[test]
1052    fn test_python_exception_name_no_conflict() {
1053        // "ParseError" already ends with "Error" and is not a builtin
1054        assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1055        // "Other" gets "Error" suffix, "OtherError" is not a builtin
1056        assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1057    }
1058
1059    #[test]
1060    fn test_python_exception_name_shadows_builtin() {
1061        // "Connection" -> "ConnectionError" shadows builtin -> prefix with "Crawl"
1062        assert_eq!(
1063            python_exception_name("Connection", "CrawlError"),
1064            "CrawlConnectionError"
1065        );
1066        // "Timeout" -> "TimeoutError" shadows builtin -> prefix with "Crawl"
1067        assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1068        // "ConnectionError" already ends with "Error", still shadows -> prefix
1069        assert_eq!(
1070            python_exception_name("ConnectionError", "CrawlError"),
1071            "CrawlConnectionError"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_python_exception_name_no_double_prefix() {
1077        // If variant is already prefixed with the error base, don't double-prefix
1078        assert_eq!(
1079            python_exception_name("CrawlConnectionError", "CrawlError"),
1080            "CrawlConnectionError"
1081        );
1082    }
1083}