1use ahash::AHashSet;
2use alef_core::ir::{ErrorDef, ErrorVariant};
3
4use crate::conversions::is_tuple_variant;
5
6fn 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
18const 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
55fn error_base_prefix(error_name: &str) -> &str {
58 error_name.strip_suffix("Error").unwrap_or(error_name)
59}
60
61pub 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 if candidate.starts_with(prefix) {
77 candidate
78 } else {
79 format!("{}{}", prefix, candidate)
80 }
81 } else {
82 candidate
83 }
84}
85
86pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
90 let mut variant_names = Vec::new();
92 for variant in &error.variants {
93 let variant_name = python_exception_name(&variant.name, &error.name);
94 if seen_exceptions.insert(variant_name.clone()) {
95 variant_names.push(variant_name);
96 }
97 }
98
99 let include_base = seen_exceptions.insert(error.name.clone());
101
102 crate::template_env::render(
103 "error_gen/pyo3_error_types.jinja",
104 minijinja::context! {
105 variant_names => variant_names,
106 module_name => module_name,
107 error_name => error.name.as_str(),
108 include_base => include_base,
109 },
110 )
111}
112
113pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
120 let rust_path = if error.rust_path.is_empty() {
121 format!("{core_import}::{}", error.name)
122 } else {
123 let normalized = error.rust_path.replace('-', "_");
124 let segments: Vec<&str> = normalized.split("::").collect();
128 if segments.len() > 2 {
129 let crate_name = segments[0];
130 let error_name = segments[segments.len() - 1];
131 format!("{crate_name}::{error_name}")
132 } else {
133 normalized
134 }
135 };
136
137 let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
138 let has_methods = !error.methods.is_empty();
139
140 let mut variants = Vec::new();
142 for variant in &error.variants {
143 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
144 let variant_exc_name = python_exception_name(&variant.name, &error.name);
145 variants.push((pattern, variant_exc_name));
146 }
147
148 crate::template_env::render(
149 "error_gen/pyo3_error_converter.jinja",
150 minijinja::context! {
151 rust_path => rust_path.as_str(),
152 fn_name => fn_name.as_str(),
153 error_name => error.name.as_str(),
154 variants => variants,
155 has_methods => has_methods,
156 },
157 )
158}
159
160pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
164 let mut registrations = Vec::with_capacity(error.variants.len() + 1);
165
166 for variant in &error.variants {
167 let variant_exc_name = python_exception_name(&variant.name, &error.name);
168 if seen_registrations.insert(variant_exc_name.clone()) {
169 registrations.push(format!(
170 " m.add(\"{}\", m.py().get_type::<{}>())?;",
171 variant_exc_name, variant_exc_name
172 ));
173 }
174 }
175
176 if seen_registrations.insert(error.name.clone()) {
178 registrations.push(format!(
179 " m.add(\"{}\", m.py().get_type::<{}>())?;",
180 error.name, error.name
181 ));
182 }
183
184 registrations
185}
186
187pub fn converter_fn_name(error: &ErrorDef) -> String {
189 format!("{}_to_py_err", to_snake_case(&error.name))
190}
191
192fn to_snake_case(s: &str) -> String {
194 let mut result = String::with_capacity(s.len() + 4);
195 for (i, c) in s.chars().enumerate() {
196 if c.is_uppercase() {
197 if i > 0 {
198 result.push('_');
199 }
200 result.push(c.to_ascii_lowercase());
201 } else {
202 result.push(c);
203 }
204 }
205 result
206}
207
208pub fn gen_napi_error_types(error: &ErrorDef) -> String {
214 let mut variants = Vec::new();
216 let error_screaming = to_screaming_snake(&error.name);
217 for variant in &error.variants {
218 let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
219 variants.push((variant_const, variant.name.clone()));
220 }
221
222 crate::template_env::render(
223 "error_gen/napi_error_types.jinja",
224 minijinja::context! {
225 variants => variants,
226 },
227 )
228}
229
230pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
232 let rust_path = if error.rust_path.is_empty() {
233 format!("{core_import}::{}", error.name)
234 } else {
235 error.rust_path.replace('-', "_")
236 };
237
238 let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
239
240 let mut variants = Vec::new();
242 for variant in &error.variants {
243 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
244 variants.push((pattern, variant.name.clone()));
245 }
246
247 crate::template_env::render(
248 "error_gen/napi_error_converter.jinja",
249 minijinja::context! {
250 rust_path => rust_path.as_str(),
251 fn_name => fn_name.as_str(),
252 variants => variants,
253 },
254 )
255}
256
257pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
259 format!("{}_to_napi_err", to_snake_case(&error.name))
260}
261
262pub 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 code_variants = Vec::new();
281 for variant in &error.variants {
282 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
283 let code = to_snake_case(&variant.name);
284 code_variants.push((pattern, code));
285 }
286 let default_code = to_snake_case(&error.name);
287
288 let code_fn = crate::template_env::render(
289 "error_gen/wasm_error_code_fn.jinja",
290 minijinja::context! {
291 rust_path => rust_path.as_str(),
292 code_fn_name => code_fn_name.as_str(),
293 variants => code_variants,
294 default_code => default_code.as_str(),
295 },
296 );
297
298 let converter_fn = crate::template_env::render(
299 "error_gen/wasm_error_converter.jinja",
300 minijinja::context! {
301 rust_path => rust_path.as_str(),
302 fn_name => fn_name.as_str(),
303 code_fn_name => code_fn_name.as_str(),
304 },
305 );
306
307 format!("{}\n\n{}", code_fn, converter_fn)
308}
309
310pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
312 format!("{}_to_js_value", to_snake_case(&error.name))
313}
314
315pub fn gen_wasm_error_methods(error: &ErrorDef, core_import: &str, wasm_prefix: &str) -> String {
329 if error.methods.is_empty() {
330 return String::new();
331 }
332
333 let rust_path = if error.rust_path.is_empty() {
334 format!("{core_import}::{}", error.name)
335 } else {
336 error.rust_path.replace('-', "_")
337 };
338
339 let wasm_struct_name = format!("{wasm_prefix}{}", error.name);
342
343 let struct_def = format!(
344 "/// Opaque WASM handle for [`{rust_path}`] that exposes introspection methods.\n\
345 #[wasm_bindgen]\n\
346 pub struct {wasm_struct_name} {{\n\
347 pub(crate) inner: {rust_path},\n\
348 }}"
349 );
350
351 let mut method_bodies = Vec::new();
352 for method in &error.methods {
353 let method_src = match method.name.as_str() {
354 "status_code" => " /// HTTP status code for this error variant.\n \
355 #[wasm_bindgen(js_name = \"statusCode\")]\n \
356 pub fn status_code(&self) -> u16 {\n \
357 self.inner.status_code()\n }"
358 .to_string(),
359 "is_transient" => " /// Returns `true` if the error is transient and a retry may succeed.\n \
360 #[wasm_bindgen(js_name = \"isTransient\")]\n \
361 pub fn is_transient(&self) -> bool {\n \
362 self.inner.is_transient()\n }"
363 .to_string(),
364 "error_type" => " /// Returns a machine-readable error category string.\n \
365 #[wasm_bindgen(js_name = \"errorType\")]\n \
366 pub fn error_type(&self) -> String {\n \
367 self.inner.error_type().to_string()\n }"
368 .to_string(),
369 other => {
370 format!(
373 " // TODO: emit binding for method `{other}` on `{wasm_struct_name}`\n \
374 #[allow(dead_code)]\n \
375 pub fn {other}(&self) {{}}"
376 )
377 }
378 };
379 method_bodies.push(method_src);
380 }
381
382 let impl_block = format!(
383 "#[wasm_bindgen]\nimpl {wasm_struct_name} {{\n{}\n}}",
384 method_bodies.join("\n\n")
385 );
386
387 format!("{struct_def}\n\n{impl_block}")
388}
389
390pub fn gen_pyo3_error_methods_impl(error: &ErrorDef) -> String {
406 if error.methods.is_empty() {
407 return String::new();
408 }
409
410 let struct_name = format!("{}Info", error.name);
411 let snake_name = to_snake_case(&error.name);
412 let fn_name = format!("{snake_name}_info");
413
414 let mut fields = Vec::new();
415 let mut getters = Vec::new();
416
417 let has_status_code = error.methods.iter().any(|m| m.name == "status_code");
418 let has_is_transient = error.methods.iter().any(|m| m.name == "is_transient");
419 let has_error_type = error.methods.iter().any(|m| m.name == "error_type");
420
421 if has_status_code {
422 fields.push(" pub status_code: u16,".to_string());
423 getters.push(
424 concat!(
425 " /// HTTP status code for this error (0 means no associated status).\n",
426 " #[getter]\n",
427 " fn status_code(&self) -> u16 {\n",
428 " self.status_code\n",
429 " }",
430 )
431 .to_string(),
432 );
433 }
434 if has_is_transient {
435 fields.push(" pub is_transient: bool,".to_string());
436 getters.push(
437 concat!(
438 " /// Returns `true` if the error is transient and a retry may succeed.\n",
439 " #[getter]\n",
440 " fn is_transient(&self) -> bool {\n",
441 " self.is_transient\n",
442 " }",
443 )
444 .to_string(),
445 );
446 }
447 if has_error_type {
448 fields.push(" pub error_type: String,".to_string());
449 getters.push(
450 concat!(
451 " /// Machine-readable error category string for matching and logging.\n",
452 " #[getter]\n",
453 " fn error_type(&self) -> String {\n",
454 " self.error_type.clone()\n",
455 " }",
456 )
457 .to_string(),
458 );
459 }
460 for method in &error.methods {
462 match method.name.as_str() {
463 "status_code" | "is_transient" | "error_type" => {}
464 other => getters.push(format!(
465 " // TODO: emit getter for method `{other}` on `{struct_name}`"
466 )),
467 }
468 }
469
470 let mut ctor_fields = Vec::new();
473 if has_status_code {
474 ctor_fields.push(
475 " status_code: args\n\
476 \x20 .as_ref()\n\
477 \x20 .and_then(|a| a.get_item(1).ok())\n\
478 \x20 .and_then(|v| v.extract::<u16>().ok())\n\
479 \x20 .unwrap_or(0),",
480 );
481 }
482 if has_is_transient {
483 ctor_fields.push(
484 " is_transient: args\n\
485 \x20 .as_ref()\n\
486 \x20 .and_then(|a| a.get_item(2).ok())\n\
487 \x20 .and_then(|v| v.extract::<bool>().ok())\n\
488 \x20 .unwrap_or(false),",
489 );
490 }
491 if has_error_type {
492 ctor_fields.push(
493 " error_type: args\n\
494 \x20 .as_ref()\n\
495 \x20 .and_then(|a| a.get_item(3).ok())\n\
496 \x20 .and_then(|v| v.extract::<String>().ok())\n\
497 \x20 .unwrap_or_default(),",
498 );
499 }
500
501 let struct_def = format!(
502 "#[pyclass(name = \"{struct_name}\")]\npub struct {struct_name} {{\n{}\n}}",
503 fields.join("\n")
504 );
505
506 let impl_block = format!("#[pymethods]\nimpl {struct_name} {{\n{}\n}}", getters.join("\n\n"));
507
508 let free_fn = format!(
509 "/// Build a `{struct_name}` from any exception raised by the `{error_name}` hierarchy.\n\
510 ///\n\
511 /// The converter stores `(message, status_code, is_transient, error_type)` in the\n\
512 /// exception args tuple; this function extracts those values at indices 1–3.\n\
513 #[pyfunction]\n\
514 pub fn {fn_name}(err: pyo3::Bound<'_, pyo3::types::PyAny>) -> {struct_name} {{\n\
515 let args = err.getattr(\"args\").ok();\n\
516 {struct_name} {{\n\
517 {ctor}\n\
518 }}\n\
519 }}",
520 error_name = error.name,
521 ctor = ctor_fields.join("\n"),
522 );
523
524 format!("{struct_def}\n\n{impl_block}\n\n{free_fn}")
525}
526
527pub fn pyo3_error_has_methods(error: &ErrorDef) -> bool {
530 !error.methods.is_empty()
531}
532
533pub fn pyo3_error_info_struct_name(error: &ErrorDef) -> String {
535 format!("{}Info", error.name)
536}
537
538pub fn pyo3_error_info_fn_name(error: &ErrorDef) -> String {
540 format!("{}_info", to_snake_case(&error.name))
541}
542
543pub fn gen_napi_error_class(error: &ErrorDef, core_import: &str) -> String {
552 if error.methods.is_empty() {
553 return String::new();
554 }
555
556 let rust_path = if error.rust_path.is_empty() {
557 format!("{core_import}::{}", error.name)
558 } else {
559 error.rust_path.replace('-', "_")
560 };
561
562 let struct_name = format!("Js{}Info", error.name);
563
564 let mut fields = Vec::new();
565 let mut methods = Vec::new();
566 let mut ctor_assignments = Vec::new();
567
568 for method in &error.methods {
569 match method.name.as_str() {
570 "status_code" => {
571 fields.push(" pub status_code: u16,".to_string());
572 methods.push(
573 concat!(
574 " /// HTTP status code for this error (0 means no associated status).\n",
575 " #[napi(js_name = \"statusCode\")]\n",
576 " pub fn status_code(&self) -> u16 {\n",
577 " self.status_code\n",
578 " }",
579 )
580 .to_string(),
581 );
582 ctor_assignments.push(" status_code: e.status_code(),".to_string());
583 }
584 "is_transient" => {
585 fields.push(" pub is_transient: bool,".to_string());
586 methods.push(
587 concat!(
588 " /// Returns `true` if the error is transient and a retry may succeed.\n",
589 " #[napi(js_name = \"isTransient\")]\n",
590 " pub fn is_transient(&self) -> bool {\n",
591 " self.is_transient\n",
592 " }",
593 )
594 .to_string(),
595 );
596 ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
597 }
598 "error_type" => {
599 fields.push(" pub error_type: String,".to_string());
600 methods.push(
601 concat!(
602 " /// Machine-readable error category string for matching and logging.\n",
603 " #[napi(js_name = \"errorType\")]\n",
604 " pub fn error_type(&self) -> String {\n",
605 " self.error_type.clone()\n",
606 " }",
607 )
608 .to_string(),
609 );
610 ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
611 }
612 other => {
613 methods.push(format!(" // TODO: emit #[napi] method `{other}` on `{struct_name}`"));
614 }
615 }
616 }
617
618 let struct_def = format!("#[napi]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
619
620 let from_fn = format!(
621 "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
622 ctor_assignments.join("\n"),
623 snake_name = to_snake_case(&error.name),
624 );
625
626 let impl_block = format!("#[napi]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
627
628 format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
629}
630
631pub fn gen_magnus_error_methods_struct(error: &ErrorDef, core_import: &str) -> String {
640 if error.methods.is_empty() {
641 return String::new();
642 }
643
644 let rust_path = if error.rust_path.is_empty() {
645 format!("{core_import}::{}", error.name)
646 } else {
647 error.rust_path.replace('-', "_")
648 };
649
650 let struct_name = format!("{}Info", error.name);
651
652 let mut fields = Vec::new();
653 let mut methods = Vec::new();
654 let mut ctor_assignments = Vec::new();
655
656 for method in &error.methods {
657 match method.name.as_str() {
658 "status_code" => {
659 fields.push(" status_code: u16,".to_string());
660 methods.push(
661 concat!(
662 " /// HTTP status code for this error (0 means no associated status).\n",
663 " pub fn status_code(&self) -> u16 {\n",
664 " self.status_code\n",
665 " }",
666 )
667 .to_string(),
668 );
669 ctor_assignments.push(" status_code: e.status_code(),".to_string());
670 }
671 "is_transient" => {
672 fields.push(" is_transient: bool,".to_string());
673 methods.push(
674 concat!(
675 " /// Returns `true` if the error is transient and a retry may succeed.\n",
676 " pub fn transient(&self) -> bool {\n",
677 " self.is_transient\n",
678 " }",
679 )
680 .to_string(),
681 );
682 ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
683 }
684 "error_type" => {
685 fields.push(" error_type: String,".to_string());
686 methods.push(
687 concat!(
688 " /// Machine-readable error category string for matching and logging.\n",
689 " pub fn error_type(&self) -> String {\n",
690 " self.error_type.clone()\n",
691 " }",
692 )
693 .to_string(),
694 );
695 ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
696 }
697 other => {
698 methods.push(format!(" // TODO: emit method `{other}` on `{struct_name}`"));
699 }
700 }
701 }
702
703 let struct_def = format!(
704 "#[magnus::wrap(class = \"{struct_name}\", free_immediately, size)]\npub struct {struct_name} {{\n{}\n}}",
705 fields.join("\n")
706 );
707
708 let from_fn = format!(
709 "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
710 ctor_assignments.join("\n"),
711 snake_name = to_snake_case(&error.name),
712 );
713
714 let impl_block = format!("impl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
715
716 format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
717}
718
719pub fn magnus_error_methods_registrations(error: &ErrorDef) -> Vec<String> {
721 if error.methods.is_empty() {
722 return Vec::new();
723 }
724 let struct_name = format!("{}Info", error.name);
725 let snake = to_snake_case(&error.name);
726 let class_var = format!("{snake}_info_class");
727 let mut lines = Vec::new();
728 lines.push(format!(
729 " let {class_var} = module.define_class(\"{struct_name}\", ruby.class_object())?;"
730 ));
731 for method in &error.methods {
732 let (ruby_name, rust_fn) = if method.name == "is_transient" {
733 ("transient?".to_string(), "transient".to_string())
734 } else {
735 (method.name.clone(), method.name.clone())
736 };
737 lines.push(format!(
738 " {class_var}.define_method(\"{ruby_name}\", magnus::method!({struct_name}::{rust_fn}, 0))?;"
739 ));
740 }
741 lines
742}
743
744pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
750 let rust_path = if error.rust_path.is_empty() {
751 format!("{core_import}::{}", error.name)
752 } else {
753 error.rust_path.replace('-', "_")
754 };
755
756 let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
757
758 let mut variants = Vec::new();
760 for variant in &error.variants {
761 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
762 variants.push((pattern, variant.name.clone()));
763 }
764
765 crate::template_env::render(
766 "error_gen/php_error_converter.jinja",
767 minijinja::context! {
768 rust_path => rust_path.as_str(),
769 fn_name => fn_name.as_str(),
770 variants => variants,
771 },
772 )
773}
774
775pub fn php_converter_fn_name(error: &ErrorDef) -> String {
777 format!("{}_to_php_err", to_snake_case(&error.name))
778}
779
780pub fn gen_php_error_methods_impl(error: &ErrorDef, core_import: &str) -> String {
786 if error.methods.is_empty() {
787 return String::new();
788 }
789
790 let rust_path = if error.rust_path.is_empty() {
791 format!("{core_import}::{}", error.name)
792 } else {
793 error.rust_path.replace('-', "_")
794 };
795
796 let struct_name = format!("{}Info", error.name);
797
798 let mut fields = Vec::new();
799 let mut methods = Vec::new();
800 let mut ctor_assignments = Vec::new();
801
802 for method in &error.methods {
803 match method.name.as_str() {
804 "status_code" => {
805 fields.push(" pub status_code: u16,".to_string());
806 methods.push(
807 concat!(
808 " /// HTTP status code for this error (0 means no associated status).\n",
809 " pub fn status_code(&self) -> u16 {\n",
810 " self.status_code\n",
811 " }",
812 )
813 .to_string(),
814 );
815 ctor_assignments.push(" status_code: e.status_code(),".to_string());
816 }
817 "is_transient" => {
818 fields.push(" pub is_transient: bool,".to_string());
819 methods.push(
820 concat!(
821 " /// Returns `true` if the error is transient and a retry may succeed.\n",
822 " pub fn is_transient(&self) -> bool {\n",
823 " self.is_transient\n",
824 " }",
825 )
826 .to_string(),
827 );
828 ctor_assignments.push(" is_transient: e.is_transient(),".to_string());
829 }
830 "error_type" => {
831 fields.push(" pub error_type: String,".to_string());
832 methods.push(
833 concat!(
834 " /// Machine-readable error category string for matching and logging.\n",
835 " pub fn error_type(&self) -> String {\n",
836 " self.error_type.clone()\n",
837 " }",
838 )
839 .to_string(),
840 );
841 ctor_assignments.push(" error_type: e.error_type().to_string(),".to_string());
842 }
843 other => {
844 methods.push(format!(" // TODO: emit method for `{other}` on `{struct_name}`"));
845 }
846 }
847 }
848
849 let struct_def = format!("#[php_class]\npub struct {struct_name} {{\n{}\n}}", fields.join("\n"));
850
851 let from_fn = format!(
852 "#[allow(dead_code)]\nfn {snake_name}_info(e: &{rust_path}) -> {struct_name} {{\n {struct_name} {{\n{}\n }}\n}}",
853 ctor_assignments.join("\n"),
854 snake_name = to_snake_case(&error.name),
855 );
856
857 let impl_block = format!("#[php_impl]\nimpl {struct_name} {{\n{}\n}}", methods.join("\n\n"));
858
859 format!("{struct_def}\n\n{from_fn}\n\n{impl_block}")
860}
861
862pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
868 let rust_path = if error.rust_path.is_empty() {
869 format!("{core_import}::{}", error.name)
870 } else {
871 error.rust_path.replace('-', "_")
872 };
873
874 let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
875
876 crate::template_env::render(
877 "error_gen/magnus_error_converter.jinja",
878 minijinja::context! {
879 rust_path => rust_path.as_str(),
880 fn_name => fn_name.as_str(),
881 },
882 )
883}
884
885pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
887 format!("{}_to_magnus_err", to_snake_case(&error.name))
888}
889
890pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
896 let rust_path = if error.rust_path.is_empty() {
897 format!("{core_import}::{}", error.name)
898 } else {
899 error.rust_path.replace('-', "_")
900 };
901
902 let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
903
904 crate::template_env::render(
905 "error_gen/rustler_error_converter.jinja",
906 minijinja::context! {
907 rust_path => rust_path.as_str(),
908 fn_name => fn_name.as_str(),
909 },
910 )
911}
912
913pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
915 format!("{}_to_rustler_err", to_snake_case(&error.name))
916}
917
918pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
927 let prefix = to_screaming_snake(&error.name);
928 let prefix_lower = to_snake_case(&error.name);
929
930 let mut variant_variants = Vec::new();
932 for (i, variant) in error.variants.iter().enumerate() {
933 let variant_screaming = to_screaming_snake(&variant.name);
934 variant_variants.push((variant_screaming, (i + 1).to_string()));
935 }
936
937 crate::template_env::render(
938 "error_gen/ffi_error_codes.jinja",
939 minijinja::context! {
940 error_name => error.name.as_str(),
941 prefix => prefix.as_str(),
942 prefix_lower => prefix_lower.as_str(),
943 variant_variants => variant_variants,
944 },
945 )
946}
947
948pub fn gen_ffi_error_methods(error: &ErrorDef, core_import: &str, api_prefix: &str) -> String {
959 if error.methods.is_empty() {
960 return String::new();
961 }
962
963 let rust_path = if error.rust_path.is_empty() {
964 format!("{core_import}::{}", error.name)
965 } else {
966 error.rust_path.replace('-', "_")
967 };
968
969 let error_snake = to_snake_case(&error.name);
970 let mut items: Vec<String> = Vec::new();
971
972 for method in &error.methods {
973 match method.name.as_str() {
974 "status_code" => {
975 let fn_name = format!("{api_prefix}_{error_snake}_status_code");
976 items.push(format!(
977 "/// Return the HTTP status code for the error pointed to by `err`.\n\
978 /// Returns `0` if `err` is null.\n\
979 #[no_mangle]\n\
980 pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> u16 {{\n\
981 // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
982 // allocated by this library, or is null.\n\
983 if err.is_null() {{\n\
984 return 0;\n\
985 }}\n\
986 (*err).status_code()\n\
987 }}"
988 ));
989 }
990 "is_transient" => {
991 let fn_name = format!("{api_prefix}_{error_snake}_is_transient");
992 items.push(format!(
993 "/// Return whether the error pointed to by `err` is transient.\n\
994 /// Returns `false` if `err` is null.\n\
995 #[no_mangle]\n\
996 pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> bool {{\n\
997 // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
998 // allocated by this library, or is null.\n\
999 if err.is_null() {{\n\
1000 return false;\n\
1001 }}\n\
1002 (*err).is_transient()\n\
1003 }}"
1004 ));
1005 }
1006 "error_type" => {
1007 let fn_name = format!("{api_prefix}_{error_snake}_error_type");
1008 let free_fn_name = format!("{fn_name}_free");
1009 items.push(format!(
1010 "/// Return the machine-readable error category string for the error pointed\n\
1011 /// to by `err` as a heap-allocated, NUL-terminated C string.\n\
1012 /// The caller must free the returned pointer with `{free_fn_name}`.\n\
1013 /// Returns a null pointer if `err` is null.\n\
1014 #[no_mangle]\n\
1015 pub unsafe extern \"C\" fn {fn_name}(err: *const {rust_path}) -> *mut std::ffi::c_char {{\n\
1016 // SAFETY: caller guarantees `err` points to a live `{rust_path}` value\n\
1017 // allocated by this library, or is null.\n\
1018 if err.is_null() {{\n\
1019 return std::ptr::null_mut();\n\
1020 }}\n\
1021 let s = (*err).error_type();\n\
1022 // SAFETY: `error_type()` returns a `'static str` containing no NUL bytes.\n\
1023 std::ffi::CString::new(s)\n\
1024 .map(|c| c.into_raw())\n\
1025 .unwrap_or(std::ptr::null_mut())\n\
1026 }}\n\n\
1027 /// Free a string previously returned by `{fn_name}`.\n\
1028 /// Passing a null pointer is a no-op.\n\
1029 #[no_mangle]\n\
1030 pub unsafe extern \"C\" fn {free_fn_name}(ptr: *mut std::ffi::c_char) {{\n\
1031 // SAFETY: `ptr` was allocated by `CString::into_raw` inside\n\
1032 // `{fn_name}` and is now being reclaimed by the matching\n\
1033 // `CString::from_raw`. Passing null is explicitly allowed.\n\
1034 if !ptr.is_null() {{\n\
1035 drop(std::ffi::CString::from_raw(ptr));\n\
1036 }}\n\
1037 }}"
1038 ));
1039 }
1040 other => {
1041 items.push(format!(
1043 "// TODO: emit FFI helper for method `{other}` on `{rust_path}`"
1044 ));
1045 }
1046 }
1047 }
1048
1049 items.join("\n\n")
1050}
1051
1052pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
1063 let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
1064 let structured = gen_go_error_struct(error, pkg_name);
1065 format!("{}\n\n{}", sentinels, structured)
1066}
1067
1068pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
1079 if errors.is_empty() {
1080 return String::new();
1081 }
1082 let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1083 for err in errors {
1084 for v in &err.variants {
1085 *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
1086 }
1087 }
1088 let mut seen = std::collections::HashSet::new();
1089 let mut sentinels = Vec::new();
1090 for err in errors {
1091 let parent_base = error_base_prefix(&err.name);
1092 for variant in &err.variants {
1093 let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
1094 let const_name = if collides {
1095 format!("Err{}{}", parent_base, variant.name)
1096 } else {
1097 format!("Err{}", variant.name)
1098 };
1099 if !seen.insert(const_name.clone()) {
1100 continue;
1101 }
1102 let msg = variant_display_message(variant);
1103 sentinels.push((const_name, msg));
1104 }
1105 }
1106
1107 crate::template_env::render(
1108 "error_gen/go_sentinel_errors.jinja",
1109 minijinja::context! {
1110 sentinels => sentinels,
1111 },
1112 )
1113}
1114
1115pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
1123 let go_type_name = strip_package_prefix(&error.name, pkg_name);
1124
1125 let methods: Vec<serde_json::Value> = error
1131 .methods
1132 .iter()
1133 .map(|m| {
1134 let go_type = typeref_to_go_type(&m.return_type);
1135 let method_name = to_pascal_case(&m.name);
1136 let doc_summary = if m.doc.is_empty() {
1141 String::new()
1142 } else {
1143 let first = crate::doc_emission::doc_first_paragraph_joined(&m.doc);
1144 first.trim_end_matches('.').trim_end().to_string()
1145 };
1146 serde_json::json!({
1147 "field_name": method_name,
1148 "go_type": go_type,
1149 "method_name": method_name,
1150 "doc": doc_summary,
1151 })
1152 })
1153 .collect();
1154 let has_methods = !methods.is_empty();
1155
1156 crate::template_env::render(
1157 "error_gen/go_error_struct.jinja",
1158 minijinja::context! {
1159 go_type_name => go_type_name.as_str(),
1160 methods => methods,
1161 has_methods => has_methods,
1162 },
1163 )
1164}
1165
1166fn typeref_to_go_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1170 use alef_core::ir::{PrimitiveType, TypeRef};
1171 match ty {
1172 TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1173 TypeRef::Primitive(PrimitiveType::U8) => "uint8",
1174 TypeRef::Primitive(PrimitiveType::U16) => "uint16",
1175 TypeRef::Primitive(PrimitiveType::U32) => "uint32",
1176 TypeRef::Primitive(PrimitiveType::U64) => "uint64",
1177 TypeRef::Primitive(PrimitiveType::I8) => "int8",
1178 TypeRef::Primitive(PrimitiveType::I16) => "int16",
1179 TypeRef::Primitive(PrimitiveType::I32) => "int32",
1180 TypeRef::Primitive(PrimitiveType::I64) => "int64",
1181 TypeRef::Primitive(PrimitiveType::F32) => "float32",
1182 TypeRef::Primitive(PrimitiveType::F64) => "float64",
1183 TypeRef::String => "string",
1184 _ => "string",
1185 }
1186}
1187
1188fn to_pascal_case(s: &str) -> String {
1190 s.split('_')
1191 .map(|word| {
1192 let mut chars = word.chars();
1193 match chars.next() {
1194 None => String::new(),
1195 Some(first) => first.to_uppercase().to_string() + chars.as_str(),
1196 }
1197 })
1198 .collect()
1199}
1200
1201fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
1213 let type_lower = type_name.to_lowercase();
1214 let pkg_lower = pkg_name.to_lowercase();
1215 if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
1216 type_name[pkg_lower.len()..].to_string()
1218 } else {
1219 type_name.to_string()
1220 }
1221}
1222
1223pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
1237 let mut files = Vec::with_capacity(error.variants.len() + 1);
1238
1239 let base_name = format!("{}Exception", error.name);
1241 let doc_lines: Vec<&str> = error.doc.lines().collect();
1242
1243 let method_infos: Vec<serde_json::Value> = error
1246 .methods
1247 .iter()
1248 .map(|m| {
1249 let java_type = typeref_to_java_type(&m.return_type);
1250 let getter_name = java_getter_name(&m.name);
1251 let field_name = java_field_name(&m.name);
1252 let default_value = java_default_value(&m.return_type);
1253 serde_json::json!({
1254 "field_name": field_name,
1255 "java_type": java_type,
1256 "getter_name": getter_name,
1257 "default_value": default_value,
1258 "doc": m.doc,
1259 })
1260 })
1261 .collect();
1262 let has_methods = !method_infos.is_empty();
1263
1264 let base = crate::template_env::render(
1265 "error_gen/java_error_base.jinja",
1266 minijinja::context! {
1267 package => package,
1268 base_name => base_name.as_str(),
1269 doc => !error.doc.is_empty(),
1270 doc_lines => doc_lines,
1271 methods => method_infos,
1272 has_methods => has_methods,
1273 },
1274 );
1275 files.push((base_name.clone(), base));
1276
1277 for variant in &error.variants {
1279 let class_name = format!("{}Exception", variant.name);
1280 let doc_lines: Vec<&str> = variant.doc.lines().collect();
1281
1282 let content = crate::template_env::render(
1283 "error_gen/java_error_variant.jinja",
1284 minijinja::context! {
1285 package => package,
1286 class_name => class_name.as_str(),
1287 base_name => base_name.as_str(),
1288 doc => !variant.doc.is_empty(),
1289 doc_lines => doc_lines,
1290 has_methods => has_methods,
1291 },
1292 );
1293 files.push((class_name, content));
1294 }
1295
1296 files
1297}
1298
1299fn typeref_to_java_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1301 use alef_core::ir::{PrimitiveType, TypeRef};
1302 match ty {
1303 TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
1304 TypeRef::Primitive(
1305 PrimitiveType::U8
1306 | PrimitiveType::I8
1307 | PrimitiveType::I16
1308 | PrimitiveType::U16
1309 | PrimitiveType::I32
1310 | PrimitiveType::U32,
1311 ) => "int",
1312 TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
1313 TypeRef::Primitive(PrimitiveType::F32) => "float",
1314 TypeRef::Primitive(PrimitiveType::F64) => "double",
1315 TypeRef::String => "String",
1316 _ => "String",
1317 }
1318}
1319
1320fn java_getter_name(snake: &str) -> String {
1323 if let Some(rest) = snake.strip_prefix("is_") {
1324 let pascal = to_pascal_case(rest);
1326 format!("is{pascal}")
1327 } else {
1328 let pascal = to_pascal_case(snake);
1330 format!("get{pascal}")
1331 }
1332}
1333
1334fn java_field_name(snake: &str) -> String {
1337 let parts: Vec<&str> = snake.split('_').collect();
1338 if parts.is_empty() {
1339 return snake.to_string();
1340 }
1341 let mut out = parts[0].to_string();
1342 for part in &parts[1..] {
1343 let mut chars = part.chars();
1344 match chars.next() {
1345 None => {}
1346 Some(first) => {
1347 out.push_str(&first.to_uppercase().to_string());
1348 out.push_str(chars.as_str());
1349 }
1350 }
1351 }
1352 out
1353}
1354
1355fn java_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1357 use alef_core::ir::{PrimitiveType, TypeRef};
1358 match ty {
1359 TypeRef::Primitive(PrimitiveType::Bool) => "false",
1360 TypeRef::String => "\"\"",
1361 _ => "0",
1362 }
1363}
1364
1365pub fn gen_csharp_error_types(
1383 error: &ErrorDef,
1384 namespace: &str,
1385 fallback_class: Option<&str>,
1386) -> Vec<(String, String)> {
1387 let mut files = Vec::with_capacity(error.variants.len() + 1);
1388
1389 let base_name = format!("{}Exception", error.name);
1390 let base_parent = fallback_class.unwrap_or("Exception");
1393 let error_doc_lines: Vec<&str> = error.doc.lines().collect();
1394
1395 let method_infos: Vec<serde_json::Value> = error
1398 .methods
1399 .iter()
1400 .map(|m| {
1401 let cs_type = typeref_to_csharp_type(&m.return_type);
1402 let prop_name = to_pascal_case(&m.name);
1403 let param_name = java_field_name(&m.name); let default_value = csharp_default_value(&m.return_type);
1405 serde_json::json!({
1406 "prop_name": prop_name,
1407 "cs_type": cs_type,
1408 "param_name": param_name,
1409 "default_value": default_value,
1410 "doc": m.doc,
1411 })
1412 })
1413 .collect();
1414 let has_methods = !method_infos.is_empty();
1415
1416 {
1418 let out = crate::template_env::render(
1419 "error_gen/csharp_error_base.jinja",
1420 minijinja::context! {
1421 namespace => namespace,
1422 base_name => base_name.as_str(),
1423 base_parent => base_parent,
1424 doc => !error.doc.is_empty(),
1425 doc_lines => error_doc_lines,
1426 methods => method_infos,
1427 has_methods => has_methods,
1428 },
1429 );
1430 files.push((base_name.clone(), out));
1431 }
1432
1433 for variant in &error.variants {
1435 let class_name = format!("{}Exception", variant.name);
1436 let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
1437
1438 let out = crate::template_env::render(
1439 "error_gen/csharp_error_variant.jinja",
1440 minijinja::context! {
1441 namespace => namespace,
1442 class_name => class_name.as_str(),
1443 base_name => base_name.as_str(),
1444 doc => !variant.doc.is_empty(),
1445 doc_lines => variant_doc_lines,
1446 has_methods => has_methods,
1447 },
1448 );
1449 files.push((class_name, out));
1450 }
1451
1452 files
1453}
1454
1455fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1457 use alef_core::ir::{PrimitiveType, TypeRef};
1458 match ty {
1459 TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1460 TypeRef::Primitive(PrimitiveType::U8) => "byte",
1461 TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
1462 TypeRef::Primitive(PrimitiveType::I16) => "short",
1463 TypeRef::Primitive(PrimitiveType::U16) => "ushort",
1464 TypeRef::Primitive(PrimitiveType::I32) => "int",
1465 TypeRef::Primitive(PrimitiveType::U32) => "uint",
1466 TypeRef::Primitive(PrimitiveType::I64) => "long",
1467 TypeRef::Primitive(PrimitiveType::U64) => "ulong",
1468 TypeRef::Primitive(PrimitiveType::F32) => "float",
1469 TypeRef::Primitive(PrimitiveType::F64) => "double",
1470 TypeRef::String => "string",
1471 _ => "string",
1472 }
1473}
1474
1475fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1477 use alef_core::ir::{PrimitiveType, TypeRef};
1478 match ty {
1479 TypeRef::Primitive(PrimitiveType::Bool) => "false",
1480 TypeRef::String => "string.Empty",
1481 _ => "0",
1482 }
1483}
1484
1485fn to_screaming_snake(s: &str) -> String {
1491 let mut result = String::with_capacity(s.len() + 4);
1492 for (i, c) in s.chars().enumerate() {
1493 if c.is_uppercase() {
1494 if i > 0 {
1495 result.push('_');
1496 }
1497 result.push(c.to_ascii_uppercase());
1498 } else {
1499 result.push(c.to_ascii_uppercase());
1500 }
1501 }
1502 result
1503}
1504
1505const TECHNICAL_ACRONYMS: &[&str] = &[
1512 "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
1513 "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
1514 "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
1515 "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
1516];
1517
1518pub fn strip_thiserror_placeholders(template: &str) -> String {
1532 let mut without_placeholders = String::with_capacity(template.len());
1534 let mut depth = 0u32;
1535 for ch in template.chars() {
1536 match ch {
1537 '{' => depth = depth.saturating_add(1),
1538 '}' => depth = depth.saturating_sub(1),
1539 other if depth == 0 => without_placeholders.push(other),
1540 _ => {}
1541 }
1542 }
1543 let mut compacted = String::with_capacity(without_placeholders.len());
1547 let mut last_was_space = false;
1548 for ch in without_placeholders.chars() {
1549 if ch.is_whitespace() {
1550 if !last_was_space && !compacted.is_empty() {
1551 compacted.push(' ');
1552 }
1553 last_was_space = true;
1554 } else {
1555 compacted.push(ch);
1556 last_was_space = false;
1557 }
1558 }
1559 let trimmed = compacted
1561 .trim()
1562 .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
1563 .trim();
1564 let cleaned = trimmed
1567 .replace("()", "")
1568 .replace("''", "")
1569 .replace("\"\"", "")
1570 .replace(" ", " ");
1571 cleaned.trim().to_string()
1572}
1573
1574pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
1584 if variant_name.is_empty() {
1585 return String::new();
1586 }
1587 let bytes = variant_name.as_bytes();
1589 let mut words: Vec<&str> = Vec::new();
1590 let mut start = 0usize;
1591 for i in 1..bytes.len() {
1592 if bytes[i].is_ascii_uppercase() {
1593 words.push(&variant_name[start..i]);
1594 start = i;
1595 }
1596 }
1597 words.push(&variant_name[start..]);
1598
1599 let mut rendered: Vec<String> = Vec::with_capacity(words.len());
1600 for word in &words {
1601 let upper = word.to_ascii_uppercase();
1602 if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
1603 rendered.push(upper);
1604 } else {
1605 rendered.push(word.to_ascii_lowercase());
1606 }
1607 }
1608 rendered.join(" ")
1609}
1610
1611fn variant_display_message(variant: &ErrorVariant) -> String {
1616 if let Some(tmpl) = &variant.message_template {
1617 let stripped = strip_thiserror_placeholders(tmpl);
1618 if stripped.is_empty() {
1619 return acronym_aware_snake_phrase(&variant.name);
1620 }
1621 let mut tokens = stripped.splitn(2, ' ');
1626 let head = tokens.next().unwrap_or("").to_string();
1627 let tail = tokens.next().unwrap_or("");
1628 let head_upper = head.to_ascii_uppercase();
1629 let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
1630 head_upper
1631 } else {
1632 let mut chars = head.chars();
1633 match chars.next() {
1634 Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1635 None => head,
1636 }
1637 };
1638 if tail.is_empty() {
1639 head_rendered
1640 } else {
1641 format!("{} {}", head_rendered, tail)
1642 }
1643 } else {
1644 acronym_aware_snake_phrase(&variant.name)
1645 }
1646}
1647
1648#[cfg(test)]
1649mod tests {
1650 use super::*;
1651 use alef_core::ir::{ErrorDef, ErrorVariant};
1652
1653 use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
1654
1655 fn tuple_field(index: usize) -> FieldDef {
1657 FieldDef {
1658 name: format!("_{index}"),
1659 ty: TypeRef::String,
1660 optional: false,
1661 default: None,
1662 doc: String::new(),
1663 sanitized: false,
1664 is_boxed: false,
1665 type_rust_path: None,
1666 cfg: None,
1667 typed_default: None,
1668 core_wrapper: CoreWrapper::None,
1669 vec_inner_core_wrapper: CoreWrapper::None,
1670 newtype_wrapper: None,
1671 serde_rename: None,
1672 serde_flatten: false,
1673 binding_excluded: false,
1674 binding_exclusion_reason: None,
1675 original_type: None,
1676 }
1677 }
1678
1679 fn named_field(name: &str) -> FieldDef {
1681 FieldDef {
1682 name: name.to_string(),
1683 ty: TypeRef::String,
1684 optional: false,
1685 default: None,
1686 doc: String::new(),
1687 sanitized: false,
1688 is_boxed: false,
1689 type_rust_path: None,
1690 cfg: None,
1691 typed_default: None,
1692 core_wrapper: CoreWrapper::None,
1693 vec_inner_core_wrapper: CoreWrapper::None,
1694 newtype_wrapper: None,
1695 serde_rename: None,
1696 serde_flatten: false,
1697 binding_excluded: false,
1698 binding_exclusion_reason: None,
1699 original_type: None,
1700 }
1701 }
1702
1703 fn sample_error() -> ErrorDef {
1704 ErrorDef {
1705 name: "ConversionError".to_string(),
1706 rust_path: "html_to_markdown_rs::ConversionError".to_string(),
1707 original_rust_path: String::new(),
1708 variants: vec![
1709 ErrorVariant {
1710 name: "ParseError".to_string(),
1711 message_template: Some("HTML parsing error: {0}".to_string()),
1712 fields: vec![tuple_field(0)],
1713 has_source: false,
1714 has_from: false,
1715 is_unit: false,
1716 doc: String::new(),
1717 },
1718 ErrorVariant {
1719 name: "IoError".to_string(),
1720 message_template: Some("I/O error: {0}".to_string()),
1721 fields: vec![tuple_field(0)],
1722 has_source: false,
1723 has_from: true,
1724 is_unit: false,
1725 doc: String::new(),
1726 },
1727 ErrorVariant {
1728 name: "Other".to_string(),
1729 message_template: Some("Conversion error: {0}".to_string()),
1730 fields: vec![tuple_field(0)],
1731 has_source: false,
1732 has_from: false,
1733 is_unit: false,
1734 doc: String::new(),
1735 },
1736 ],
1737 doc: "Error type for conversion operations.".to_string(),
1738 methods: vec![],
1739 binding_excluded: false,
1740 binding_exclusion_reason: None,
1741 }
1742 }
1743
1744 #[test]
1745 fn test_gen_error_types() {
1746 let error = sample_error();
1747 let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
1748 assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
1749 assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
1750 assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
1751 assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
1752 }
1753
1754 #[test]
1755 fn test_gen_error_converter() {
1756 let error = sample_error();
1757 let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
1758 assert!(
1759 output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
1760 );
1761 assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
1762 assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
1763 }
1764
1765 #[test]
1766 fn test_gen_error_registration() {
1767 let error = sample_error();
1768 let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
1769 assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
1771 assert!(regs[3].contains("\"ConversionError\""));
1772 }
1773
1774 #[test]
1775 fn test_unit_variant_pattern() {
1776 let error = ErrorDef {
1777 name: "MyError".to_string(),
1778 rust_path: "my_crate::MyError".to_string(),
1779 original_rust_path: String::new(),
1780 variants: vec![ErrorVariant {
1781 name: "NotFound".to_string(),
1782 message_template: Some("not found".to_string()),
1783 fields: vec![],
1784 has_source: false,
1785 has_from: false,
1786 is_unit: true,
1787 doc: String::new(),
1788 }],
1789 doc: String::new(),
1790 methods: vec![],
1791 binding_excluded: false,
1792 binding_exclusion_reason: None,
1793 };
1794 let output = gen_pyo3_error_converter(&error, "my_crate");
1795 assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
1796 assert!(!output.contains("NotFound(..)"));
1798 }
1799
1800 #[test]
1801 fn test_struct_variant_pattern() {
1802 let error = ErrorDef {
1803 name: "MyError".to_string(),
1804 rust_path: "my_crate::MyError".to_string(),
1805 original_rust_path: String::new(),
1806 variants: vec![ErrorVariant {
1807 name: "Parsing".to_string(),
1808 message_template: Some("parsing error: {message}".to_string()),
1809 fields: vec![named_field("message")],
1810 has_source: false,
1811 has_from: false,
1812 is_unit: false,
1813 doc: String::new(),
1814 }],
1815 doc: String::new(),
1816 methods: vec![],
1817 binding_excluded: false,
1818 binding_exclusion_reason: None,
1819 };
1820 let output = gen_pyo3_error_converter(&error, "my_crate");
1821 assert!(
1822 output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1823 "Struct variants must use {{ .. }} pattern, got:\n{output}"
1824 );
1825 assert!(!output.contains("Parsing(..)"));
1827 }
1828
1829 #[test]
1834 fn test_gen_napi_error_types() {
1835 let error = sample_error();
1836 let output = gen_napi_error_types(&error);
1837 assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1838 assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1839 assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1840 }
1841
1842 #[test]
1843 fn test_gen_napi_error_converter() {
1844 let error = sample_error();
1845 let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1846 assert!(
1847 output
1848 .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1849 );
1850 assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1851 assert!(output.contains("[ParseError]"));
1852 assert!(output.contains("[IoError]"));
1853 assert!(output.contains("#[allow(dead_code)]"));
1854 }
1855
1856 #[test]
1857 fn test_napi_unit_variant() {
1858 let error = ErrorDef {
1859 name: "MyError".to_string(),
1860 rust_path: "my_crate::MyError".to_string(),
1861 original_rust_path: String::new(),
1862 variants: vec![ErrorVariant {
1863 name: "NotFound".to_string(),
1864 message_template: None,
1865 fields: vec![],
1866 has_source: false,
1867 has_from: false,
1868 is_unit: true,
1869 doc: String::new(),
1870 }],
1871 doc: String::new(),
1872 methods: vec![],
1873 binding_excluded: false,
1874 binding_exclusion_reason: None,
1875 };
1876 let output = gen_napi_error_converter(&error, "my_crate");
1877 assert!(output.contains("my_crate::MyError::NotFound =>"));
1878 assert!(!output.contains("NotFound(..)"));
1879 }
1880
1881 #[test]
1886 fn test_gen_wasm_error_converter() {
1887 let error = sample_error();
1888 let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1889 assert!(output.contains(
1891 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1892 ));
1893 assert!(output.contains("js_sys::Object::new()"));
1895 assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1896 assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1897 assert!(output.contains("obj.into()"));
1898 assert!(
1900 output
1901 .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1902 );
1903 assert!(output.contains("\"parse_error\""));
1904 assert!(output.contains("\"io_error\""));
1905 assert!(output.contains("\"other\""));
1906 assert!(output.contains("#[allow(dead_code)]"));
1907 }
1908
1909 #[test]
1914 fn test_gen_php_error_converter() {
1915 let error = sample_error();
1916 let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1917 assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1918 assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1919 assert!(output.contains("#[allow(dead_code)]"));
1920 }
1921
1922 #[test]
1927 fn test_gen_magnus_error_converter() {
1928 let error = sample_error();
1929 let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1930 assert!(
1931 output.contains(
1932 "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1933 )
1934 );
1935 assert!(
1936 output.contains(
1937 "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1938 )
1939 );
1940 assert!(output.contains("#[allow(dead_code)]"));
1941 }
1942
1943 #[test]
1948 fn test_gen_rustler_error_converter() {
1949 let error = sample_error();
1950 let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1951 assert!(
1952 output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1953 );
1954 assert!(output.contains("e.to_string()"));
1955 assert!(output.contains("#[allow(dead_code)]"));
1956 }
1957
1958 #[test]
1963 fn test_gen_go_error_struct_with_methods() {
1964 let error = error_with_methods();
1965 let output = gen_go_error_struct(&error, "literllm");
1966 assert!(output.contains("type Error struct {"), "struct def: {output}");
1968 assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
1971 assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
1972 assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
1973 assert!(
1975 !output.contains("func (e Error) StatusCode()"),
1976 "no StatusCode accessor: {output}"
1977 );
1978 assert!(
1979 !output.contains("func (e Error) IsTransient()"),
1980 "no IsTransient accessor: {output}"
1981 );
1982 assert!(
1983 !output.contains("func (e Error) ErrorType()"),
1984 "no ErrorType accessor: {output}"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_gen_go_error_struct_no_field_method_collision() {
1990 use alef_core::ir::{ErrorDef, ErrorVariant, PrimitiveType, TypeRef};
1993 let error = ErrorDef {
1994 name: "ApiError".to_string(),
1995 rust_path: String::new(),
1996 original_rust_path: String::new(),
1997 doc: String::new(),
1998 variants: vec![ErrorVariant {
1999 name: "Network".to_string(),
2000 message_template: None,
2001 fields: vec![],
2002 has_source: false,
2003 has_from: false,
2004 is_unit: true,
2005 doc: String::new(),
2006 }],
2007 methods: vec![
2008 sample_method("retry_count", TypeRef::Primitive(PrimitiveType::U32)),
2009 sample_method("permanent", TypeRef::Primitive(PrimitiveType::Bool)),
2010 ],
2011 binding_excluded: false,
2012 binding_exclusion_reason: None,
2013 };
2014 let output = gen_go_error_struct(&error, "mypkg");
2015 assert!(output.contains("RetryCount uint32"), "RetryCount field: {output}");
2017 assert!(output.contains("Permanent bool"), "Permanent field: {output}");
2018 assert!(
2021 !output.contains("func (e ApiError) RetryCount()"),
2022 "no RetryCount accessor: {output}"
2023 );
2024 assert!(
2025 !output.contains("func (e ApiError) Permanent()"),
2026 "no Permanent accessor: {output}"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_gen_go_error_struct_no_methods() {
2032 let error = sample_error(); let output = gen_go_error_struct(&error, "mylib");
2034 assert!(output.contains("type ConversionError struct {"), "{output}");
2035 assert!(!output.contains("StatusCode"), "{output}");
2036 assert!(!output.contains("IsTransient"), "{output}");
2037 }
2038
2039 #[test]
2044 fn test_gen_java_error_types_with_methods() {
2045 let error = error_with_methods();
2046 let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
2047 assert_eq!(files.len(), 1); let base = &files[0].1;
2049 assert!(
2050 base.contains("private final int statusCode;"),
2051 "statusCode field: {base}"
2052 );
2053 assert!(
2054 base.contains("private final boolean isTransient;"),
2055 "isTransient field: {base}"
2056 );
2057 assert!(
2058 base.contains("private final String errorType;"),
2059 "errorType field: {base}"
2060 );
2061 assert!(
2062 base.contains("public int getStatusCode()"),
2063 "getStatusCode getter: {base}"
2064 );
2065 assert!(
2066 base.contains("public boolean isTransient()"),
2067 "isTransient getter: {base}"
2068 );
2069 assert!(
2070 base.contains("public String getErrorType()"),
2071 "getErrorType getter: {base}"
2072 );
2073 assert!(
2075 base.contains("public LiterLlmErrorException(final String message)"),
2076 "simple ctor: {base}"
2077 );
2078 assert!(
2080 base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
2081 "full ctor: {base}"
2082 );
2083 }
2084
2085 #[test]
2086 fn test_gen_java_error_types_no_methods() {
2087 let error = sample_error(); let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2089 let base = &files[0].1;
2090 assert!(!base.contains("private final"), "no fields when no methods: {base}");
2091 assert!(
2092 base.contains("public ConversionErrorException(final String message)"),
2093 "{base}"
2094 );
2095 }
2096
2097 #[test]
2102 fn test_gen_csharp_error_types_with_methods() {
2103 let error = error_with_methods();
2104 let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
2105 assert_eq!(files.len(), 1); let base = &files[0].1;
2107 assert!(
2108 base.contains("public ushort StatusCode { get; }"),
2109 "StatusCode prop: {base}"
2110 );
2111 assert!(
2112 base.contains("public bool IsTransient { get; }"),
2113 "IsTransient prop: {base}"
2114 );
2115 assert!(
2116 base.contains("public string ErrorType { get; }"),
2117 "ErrorType prop: {base}"
2118 );
2119 assert!(
2121 base.contains("public LiterLlmErrorException(string message) : base(message)"),
2122 "simple ctor: {base}"
2123 );
2124 assert!(
2126 base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
2127 "full ctor: {base}"
2128 );
2129 }
2130
2131 #[test]
2132 fn test_gen_csharp_error_types_no_methods() {
2133 let error = sample_error(); let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2135 let base = &files[0].1;
2136 assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
2137 assert!(
2138 base.contains("public ConversionErrorException(string message) : base(message) { }"),
2139 "{base}"
2140 );
2141 }
2142
2143 #[test]
2148 fn test_to_screaming_snake() {
2149 assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
2150 assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
2151 assert_eq!(to_screaming_snake("Other"), "OTHER");
2152 }
2153
2154 #[test]
2155 fn test_strip_thiserror_placeholders_struct_field() {
2156 assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
2157 assert_eq!(
2158 strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
2159 "plugin error in"
2160 );
2161 let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
2164 assert!(!result.contains('{'), "no braces: {result}");
2165 assert!(!result.contains('}'), "no braces: {result}");
2166 assert!(result.starts_with("extraction timed out after"), "{result}");
2167 }
2168
2169 #[test]
2170 fn test_strip_thiserror_placeholders_positional() {
2171 assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
2172 assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
2173 }
2174
2175 #[test]
2176 fn test_strip_thiserror_placeholders_no_placeholder() {
2177 assert_eq!(strip_thiserror_placeholders("not found"), "not found");
2178 assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
2179 }
2180
2181 #[test]
2182 fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
2183 assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
2184 assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
2185 assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
2186 assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
2187 assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
2188 }
2189
2190 #[test]
2191 fn test_acronym_aware_snake_phrase_plain_words() {
2192 assert_eq!(acronym_aware_snake_phrase("Other"), "other");
2193 assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
2194 assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
2195 }
2196
2197 #[test]
2198 fn test_variant_display_message_acronym_first_word() {
2199 let variant = ErrorVariant {
2200 name: "Io".to_string(),
2201 message_template: Some("I/O error: {0}".to_string()),
2202 fields: vec![tuple_field(0)],
2203 has_source: false,
2204 has_from: false,
2205 is_unit: false,
2206 doc: String::new(),
2207 };
2208 let msg = variant_display_message(&variant);
2211 assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
2212 }
2213
2214 #[test]
2215 fn test_variant_display_message_no_template_uses_acronyms() {
2216 let variant = ErrorVariant {
2217 name: "IoError".to_string(),
2218 message_template: None,
2219 fields: vec![],
2220 has_source: false,
2221 has_from: false,
2222 is_unit: false,
2223 doc: String::new(),
2224 };
2225 assert_eq!(variant_display_message(&variant), "IO error");
2226 }
2227
2228 #[test]
2229 fn test_variant_display_message_struct_template_no_leak() {
2230 let variant = ErrorVariant {
2231 name: "Ocr".to_string(),
2232 message_template: Some("OCR error: {message}".to_string()),
2233 fields: vec![named_field("message")],
2234 has_source: false,
2235 has_from: false,
2236 is_unit: false,
2237 doc: String::new(),
2238 };
2239 let msg = variant_display_message(&variant);
2240 assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
2241 }
2242
2243 #[test]
2244 fn test_go_sentinels_no_placeholder_leak() {
2245 let error = ErrorDef {
2246 name: "KreuzbergError".to_string(),
2247 rust_path: "kreuzberg::KreuzbergError".to_string(),
2248 original_rust_path: String::new(),
2249 variants: vec![
2250 ErrorVariant {
2251 name: "Io".to_string(),
2252 message_template: Some("IO error: {message}".to_string()),
2253 fields: vec![named_field("message")],
2254 has_source: false,
2255 has_from: false,
2256 is_unit: false,
2257 doc: String::new(),
2258 },
2259 ErrorVariant {
2260 name: "Ocr".to_string(),
2261 message_template: Some("OCR error: {message}".to_string()),
2262 fields: vec![named_field("message")],
2263 has_source: false,
2264 has_from: false,
2265 is_unit: false,
2266 doc: String::new(),
2267 },
2268 ErrorVariant {
2269 name: "Timeout".to_string(),
2270 message_template: Some(
2271 "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
2272 ),
2273 fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
2274 has_source: false,
2275 has_from: false,
2276 is_unit: false,
2277 doc: String::new(),
2278 },
2279 ],
2280 doc: String::new(),
2281 methods: vec![],
2282 binding_excluded: false,
2283 binding_exclusion_reason: None,
2284 };
2285 let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
2286 assert!(
2287 !output.contains('{'),
2288 "Go sentinels must not contain raw placeholders:\n{output}"
2289 );
2290 assert!(
2291 output.contains("ErrIo = errors.New(\"IO error\")"),
2292 "expected acronym-preserving Io sentinel, got:\n{output}"
2293 );
2294 assert!(
2295 output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
2296 "Go sentinel comments must be emitted on separate lines, got:\n{output}"
2297 );
2298 assert!(
2299 output.contains("ErrOcr = errors.New(\"OCR error\")"),
2300 "expected acronym-preserving Ocr sentinel, got:\n{output}"
2301 );
2302 assert!(
2303 output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
2304 "expected timeout sentinel to start with the prose, got:\n{output}"
2305 );
2306 }
2307
2308 #[test]
2313 fn test_gen_ffi_error_codes() {
2314 let error = sample_error();
2315 let output = gen_ffi_error_codes(&error);
2316 assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
2317 assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
2318 assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
2319 assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
2320 assert!(output.contains("conversion_error_t;"));
2321 assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
2322 }
2323
2324 #[test]
2329 fn test_gen_go_error_types() {
2330 let error = sample_error();
2331 let output = gen_go_error_types(&error, "mylib");
2333 assert!(output.contains("ErrParseError = errors.New("));
2334 assert!(output.contains("ErrIoError = errors.New("));
2335 assert!(output.contains("ErrOther = errors.New("));
2336 assert!(output.contains("type ConversionError struct {"));
2337 assert!(output.contains("Code string"));
2338 assert!(output.contains("func (e ConversionError) Error() string"));
2339 assert!(output.contains("// ErrParseError is returned when"));
2341 assert!(output.contains("// ErrIoError is returned when"));
2342 assert!(output.contains("// ErrOther is returned when"));
2343 }
2344
2345 #[test]
2346 fn test_gen_go_error_types_stutter_strip() {
2347 let error = sample_error();
2348 let output = gen_go_error_types(&error, "conversion");
2351 assert!(
2352 output.contains("type Error struct {"),
2353 "expected stutter strip, got:\n{output}"
2354 );
2355 assert!(
2356 output.contains("func (e Error) Error() string"),
2357 "expected stutter strip, got:\n{output}"
2358 );
2359 assert!(output.contains("ErrParseError = errors.New("));
2361 }
2362
2363 #[test]
2368 fn test_gen_java_error_types() {
2369 let error = sample_error();
2370 let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2371 assert_eq!(files.len(), 4);
2373 assert_eq!(files[0].0, "ConversionErrorException");
2375 assert!(
2376 files[0]
2377 .1
2378 .contains("public class ConversionErrorException extends Exception")
2379 );
2380 assert!(files[0].1.contains("package dev.kreuzberg.test;"));
2381 assert_eq!(files[1].0, "ParseErrorException");
2383 assert!(
2384 files[1]
2385 .1
2386 .contains("public class ParseErrorException extends ConversionErrorException")
2387 );
2388 assert_eq!(files[2].0, "IoErrorException");
2389 assert_eq!(files[3].0, "OtherException");
2390 }
2391
2392 #[test]
2397 fn test_gen_csharp_error_types() {
2398 let error = sample_error();
2399 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2401 assert_eq!(files.len(), 4);
2402 assert_eq!(files[0].0, "ConversionErrorException");
2403 assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
2404 assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
2405 assert_eq!(files[1].0, "ParseErrorException");
2406 assert!(
2407 files[1]
2408 .1
2409 .contains("public class ParseErrorException : ConversionErrorException")
2410 );
2411 assert_eq!(files[2].0, "IoErrorException");
2412 assert_eq!(files[3].0, "OtherException");
2413 }
2414
2415 #[test]
2416 fn test_gen_csharp_error_types_with_fallback() {
2417 let error = sample_error();
2418 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
2420 assert_eq!(files.len(), 4);
2421 assert!(
2422 files[0]
2423 .1
2424 .contains("public class ConversionErrorException : TestLibException")
2425 );
2426 assert!(
2428 files[1]
2429 .1
2430 .contains("public class ParseErrorException : ConversionErrorException")
2431 );
2432 }
2433
2434 #[test]
2439 fn test_python_exception_name_no_conflict() {
2440 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
2442 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
2444 }
2445
2446 #[test]
2447 fn test_python_exception_name_shadows_builtin() {
2448 assert_eq!(
2450 python_exception_name("Connection", "CrawlError"),
2451 "CrawlConnectionError"
2452 );
2453 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
2455 assert_eq!(
2457 python_exception_name("ConnectionError", "CrawlError"),
2458 "CrawlConnectionError"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_python_exception_name_no_double_prefix() {
2464 assert_eq!(
2466 python_exception_name("CrawlConnectionError", "CrawlError"),
2467 "CrawlConnectionError"
2468 );
2469 }
2470
2471 fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
2476 alef_core::ir::MethodDef {
2477 name: name.to_string(),
2478 params: vec![],
2479 return_type,
2480 is_async: false,
2481 is_static: false,
2482 error_type: None,
2483 doc: String::new(),
2484 receiver: Some(alef_core::ir::ReceiverKind::Ref),
2485 sanitized: false,
2486 trait_source: None,
2487 returns_ref: false,
2488 returns_cow: false,
2489 return_newtype_wrapper: None,
2490 has_default_impl: false,
2491 binding_excluded: false,
2492 binding_exclusion_reason: None,
2493 }
2494 }
2495
2496 fn error_with_methods() -> ErrorDef {
2497 ErrorDef {
2498 name: "LiterLlmError".to_string(),
2499 rust_path: "liter_llm::error::LiterLlmError".to_string(),
2500 original_rust_path: String::new(),
2501 variants: vec![],
2502 doc: String::new(),
2503 methods: vec![
2504 sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
2505 sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
2506 sample_method("error_type", TypeRef::String),
2507 ],
2508 binding_excluded: false,
2509 binding_exclusion_reason: None,
2510 }
2511 }
2512
2513 #[test]
2514 fn test_gen_wasm_error_methods_empty_when_no_methods() {
2515 let error = sample_error(); let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
2517 assert!(output.is_empty(), "should produce no output when methods is empty");
2518 }
2519
2520 #[test]
2521 fn test_gen_wasm_error_methods_struct_and_impl() {
2522 let error = error_with_methods();
2523 let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
2526 assert!(
2528 output.contains("pub struct WasmLiterLlmError"),
2529 "must emit opaque struct: {output}"
2530 );
2531 assert!(
2532 output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
2533 "{output}"
2534 );
2535 assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
2537 assert!(output.contains("js_name = \"statusCode\""), "{output}");
2539 assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
2540 assert!(output.contains("self.inner.status_code()"), "{output}");
2541 assert!(output.contains("js_name = \"isTransient\""), "{output}");
2542 assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
2543 assert!(output.contains("self.inner.is_transient()"), "{output}");
2544 assert!(output.contains("js_name = \"errorType\""), "{output}");
2545 assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
2546 assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
2547 }
2548
2549 #[test]
2554 fn test_gen_ffi_error_methods_empty_when_no_methods() {
2555 let error = sample_error(); let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
2557 assert!(output.is_empty(), "should produce no output when methods is empty");
2558 }
2559
2560 #[test]
2561 fn test_gen_ffi_error_methods_status_code() {
2562 let error = error_with_methods();
2563 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2564 assert!(
2565 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
2566 "must emit status_code fn: {output}"
2567 );
2568 assert!(
2569 output.contains("err: *const liter_llm::error::LiterLlmError"),
2570 "{output}"
2571 );
2572 assert!(output.contains("-> u16"), "{output}");
2573 assert!(output.contains("(*err).status_code()"), "{output}");
2574 assert!(output.contains("if err.is_null()"), "{output}");
2575 assert!(output.contains("return 0;"), "{output}");
2576 }
2577
2578 #[test]
2579 fn test_gen_ffi_error_methods_is_transient() {
2580 let error = error_with_methods();
2581 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2582 assert!(
2583 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
2584 "must emit is_transient fn: {output}"
2585 );
2586 assert!(output.contains("-> bool"), "{output}");
2587 assert!(output.contains("(*err).is_transient()"), "{output}");
2588 assert!(output.contains("return false;"), "{output}");
2589 }
2590
2591 #[test]
2592 fn test_gen_ffi_error_methods_error_type_with_free() {
2593 let error = error_with_methods();
2594 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2595 assert!(
2596 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
2597 "must emit error_type fn: {output}"
2598 );
2599 assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
2600 assert!(output.contains("(*err).error_type()"), "{output}");
2601 assert!(output.contains("CString::new(s)"), "{output}");
2602 assert!(output.contains(".into_raw()"), "{output}");
2603 assert!(output.contains("return std::ptr::null_mut();"), "{output}");
2604 assert!(
2606 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
2607 "must emit _free companion: {output}"
2608 );
2609 assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
2610 }
2611
2612 #[test]
2613 fn test_gen_ffi_error_methods_safety_comments() {
2614 let error = error_with_methods();
2615 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2616 assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
2617 }
2618}