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 serde_json::json!({
1137 "field_name": method_name,
1138 "go_type": go_type,
1139 "method_name": method_name,
1140 "doc": m.doc,
1141 })
1142 })
1143 .collect();
1144 let has_methods = !methods.is_empty();
1145
1146 crate::template_env::render(
1147 "error_gen/go_error_struct.jinja",
1148 minijinja::context! {
1149 go_type_name => go_type_name.as_str(),
1150 methods => methods,
1151 has_methods => has_methods,
1152 },
1153 )
1154}
1155
1156fn typeref_to_go_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1160 use alef_core::ir::{PrimitiveType, TypeRef};
1161 match ty {
1162 TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1163 TypeRef::Primitive(PrimitiveType::U8) => "uint8",
1164 TypeRef::Primitive(PrimitiveType::U16) => "uint16",
1165 TypeRef::Primitive(PrimitiveType::U32) => "uint32",
1166 TypeRef::Primitive(PrimitiveType::U64) => "uint64",
1167 TypeRef::Primitive(PrimitiveType::I8) => "int8",
1168 TypeRef::Primitive(PrimitiveType::I16) => "int16",
1169 TypeRef::Primitive(PrimitiveType::I32) => "int32",
1170 TypeRef::Primitive(PrimitiveType::I64) => "int64",
1171 TypeRef::Primitive(PrimitiveType::F32) => "float32",
1172 TypeRef::Primitive(PrimitiveType::F64) => "float64",
1173 TypeRef::String => "string",
1174 _ => "string",
1175 }
1176}
1177
1178fn to_pascal_case(s: &str) -> String {
1180 s.split('_')
1181 .map(|word| {
1182 let mut chars = word.chars();
1183 match chars.next() {
1184 None => String::new(),
1185 Some(first) => first.to_uppercase().to_string() + chars.as_str(),
1186 }
1187 })
1188 .collect()
1189}
1190
1191fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
1203 let type_lower = type_name.to_lowercase();
1204 let pkg_lower = pkg_name.to_lowercase();
1205 if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
1206 type_name[pkg_lower.len()..].to_string()
1208 } else {
1209 type_name.to_string()
1210 }
1211}
1212
1213pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
1227 let mut files = Vec::with_capacity(error.variants.len() + 1);
1228
1229 let base_name = format!("{}Exception", error.name);
1231 let doc_lines: Vec<&str> = error.doc.lines().collect();
1232
1233 let method_infos: Vec<serde_json::Value> = error
1236 .methods
1237 .iter()
1238 .map(|m| {
1239 let java_type = typeref_to_java_type(&m.return_type);
1240 let getter_name = java_getter_name(&m.name);
1241 let field_name = java_field_name(&m.name);
1242 let default_value = java_default_value(&m.return_type);
1243 serde_json::json!({
1244 "field_name": field_name,
1245 "java_type": java_type,
1246 "getter_name": getter_name,
1247 "default_value": default_value,
1248 "doc": m.doc,
1249 })
1250 })
1251 .collect();
1252 let has_methods = !method_infos.is_empty();
1253
1254 let base = crate::template_env::render(
1255 "error_gen/java_error_base.jinja",
1256 minijinja::context! {
1257 package => package,
1258 base_name => base_name.as_str(),
1259 doc => !error.doc.is_empty(),
1260 doc_lines => doc_lines,
1261 methods => method_infos,
1262 has_methods => has_methods,
1263 },
1264 );
1265 files.push((base_name.clone(), base));
1266
1267 for variant in &error.variants {
1269 let class_name = format!("{}Exception", variant.name);
1270 let doc_lines: Vec<&str> = variant.doc.lines().collect();
1271
1272 let content = crate::template_env::render(
1273 "error_gen/java_error_variant.jinja",
1274 minijinja::context! {
1275 package => package,
1276 class_name => class_name.as_str(),
1277 base_name => base_name.as_str(),
1278 doc => !variant.doc.is_empty(),
1279 doc_lines => doc_lines,
1280 has_methods => has_methods,
1281 },
1282 );
1283 files.push((class_name, content));
1284 }
1285
1286 files
1287}
1288
1289fn typeref_to_java_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1291 use alef_core::ir::{PrimitiveType, TypeRef};
1292 match ty {
1293 TypeRef::Primitive(PrimitiveType::Bool) => "boolean",
1294 TypeRef::Primitive(
1295 PrimitiveType::U8
1296 | PrimitiveType::I8
1297 | PrimitiveType::I16
1298 | PrimitiveType::U16
1299 | PrimitiveType::I32
1300 | PrimitiveType::U32,
1301 ) => "int",
1302 TypeRef::Primitive(PrimitiveType::I64 | PrimitiveType::U64) => "long",
1303 TypeRef::Primitive(PrimitiveType::F32) => "float",
1304 TypeRef::Primitive(PrimitiveType::F64) => "double",
1305 TypeRef::String => "String",
1306 _ => "String",
1307 }
1308}
1309
1310fn java_getter_name(snake: &str) -> String {
1313 if let Some(rest) = snake.strip_prefix("is_") {
1314 let pascal = to_pascal_case(rest);
1316 format!("is{pascal}")
1317 } else {
1318 let pascal = to_pascal_case(snake);
1320 format!("get{pascal}")
1321 }
1322}
1323
1324fn java_field_name(snake: &str) -> String {
1327 let parts: Vec<&str> = snake.split('_').collect();
1328 if parts.is_empty() {
1329 return snake.to_string();
1330 }
1331 let mut out = parts[0].to_string();
1332 for part in &parts[1..] {
1333 let mut chars = part.chars();
1334 match chars.next() {
1335 None => {}
1336 Some(first) => {
1337 out.push_str(&first.to_uppercase().to_string());
1338 out.push_str(chars.as_str());
1339 }
1340 }
1341 }
1342 out
1343}
1344
1345fn java_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1347 use alef_core::ir::{PrimitiveType, TypeRef};
1348 match ty {
1349 TypeRef::Primitive(PrimitiveType::Bool) => "false",
1350 TypeRef::String => "\"\"",
1351 _ => "0",
1352 }
1353}
1354
1355pub fn gen_csharp_error_types(
1373 error: &ErrorDef,
1374 namespace: &str,
1375 fallback_class: Option<&str>,
1376) -> Vec<(String, String)> {
1377 let mut files = Vec::with_capacity(error.variants.len() + 1);
1378
1379 let base_name = format!("{}Exception", error.name);
1380 let base_parent = fallback_class.unwrap_or("Exception");
1383 let error_doc_lines: Vec<&str> = error.doc.lines().collect();
1384
1385 let method_infos: Vec<serde_json::Value> = error
1388 .methods
1389 .iter()
1390 .map(|m| {
1391 let cs_type = typeref_to_csharp_type(&m.return_type);
1392 let prop_name = to_pascal_case(&m.name);
1393 let param_name = java_field_name(&m.name); let default_value = csharp_default_value(&m.return_type);
1395 serde_json::json!({
1396 "prop_name": prop_name,
1397 "cs_type": cs_type,
1398 "param_name": param_name,
1399 "default_value": default_value,
1400 "doc": m.doc,
1401 })
1402 })
1403 .collect();
1404 let has_methods = !method_infos.is_empty();
1405
1406 {
1408 let out = crate::template_env::render(
1409 "error_gen/csharp_error_base.jinja",
1410 minijinja::context! {
1411 namespace => namespace,
1412 base_name => base_name.as_str(),
1413 base_parent => base_parent,
1414 doc => !error.doc.is_empty(),
1415 doc_lines => error_doc_lines,
1416 methods => method_infos,
1417 has_methods => has_methods,
1418 },
1419 );
1420 files.push((base_name.clone(), out));
1421 }
1422
1423 for variant in &error.variants {
1425 let class_name = format!("{}Exception", variant.name);
1426 let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
1427
1428 let out = crate::template_env::render(
1429 "error_gen/csharp_error_variant.jinja",
1430 minijinja::context! {
1431 namespace => namespace,
1432 class_name => class_name.as_str(),
1433 base_name => base_name.as_str(),
1434 doc => !variant.doc.is_empty(),
1435 doc_lines => variant_doc_lines,
1436 has_methods => has_methods,
1437 },
1438 );
1439 files.push((class_name, out));
1440 }
1441
1442 files
1443}
1444
1445fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1447 use alef_core::ir::{PrimitiveType, TypeRef};
1448 match ty {
1449 TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1450 TypeRef::Primitive(PrimitiveType::U8) => "byte",
1451 TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
1452 TypeRef::Primitive(PrimitiveType::I16) => "short",
1453 TypeRef::Primitive(PrimitiveType::U16) => "ushort",
1454 TypeRef::Primitive(PrimitiveType::I32) => "int",
1455 TypeRef::Primitive(PrimitiveType::U32) => "uint",
1456 TypeRef::Primitive(PrimitiveType::I64) => "long",
1457 TypeRef::Primitive(PrimitiveType::U64) => "ulong",
1458 TypeRef::Primitive(PrimitiveType::F32) => "float",
1459 TypeRef::Primitive(PrimitiveType::F64) => "double",
1460 TypeRef::String => "string",
1461 _ => "string",
1462 }
1463}
1464
1465fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1467 use alef_core::ir::{PrimitiveType, TypeRef};
1468 match ty {
1469 TypeRef::Primitive(PrimitiveType::Bool) => "false",
1470 TypeRef::String => "string.Empty",
1471 _ => "0",
1472 }
1473}
1474
1475fn to_screaming_snake(s: &str) -> String {
1481 let mut result = String::with_capacity(s.len() + 4);
1482 for (i, c) in s.chars().enumerate() {
1483 if c.is_uppercase() {
1484 if i > 0 {
1485 result.push('_');
1486 }
1487 result.push(c.to_ascii_uppercase());
1488 } else {
1489 result.push(c.to_ascii_uppercase());
1490 }
1491 }
1492 result
1493}
1494
1495const TECHNICAL_ACRONYMS: &[&str] = &[
1502 "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
1503 "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
1504 "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
1505 "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
1506];
1507
1508pub fn strip_thiserror_placeholders(template: &str) -> String {
1522 let mut without_placeholders = String::with_capacity(template.len());
1524 let mut depth = 0u32;
1525 for ch in template.chars() {
1526 match ch {
1527 '{' => depth = depth.saturating_add(1),
1528 '}' => depth = depth.saturating_sub(1),
1529 other if depth == 0 => without_placeholders.push(other),
1530 _ => {}
1531 }
1532 }
1533 let mut compacted = String::with_capacity(without_placeholders.len());
1537 let mut last_was_space = false;
1538 for ch in without_placeholders.chars() {
1539 if ch.is_whitespace() {
1540 if !last_was_space && !compacted.is_empty() {
1541 compacted.push(' ');
1542 }
1543 last_was_space = true;
1544 } else {
1545 compacted.push(ch);
1546 last_was_space = false;
1547 }
1548 }
1549 let trimmed = compacted
1551 .trim()
1552 .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
1553 .trim();
1554 let cleaned = trimmed
1557 .replace("()", "")
1558 .replace("''", "")
1559 .replace("\"\"", "")
1560 .replace(" ", " ");
1561 cleaned.trim().to_string()
1562}
1563
1564pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
1574 if variant_name.is_empty() {
1575 return String::new();
1576 }
1577 let bytes = variant_name.as_bytes();
1579 let mut words: Vec<&str> = Vec::new();
1580 let mut start = 0usize;
1581 for i in 1..bytes.len() {
1582 if bytes[i].is_ascii_uppercase() {
1583 words.push(&variant_name[start..i]);
1584 start = i;
1585 }
1586 }
1587 words.push(&variant_name[start..]);
1588
1589 let mut rendered: Vec<String> = Vec::with_capacity(words.len());
1590 for word in &words {
1591 let upper = word.to_ascii_uppercase();
1592 if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
1593 rendered.push(upper);
1594 } else {
1595 rendered.push(word.to_ascii_lowercase());
1596 }
1597 }
1598 rendered.join(" ")
1599}
1600
1601fn variant_display_message(variant: &ErrorVariant) -> String {
1606 if let Some(tmpl) = &variant.message_template {
1607 let stripped = strip_thiserror_placeholders(tmpl);
1608 if stripped.is_empty() {
1609 return acronym_aware_snake_phrase(&variant.name);
1610 }
1611 let mut tokens = stripped.splitn(2, ' ');
1616 let head = tokens.next().unwrap_or("").to_string();
1617 let tail = tokens.next().unwrap_or("");
1618 let head_upper = head.to_ascii_uppercase();
1619 let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
1620 head_upper
1621 } else {
1622 let mut chars = head.chars();
1623 match chars.next() {
1624 Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1625 None => head,
1626 }
1627 };
1628 if tail.is_empty() {
1629 head_rendered
1630 } else {
1631 format!("{} {}", head_rendered, tail)
1632 }
1633 } else {
1634 acronym_aware_snake_phrase(&variant.name)
1635 }
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640 use super::*;
1641 use alef_core::ir::{ErrorDef, ErrorVariant};
1642
1643 use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
1644
1645 fn tuple_field(index: usize) -> FieldDef {
1647 FieldDef {
1648 name: format!("_{index}"),
1649 ty: TypeRef::String,
1650 optional: false,
1651 default: None,
1652 doc: String::new(),
1653 sanitized: false,
1654 is_boxed: false,
1655 type_rust_path: None,
1656 cfg: None,
1657 typed_default: None,
1658 core_wrapper: CoreWrapper::None,
1659 vec_inner_core_wrapper: CoreWrapper::None,
1660 newtype_wrapper: None,
1661 serde_rename: None,
1662 serde_flatten: false,
1663 binding_excluded: false,
1664 binding_exclusion_reason: None,
1665 original_type: None,
1666 }
1667 }
1668
1669 fn named_field(name: &str) -> FieldDef {
1671 FieldDef {
1672 name: name.to_string(),
1673 ty: TypeRef::String,
1674 optional: false,
1675 default: None,
1676 doc: String::new(),
1677 sanitized: false,
1678 is_boxed: false,
1679 type_rust_path: None,
1680 cfg: None,
1681 typed_default: None,
1682 core_wrapper: CoreWrapper::None,
1683 vec_inner_core_wrapper: CoreWrapper::None,
1684 newtype_wrapper: None,
1685 serde_rename: None,
1686 serde_flatten: false,
1687 binding_excluded: false,
1688 binding_exclusion_reason: None,
1689 original_type: None,
1690 }
1691 }
1692
1693 fn sample_error() -> ErrorDef {
1694 ErrorDef {
1695 name: "ConversionError".to_string(),
1696 rust_path: "html_to_markdown_rs::ConversionError".to_string(),
1697 original_rust_path: String::new(),
1698 variants: vec![
1699 ErrorVariant {
1700 name: "ParseError".to_string(),
1701 message_template: Some("HTML parsing error: {0}".to_string()),
1702 fields: vec![tuple_field(0)],
1703 has_source: false,
1704 has_from: false,
1705 is_unit: false,
1706 doc: String::new(),
1707 },
1708 ErrorVariant {
1709 name: "IoError".to_string(),
1710 message_template: Some("I/O error: {0}".to_string()),
1711 fields: vec![tuple_field(0)],
1712 has_source: false,
1713 has_from: true,
1714 is_unit: false,
1715 doc: String::new(),
1716 },
1717 ErrorVariant {
1718 name: "Other".to_string(),
1719 message_template: Some("Conversion error: {0}".to_string()),
1720 fields: vec![tuple_field(0)],
1721 has_source: false,
1722 has_from: false,
1723 is_unit: false,
1724 doc: String::new(),
1725 },
1726 ],
1727 doc: "Error type for conversion operations.".to_string(),
1728 methods: vec![],
1729 binding_excluded: false,
1730 binding_exclusion_reason: None,
1731 }
1732 }
1733
1734 #[test]
1735 fn test_gen_error_types() {
1736 let error = sample_error();
1737 let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
1738 assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
1739 assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
1740 assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
1741 assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
1742 }
1743
1744 #[test]
1745 fn test_gen_error_converter() {
1746 let error = sample_error();
1747 let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
1748 assert!(
1749 output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
1750 );
1751 assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
1752 assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
1753 }
1754
1755 #[test]
1756 fn test_gen_error_registration() {
1757 let error = sample_error();
1758 let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
1759 assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
1761 assert!(regs[3].contains("\"ConversionError\""));
1762 }
1763
1764 #[test]
1765 fn test_unit_variant_pattern() {
1766 let error = ErrorDef {
1767 name: "MyError".to_string(),
1768 rust_path: "my_crate::MyError".to_string(),
1769 original_rust_path: String::new(),
1770 variants: vec![ErrorVariant {
1771 name: "NotFound".to_string(),
1772 message_template: Some("not found".to_string()),
1773 fields: vec![],
1774 has_source: false,
1775 has_from: false,
1776 is_unit: true,
1777 doc: String::new(),
1778 }],
1779 doc: String::new(),
1780 methods: vec![],
1781 binding_excluded: false,
1782 binding_exclusion_reason: None,
1783 };
1784 let output = gen_pyo3_error_converter(&error, "my_crate");
1785 assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
1786 assert!(!output.contains("NotFound(..)"));
1788 }
1789
1790 #[test]
1791 fn test_struct_variant_pattern() {
1792 let error = ErrorDef {
1793 name: "MyError".to_string(),
1794 rust_path: "my_crate::MyError".to_string(),
1795 original_rust_path: String::new(),
1796 variants: vec![ErrorVariant {
1797 name: "Parsing".to_string(),
1798 message_template: Some("parsing error: {message}".to_string()),
1799 fields: vec![named_field("message")],
1800 has_source: false,
1801 has_from: false,
1802 is_unit: false,
1803 doc: String::new(),
1804 }],
1805 doc: String::new(),
1806 methods: vec![],
1807 binding_excluded: false,
1808 binding_exclusion_reason: None,
1809 };
1810 let output = gen_pyo3_error_converter(&error, "my_crate");
1811 assert!(
1812 output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1813 "Struct variants must use {{ .. }} pattern, got:\n{output}"
1814 );
1815 assert!(!output.contains("Parsing(..)"));
1817 }
1818
1819 #[test]
1824 fn test_gen_napi_error_types() {
1825 let error = sample_error();
1826 let output = gen_napi_error_types(&error);
1827 assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1828 assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1829 assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1830 }
1831
1832 #[test]
1833 fn test_gen_napi_error_converter() {
1834 let error = sample_error();
1835 let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1836 assert!(
1837 output
1838 .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1839 );
1840 assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1841 assert!(output.contains("[ParseError]"));
1842 assert!(output.contains("[IoError]"));
1843 assert!(output.contains("#[allow(dead_code)]"));
1844 }
1845
1846 #[test]
1847 fn test_napi_unit_variant() {
1848 let error = ErrorDef {
1849 name: "MyError".to_string(),
1850 rust_path: "my_crate::MyError".to_string(),
1851 original_rust_path: String::new(),
1852 variants: vec![ErrorVariant {
1853 name: "NotFound".to_string(),
1854 message_template: None,
1855 fields: vec![],
1856 has_source: false,
1857 has_from: false,
1858 is_unit: true,
1859 doc: String::new(),
1860 }],
1861 doc: String::new(),
1862 methods: vec![],
1863 binding_excluded: false,
1864 binding_exclusion_reason: None,
1865 };
1866 let output = gen_napi_error_converter(&error, "my_crate");
1867 assert!(output.contains("my_crate::MyError::NotFound =>"));
1868 assert!(!output.contains("NotFound(..)"));
1869 }
1870
1871 #[test]
1876 fn test_gen_wasm_error_converter() {
1877 let error = sample_error();
1878 let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1879 assert!(output.contains(
1881 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1882 ));
1883 assert!(output.contains("js_sys::Object::new()"));
1885 assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1886 assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1887 assert!(output.contains("obj.into()"));
1888 assert!(
1890 output
1891 .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1892 );
1893 assert!(output.contains("\"parse_error\""));
1894 assert!(output.contains("\"io_error\""));
1895 assert!(output.contains("\"other\""));
1896 assert!(output.contains("#[allow(dead_code)]"));
1897 }
1898
1899 #[test]
1904 fn test_gen_php_error_converter() {
1905 let error = sample_error();
1906 let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1907 assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1908 assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1909 assert!(output.contains("#[allow(dead_code)]"));
1910 }
1911
1912 #[test]
1917 fn test_gen_magnus_error_converter() {
1918 let error = sample_error();
1919 let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1920 assert!(
1921 output.contains(
1922 "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1923 )
1924 );
1925 assert!(
1926 output.contains(
1927 "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1928 )
1929 );
1930 assert!(output.contains("#[allow(dead_code)]"));
1931 }
1932
1933 #[test]
1938 fn test_gen_rustler_error_converter() {
1939 let error = sample_error();
1940 let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1941 assert!(
1942 output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1943 );
1944 assert!(output.contains("e.to_string()"));
1945 assert!(output.contains("#[allow(dead_code)]"));
1946 }
1947
1948 #[test]
1953 fn test_gen_go_error_struct_with_methods() {
1954 let error = error_with_methods();
1955 let output = gen_go_error_struct(&error, "literllm");
1956 assert!(output.contains("type Error struct {"), "struct def: {output}");
1958 assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
1959 assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
1960 assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
1961 assert!(
1962 output.contains("func (e Error) StatusCode() uint16 { return e.StatusCode }"),
1963 "{output}"
1964 );
1965 assert!(
1966 output.contains("func (e Error) IsTransient() bool { return e.IsTransient }"),
1967 "{output}"
1968 );
1969 assert!(
1970 output.contains("func (e Error) ErrorType() string { return e.ErrorType }"),
1971 "{output}"
1972 );
1973 }
1974
1975 #[test]
1976 fn test_gen_go_error_struct_no_methods() {
1977 let error = sample_error(); let output = gen_go_error_struct(&error, "mylib");
1979 assert!(output.contains("type ConversionError struct {"), "{output}");
1980 assert!(!output.contains("StatusCode"), "{output}");
1981 assert!(!output.contains("IsTransient"), "{output}");
1982 }
1983
1984 #[test]
1989 fn test_gen_java_error_types_with_methods() {
1990 let error = error_with_methods();
1991 let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
1992 assert_eq!(files.len(), 1); let base = &files[0].1;
1994 assert!(
1995 base.contains("private final int statusCode;"),
1996 "statusCode field: {base}"
1997 );
1998 assert!(
1999 base.contains("private final boolean isTransient;"),
2000 "isTransient field: {base}"
2001 );
2002 assert!(
2003 base.contains("private final String errorType;"),
2004 "errorType field: {base}"
2005 );
2006 assert!(
2007 base.contains("public int getStatusCode()"),
2008 "getStatusCode getter: {base}"
2009 );
2010 assert!(
2011 base.contains("public boolean isTransient()"),
2012 "isTransient getter: {base}"
2013 );
2014 assert!(
2015 base.contains("public String getErrorType()"),
2016 "getErrorType getter: {base}"
2017 );
2018 assert!(
2020 base.contains("public LiterLlmErrorException(final String message)"),
2021 "simple ctor: {base}"
2022 );
2023 assert!(
2025 base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
2026 "full ctor: {base}"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_gen_java_error_types_no_methods() {
2032 let error = sample_error(); let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2034 let base = &files[0].1;
2035 assert!(!base.contains("private final"), "no fields when no methods: {base}");
2036 assert!(
2037 base.contains("public ConversionErrorException(final String message)"),
2038 "{base}"
2039 );
2040 }
2041
2042 #[test]
2047 fn test_gen_csharp_error_types_with_methods() {
2048 let error = error_with_methods();
2049 let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
2050 assert_eq!(files.len(), 1); let base = &files[0].1;
2052 assert!(
2053 base.contains("public ushort StatusCode { get; }"),
2054 "StatusCode prop: {base}"
2055 );
2056 assert!(
2057 base.contains("public bool IsTransient { get; }"),
2058 "IsTransient prop: {base}"
2059 );
2060 assert!(
2061 base.contains("public string ErrorType { get; }"),
2062 "ErrorType prop: {base}"
2063 );
2064 assert!(
2066 base.contains("public LiterLlmErrorException(string message) : base(message)"),
2067 "simple ctor: {base}"
2068 );
2069 assert!(
2071 base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
2072 "full ctor: {base}"
2073 );
2074 }
2075
2076 #[test]
2077 fn test_gen_csharp_error_types_no_methods() {
2078 let error = sample_error(); let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2080 let base = &files[0].1;
2081 assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
2082 assert!(
2083 base.contains("public ConversionErrorException(string message) : base(message) { }"),
2084 "{base}"
2085 );
2086 }
2087
2088 #[test]
2093 fn test_to_screaming_snake() {
2094 assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
2095 assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
2096 assert_eq!(to_screaming_snake("Other"), "OTHER");
2097 }
2098
2099 #[test]
2100 fn test_strip_thiserror_placeholders_struct_field() {
2101 assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
2102 assert_eq!(
2103 strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
2104 "plugin error in"
2105 );
2106 let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
2109 assert!(!result.contains('{'), "no braces: {result}");
2110 assert!(!result.contains('}'), "no braces: {result}");
2111 assert!(result.starts_with("extraction timed out after"), "{result}");
2112 }
2113
2114 #[test]
2115 fn test_strip_thiserror_placeholders_positional() {
2116 assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
2117 assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
2118 }
2119
2120 #[test]
2121 fn test_strip_thiserror_placeholders_no_placeholder() {
2122 assert_eq!(strip_thiserror_placeholders("not found"), "not found");
2123 assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
2124 }
2125
2126 #[test]
2127 fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
2128 assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
2129 assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
2130 assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
2131 assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
2132 assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
2133 }
2134
2135 #[test]
2136 fn test_acronym_aware_snake_phrase_plain_words() {
2137 assert_eq!(acronym_aware_snake_phrase("Other"), "other");
2138 assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
2139 assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
2140 }
2141
2142 #[test]
2143 fn test_variant_display_message_acronym_first_word() {
2144 let variant = ErrorVariant {
2145 name: "Io".to_string(),
2146 message_template: Some("I/O error: {0}".to_string()),
2147 fields: vec![tuple_field(0)],
2148 has_source: false,
2149 has_from: false,
2150 is_unit: false,
2151 doc: String::new(),
2152 };
2153 let msg = variant_display_message(&variant);
2156 assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
2157 }
2158
2159 #[test]
2160 fn test_variant_display_message_no_template_uses_acronyms() {
2161 let variant = ErrorVariant {
2162 name: "IoError".to_string(),
2163 message_template: None,
2164 fields: vec![],
2165 has_source: false,
2166 has_from: false,
2167 is_unit: false,
2168 doc: String::new(),
2169 };
2170 assert_eq!(variant_display_message(&variant), "IO error");
2171 }
2172
2173 #[test]
2174 fn test_variant_display_message_struct_template_no_leak() {
2175 let variant = ErrorVariant {
2176 name: "Ocr".to_string(),
2177 message_template: Some("OCR error: {message}".to_string()),
2178 fields: vec![named_field("message")],
2179 has_source: false,
2180 has_from: false,
2181 is_unit: false,
2182 doc: String::new(),
2183 };
2184 let msg = variant_display_message(&variant);
2185 assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
2186 }
2187
2188 #[test]
2189 fn test_go_sentinels_no_placeholder_leak() {
2190 let error = ErrorDef {
2191 name: "KreuzbergError".to_string(),
2192 rust_path: "kreuzberg::KreuzbergError".to_string(),
2193 original_rust_path: String::new(),
2194 variants: vec![
2195 ErrorVariant {
2196 name: "Io".to_string(),
2197 message_template: Some("IO error: {message}".to_string()),
2198 fields: vec![named_field("message")],
2199 has_source: false,
2200 has_from: false,
2201 is_unit: false,
2202 doc: String::new(),
2203 },
2204 ErrorVariant {
2205 name: "Ocr".to_string(),
2206 message_template: Some("OCR error: {message}".to_string()),
2207 fields: vec![named_field("message")],
2208 has_source: false,
2209 has_from: false,
2210 is_unit: false,
2211 doc: String::new(),
2212 },
2213 ErrorVariant {
2214 name: "Timeout".to_string(),
2215 message_template: Some(
2216 "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
2217 ),
2218 fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
2219 has_source: false,
2220 has_from: false,
2221 is_unit: false,
2222 doc: String::new(),
2223 },
2224 ],
2225 doc: String::new(),
2226 methods: vec![],
2227 binding_excluded: false,
2228 binding_exclusion_reason: None,
2229 };
2230 let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
2231 assert!(
2232 !output.contains('{'),
2233 "Go sentinels must not contain raw placeholders:\n{output}"
2234 );
2235 assert!(
2236 output.contains("ErrIo = errors.New(\"IO error\")"),
2237 "expected acronym-preserving Io sentinel, got:\n{output}"
2238 );
2239 assert!(
2240 output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
2241 "Go sentinel comments must be emitted on separate lines, got:\n{output}"
2242 );
2243 assert!(
2244 output.contains("ErrOcr = errors.New(\"OCR error\")"),
2245 "expected acronym-preserving Ocr sentinel, got:\n{output}"
2246 );
2247 assert!(
2248 output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
2249 "expected timeout sentinel to start with the prose, got:\n{output}"
2250 );
2251 }
2252
2253 #[test]
2258 fn test_gen_ffi_error_codes() {
2259 let error = sample_error();
2260 let output = gen_ffi_error_codes(&error);
2261 assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
2262 assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
2263 assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
2264 assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
2265 assert!(output.contains("conversion_error_t;"));
2266 assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
2267 }
2268
2269 #[test]
2274 fn test_gen_go_error_types() {
2275 let error = sample_error();
2276 let output = gen_go_error_types(&error, "mylib");
2278 assert!(output.contains("ErrParseError = errors.New("));
2279 assert!(output.contains("ErrIoError = errors.New("));
2280 assert!(output.contains("ErrOther = errors.New("));
2281 assert!(output.contains("type ConversionError struct {"));
2282 assert!(output.contains("Code string"));
2283 assert!(output.contains("func (e ConversionError) Error() string"));
2284 assert!(output.contains("// ErrParseError is returned when"));
2286 assert!(output.contains("// ErrIoError is returned when"));
2287 assert!(output.contains("// ErrOther is returned when"));
2288 }
2289
2290 #[test]
2291 fn test_gen_go_error_types_stutter_strip() {
2292 let error = sample_error();
2293 let output = gen_go_error_types(&error, "conversion");
2296 assert!(
2297 output.contains("type Error struct {"),
2298 "expected stutter strip, got:\n{output}"
2299 );
2300 assert!(
2301 output.contains("func (e Error) Error() string"),
2302 "expected stutter strip, got:\n{output}"
2303 );
2304 assert!(output.contains("ErrParseError = errors.New("));
2306 }
2307
2308 #[test]
2313 fn test_gen_java_error_types() {
2314 let error = sample_error();
2315 let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2316 assert_eq!(files.len(), 4);
2318 assert_eq!(files[0].0, "ConversionErrorException");
2320 assert!(
2321 files[0]
2322 .1
2323 .contains("public class ConversionErrorException extends Exception")
2324 );
2325 assert!(files[0].1.contains("package dev.kreuzberg.test;"));
2326 assert_eq!(files[1].0, "ParseErrorException");
2328 assert!(
2329 files[1]
2330 .1
2331 .contains("public class ParseErrorException extends ConversionErrorException")
2332 );
2333 assert_eq!(files[2].0, "IoErrorException");
2334 assert_eq!(files[3].0, "OtherException");
2335 }
2336
2337 #[test]
2342 fn test_gen_csharp_error_types() {
2343 let error = sample_error();
2344 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2346 assert_eq!(files.len(), 4);
2347 assert_eq!(files[0].0, "ConversionErrorException");
2348 assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
2349 assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
2350 assert_eq!(files[1].0, "ParseErrorException");
2351 assert!(
2352 files[1]
2353 .1
2354 .contains("public class ParseErrorException : ConversionErrorException")
2355 );
2356 assert_eq!(files[2].0, "IoErrorException");
2357 assert_eq!(files[3].0, "OtherException");
2358 }
2359
2360 #[test]
2361 fn test_gen_csharp_error_types_with_fallback() {
2362 let error = sample_error();
2363 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
2365 assert_eq!(files.len(), 4);
2366 assert!(
2367 files[0]
2368 .1
2369 .contains("public class ConversionErrorException : TestLibException")
2370 );
2371 assert!(
2373 files[1]
2374 .1
2375 .contains("public class ParseErrorException : ConversionErrorException")
2376 );
2377 }
2378
2379 #[test]
2384 fn test_python_exception_name_no_conflict() {
2385 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
2387 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
2389 }
2390
2391 #[test]
2392 fn test_python_exception_name_shadows_builtin() {
2393 assert_eq!(
2395 python_exception_name("Connection", "CrawlError"),
2396 "CrawlConnectionError"
2397 );
2398 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
2400 assert_eq!(
2402 python_exception_name("ConnectionError", "CrawlError"),
2403 "CrawlConnectionError"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_python_exception_name_no_double_prefix() {
2409 assert_eq!(
2411 python_exception_name("CrawlConnectionError", "CrawlError"),
2412 "CrawlConnectionError"
2413 );
2414 }
2415
2416 fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
2421 alef_core::ir::MethodDef {
2422 name: name.to_string(),
2423 params: vec![],
2424 return_type,
2425 is_async: false,
2426 is_static: false,
2427 error_type: None,
2428 doc: String::new(),
2429 receiver: Some(alef_core::ir::ReceiverKind::Ref),
2430 sanitized: false,
2431 trait_source: None,
2432 returns_ref: false,
2433 returns_cow: false,
2434 return_newtype_wrapper: None,
2435 has_default_impl: false,
2436 binding_excluded: false,
2437 binding_exclusion_reason: None,
2438 }
2439 }
2440
2441 fn error_with_methods() -> ErrorDef {
2442 ErrorDef {
2443 name: "LiterLlmError".to_string(),
2444 rust_path: "liter_llm::error::LiterLlmError".to_string(),
2445 original_rust_path: String::new(),
2446 variants: vec![],
2447 doc: String::new(),
2448 methods: vec![
2449 sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
2450 sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
2451 sample_method("error_type", TypeRef::String),
2452 ],
2453 binding_excluded: false,
2454 binding_exclusion_reason: None,
2455 }
2456 }
2457
2458 #[test]
2459 fn test_gen_wasm_error_methods_empty_when_no_methods() {
2460 let error = sample_error(); let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
2462 assert!(output.is_empty(), "should produce no output when methods is empty");
2463 }
2464
2465 #[test]
2466 fn test_gen_wasm_error_methods_struct_and_impl() {
2467 let error = error_with_methods();
2468 let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
2471 assert!(
2473 output.contains("pub struct WasmLiterLlmError"),
2474 "must emit opaque struct: {output}"
2475 );
2476 assert!(
2477 output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
2478 "{output}"
2479 );
2480 assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
2482 assert!(output.contains("js_name = \"statusCode\""), "{output}");
2484 assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
2485 assert!(output.contains("self.inner.status_code()"), "{output}");
2486 assert!(output.contains("js_name = \"isTransient\""), "{output}");
2487 assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
2488 assert!(output.contains("self.inner.is_transient()"), "{output}");
2489 assert!(output.contains("js_name = \"errorType\""), "{output}");
2490 assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
2491 assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
2492 }
2493
2494 #[test]
2499 fn test_gen_ffi_error_methods_empty_when_no_methods() {
2500 let error = sample_error(); let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
2502 assert!(output.is_empty(), "should produce no output when methods is empty");
2503 }
2504
2505 #[test]
2506 fn test_gen_ffi_error_methods_status_code() {
2507 let error = error_with_methods();
2508 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2509 assert!(
2510 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
2511 "must emit status_code fn: {output}"
2512 );
2513 assert!(
2514 output.contains("err: *const liter_llm::error::LiterLlmError"),
2515 "{output}"
2516 );
2517 assert!(output.contains("-> u16"), "{output}");
2518 assert!(output.contains("(*err).status_code()"), "{output}");
2519 assert!(output.contains("if err.is_null()"), "{output}");
2520 assert!(output.contains("return 0;"), "{output}");
2521 }
2522
2523 #[test]
2524 fn test_gen_ffi_error_methods_is_transient() {
2525 let error = error_with_methods();
2526 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2527 assert!(
2528 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
2529 "must emit is_transient fn: {output}"
2530 );
2531 assert!(output.contains("-> bool"), "{output}");
2532 assert!(output.contains("(*err).is_transient()"), "{output}");
2533 assert!(output.contains("return false;"), "{output}");
2534 }
2535
2536 #[test]
2537 fn test_gen_ffi_error_methods_error_type_with_free() {
2538 let error = error_with_methods();
2539 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2540 assert!(
2541 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
2542 "must emit error_type fn: {output}"
2543 );
2544 assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
2545 assert!(output.contains("(*err).error_type()"), "{output}");
2546 assert!(output.contains("CString::new(s)"), "{output}");
2547 assert!(output.contains(".into_raw()"), "{output}");
2548 assert!(output.contains("return std::ptr::null_mut();"), "{output}");
2549 assert!(
2551 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
2552 "must emit _free companion: {output}"
2553 );
2554 assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
2555 }
2556
2557 #[test]
2558 fn test_gen_ffi_error_methods_safety_comments() {
2559 let error = error_with_methods();
2560 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2561 assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
2562 }
2563}