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 sanitized_error_doc =
1398 crate::doc_emission::sanitize_rust_idioms(&error.doc, crate::doc_emission::DocTarget::CSharpDoc);
1399 let error_doc_lines: Vec<&str> = sanitized_error_doc.lines().collect();
1400 let error_has_doc = !sanitized_error_doc.trim().is_empty();
1401
1402 let method_infos: Vec<serde_json::Value> = error
1405 .methods
1406 .iter()
1407 .map(|m| {
1408 let cs_type = typeref_to_csharp_type(&m.return_type);
1409 let prop_name = to_pascal_case(&m.name);
1410 let param_name = java_field_name(&m.name); let default_value = csharp_default_value(&m.return_type);
1412 let sanitized_method_doc =
1415 crate::doc_emission::sanitize_rust_idioms(&m.doc, crate::doc_emission::DocTarget::CSharpDoc);
1416 let inline_doc = sanitized_method_doc
1417 .lines()
1418 .map(str::trim)
1419 .filter(|l| !l.is_empty())
1420 .collect::<Vec<_>>()
1421 .join(" ");
1422 serde_json::json!({
1423 "prop_name": prop_name,
1424 "cs_type": cs_type,
1425 "param_name": param_name,
1426 "default_value": default_value,
1427 "doc": inline_doc,
1428 })
1429 })
1430 .collect();
1431 let has_methods = !method_infos.is_empty();
1432
1433 {
1435 let out = crate::template_env::render(
1436 "error_gen/csharp_error_base.jinja",
1437 minijinja::context! {
1438 namespace => namespace,
1439 base_name => base_name.as_str(),
1440 base_parent => base_parent,
1441 doc => error_has_doc,
1442 doc_lines => error_doc_lines,
1443 methods => method_infos,
1444 has_methods => has_methods,
1445 },
1446 );
1447 files.push((base_name.clone(), out));
1448 }
1449
1450 for variant in &error.variants {
1452 let class_name = format!("{}Exception", variant.name);
1453 let sanitized_variant_doc =
1454 crate::doc_emission::sanitize_rust_idioms(&variant.doc, crate::doc_emission::DocTarget::CSharpDoc);
1455 let variant_doc_lines: Vec<&str> = sanitized_variant_doc.lines().collect();
1456 let variant_has_doc = !sanitized_variant_doc.trim().is_empty();
1457
1458 let out = crate::template_env::render(
1459 "error_gen/csharp_error_variant.jinja",
1460 minijinja::context! {
1461 namespace => namespace,
1462 class_name => class_name.as_str(),
1463 base_name => base_name.as_str(),
1464 doc => variant_has_doc,
1465 doc_lines => variant_doc_lines,
1466 has_methods => has_methods,
1467 },
1468 );
1469 files.push((class_name, out));
1470 }
1471
1472 files
1473}
1474
1475fn typeref_to_csharp_type(ty: &alef_core::ir::TypeRef) -> &'static str {
1477 use alef_core::ir::{PrimitiveType, TypeRef};
1478 match ty {
1479 TypeRef::Primitive(PrimitiveType::Bool) => "bool",
1480 TypeRef::Primitive(PrimitiveType::U8) => "byte",
1481 TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
1482 TypeRef::Primitive(PrimitiveType::I16) => "short",
1483 TypeRef::Primitive(PrimitiveType::U16) => "ushort",
1484 TypeRef::Primitive(PrimitiveType::I32) => "int",
1485 TypeRef::Primitive(PrimitiveType::U32) => "uint",
1486 TypeRef::Primitive(PrimitiveType::I64) => "long",
1487 TypeRef::Primitive(PrimitiveType::U64) => "ulong",
1488 TypeRef::Primitive(PrimitiveType::F32) => "float",
1489 TypeRef::Primitive(PrimitiveType::F64) => "double",
1490 TypeRef::String => "string",
1491 _ => "string",
1492 }
1493}
1494
1495fn csharp_default_value(ty: &alef_core::ir::TypeRef) -> &'static str {
1497 use alef_core::ir::{PrimitiveType, TypeRef};
1498 match ty {
1499 TypeRef::Primitive(PrimitiveType::Bool) => "false",
1500 TypeRef::String => "string.Empty",
1501 _ => "0",
1502 }
1503}
1504
1505fn to_screaming_snake(s: &str) -> String {
1511 let mut result = String::with_capacity(s.len() + 4);
1512 for (i, c) in s.chars().enumerate() {
1513 if c.is_uppercase() {
1514 if i > 0 {
1515 result.push('_');
1516 }
1517 result.push(c.to_ascii_uppercase());
1518 } else {
1519 result.push(c.to_ascii_uppercase());
1520 }
1521 }
1522 result
1523}
1524
1525const TECHNICAL_ACRONYMS: &[&str] = &[
1532 "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
1533 "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
1534 "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
1535 "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
1536];
1537
1538pub fn strip_thiserror_placeholders(template: &str) -> String {
1552 let mut without_placeholders = String::with_capacity(template.len());
1554 let mut depth = 0u32;
1555 for ch in template.chars() {
1556 match ch {
1557 '{' => depth = depth.saturating_add(1),
1558 '}' => depth = depth.saturating_sub(1),
1559 other if depth == 0 => without_placeholders.push(other),
1560 _ => {}
1561 }
1562 }
1563 let mut compacted = String::with_capacity(without_placeholders.len());
1567 let mut last_was_space = false;
1568 for ch in without_placeholders.chars() {
1569 if ch.is_whitespace() {
1570 if !last_was_space && !compacted.is_empty() {
1571 compacted.push(' ');
1572 }
1573 last_was_space = true;
1574 } else {
1575 compacted.push(ch);
1576 last_was_space = false;
1577 }
1578 }
1579 let trimmed = compacted
1581 .trim()
1582 .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
1583 .trim();
1584 let cleaned = trimmed
1587 .replace("()", "")
1588 .replace("''", "")
1589 .replace("\"\"", "")
1590 .replace(" ", " ");
1591 cleaned.trim().to_string()
1592}
1593
1594pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
1604 if variant_name.is_empty() {
1605 return String::new();
1606 }
1607 let bytes = variant_name.as_bytes();
1609 let mut words: Vec<&str> = Vec::new();
1610 let mut start = 0usize;
1611 for i in 1..bytes.len() {
1612 if bytes[i].is_ascii_uppercase() {
1613 words.push(&variant_name[start..i]);
1614 start = i;
1615 }
1616 }
1617 words.push(&variant_name[start..]);
1618
1619 let mut rendered: Vec<String> = Vec::with_capacity(words.len());
1620 for word in &words {
1621 let upper = word.to_ascii_uppercase();
1622 if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
1623 rendered.push(upper);
1624 } else {
1625 rendered.push(word.to_ascii_lowercase());
1626 }
1627 }
1628 rendered.join(" ")
1629}
1630
1631fn variant_display_message(variant: &ErrorVariant) -> String {
1636 if let Some(tmpl) = &variant.message_template {
1637 let stripped = strip_thiserror_placeholders(tmpl);
1638 if stripped.is_empty() {
1639 return acronym_aware_snake_phrase(&variant.name);
1640 }
1641 let mut tokens = stripped.splitn(2, ' ');
1646 let head = tokens.next().unwrap_or("").to_string();
1647 let tail = tokens.next().unwrap_or("");
1648 let head_upper = head.to_ascii_uppercase();
1649 let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
1650 head_upper
1651 } else {
1652 let mut chars = head.chars();
1653 match chars.next() {
1654 Some(c) => c.to_lowercase().to_string() + chars.as_str(),
1655 None => head,
1656 }
1657 };
1658 if tail.is_empty() {
1659 head_rendered
1660 } else {
1661 format!("{} {}", head_rendered, tail)
1662 }
1663 } else {
1664 acronym_aware_snake_phrase(&variant.name)
1665 }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use super::*;
1671 use alef_core::ir::{ErrorDef, ErrorVariant};
1672
1673 use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
1674
1675 fn tuple_field(index: usize) -> FieldDef {
1677 FieldDef {
1678 name: format!("_{index}"),
1679 ty: TypeRef::String,
1680 optional: false,
1681 default: None,
1682 doc: String::new(),
1683 sanitized: false,
1684 is_boxed: false,
1685 type_rust_path: None,
1686 cfg: None,
1687 typed_default: None,
1688 core_wrapper: CoreWrapper::None,
1689 vec_inner_core_wrapper: CoreWrapper::None,
1690 newtype_wrapper: None,
1691 serde_rename: None,
1692 serde_flatten: false,
1693 binding_excluded: false,
1694 binding_exclusion_reason: None,
1695 original_type: None,
1696 }
1697 }
1698
1699 fn named_field(name: &str) -> FieldDef {
1701 FieldDef {
1702 name: name.to_string(),
1703 ty: TypeRef::String,
1704 optional: false,
1705 default: None,
1706 doc: String::new(),
1707 sanitized: false,
1708 is_boxed: false,
1709 type_rust_path: None,
1710 cfg: None,
1711 typed_default: None,
1712 core_wrapper: CoreWrapper::None,
1713 vec_inner_core_wrapper: CoreWrapper::None,
1714 newtype_wrapper: None,
1715 serde_rename: None,
1716 serde_flatten: false,
1717 binding_excluded: false,
1718 binding_exclusion_reason: None,
1719 original_type: None,
1720 }
1721 }
1722
1723 fn sample_error() -> ErrorDef {
1724 ErrorDef {
1725 name: "ConversionError".to_string(),
1726 rust_path: "html_to_markdown_rs::ConversionError".to_string(),
1727 original_rust_path: String::new(),
1728 variants: vec![
1729 ErrorVariant {
1730 name: "ParseError".to_string(),
1731 message_template: Some("HTML parsing error: {0}".to_string()),
1732 fields: vec![tuple_field(0)],
1733 has_source: false,
1734 has_from: false,
1735 is_unit: false,
1736 doc: String::new(),
1737 },
1738 ErrorVariant {
1739 name: "IoError".to_string(),
1740 message_template: Some("I/O error: {0}".to_string()),
1741 fields: vec![tuple_field(0)],
1742 has_source: false,
1743 has_from: true,
1744 is_unit: false,
1745 doc: String::new(),
1746 },
1747 ErrorVariant {
1748 name: "Other".to_string(),
1749 message_template: Some("Conversion error: {0}".to_string()),
1750 fields: vec![tuple_field(0)],
1751 has_source: false,
1752 has_from: false,
1753 is_unit: false,
1754 doc: String::new(),
1755 },
1756 ],
1757 doc: "Error type for conversion operations.".to_string(),
1758 methods: vec![],
1759 binding_excluded: false,
1760 binding_exclusion_reason: None,
1761 }
1762 }
1763
1764 #[test]
1765 fn test_gen_error_types() {
1766 let error = sample_error();
1767 let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
1768 assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
1769 assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
1770 assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
1771 assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
1772 }
1773
1774 #[test]
1775 fn test_gen_error_converter() {
1776 let error = sample_error();
1777 let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
1778 assert!(
1779 output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
1780 );
1781 assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
1782 assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
1783 }
1784
1785 #[test]
1786 fn test_gen_error_registration() {
1787 let error = sample_error();
1788 let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
1789 assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
1791 assert!(regs[3].contains("\"ConversionError\""));
1792 }
1793
1794 #[test]
1795 fn test_unit_variant_pattern() {
1796 let error = ErrorDef {
1797 name: "MyError".to_string(),
1798 rust_path: "my_crate::MyError".to_string(),
1799 original_rust_path: String::new(),
1800 variants: vec![ErrorVariant {
1801 name: "NotFound".to_string(),
1802 message_template: Some("not found".to_string()),
1803 fields: vec![],
1804 has_source: false,
1805 has_from: false,
1806 is_unit: true,
1807 doc: String::new(),
1808 }],
1809 doc: String::new(),
1810 methods: vec![],
1811 binding_excluded: false,
1812 binding_exclusion_reason: None,
1813 };
1814 let output = gen_pyo3_error_converter(&error, "my_crate");
1815 assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
1816 assert!(!output.contains("NotFound(..)"));
1818 }
1819
1820 #[test]
1821 fn test_struct_variant_pattern() {
1822 let error = ErrorDef {
1823 name: "MyError".to_string(),
1824 rust_path: "my_crate::MyError".to_string(),
1825 original_rust_path: String::new(),
1826 variants: vec![ErrorVariant {
1827 name: "Parsing".to_string(),
1828 message_template: Some("parsing error: {message}".to_string()),
1829 fields: vec![named_field("message")],
1830 has_source: false,
1831 has_from: false,
1832 is_unit: false,
1833 doc: String::new(),
1834 }],
1835 doc: String::new(),
1836 methods: vec![],
1837 binding_excluded: false,
1838 binding_exclusion_reason: None,
1839 };
1840 let output = gen_pyo3_error_converter(&error, "my_crate");
1841 assert!(
1842 output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
1843 "Struct variants must use {{ .. }} pattern, got:\n{output}"
1844 );
1845 assert!(!output.contains("Parsing(..)"));
1847 }
1848
1849 #[test]
1854 fn test_gen_napi_error_types() {
1855 let error = sample_error();
1856 let output = gen_napi_error_types(&error);
1857 assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
1858 assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
1859 assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
1860 }
1861
1862 #[test]
1863 fn test_gen_napi_error_converter() {
1864 let error = sample_error();
1865 let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
1866 assert!(
1867 output
1868 .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1869 );
1870 assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1871 assert!(output.contains("[ParseError]"));
1872 assert!(output.contains("[IoError]"));
1873 assert!(output.contains("#[allow(dead_code)]"));
1874 }
1875
1876 #[test]
1877 fn test_napi_unit_variant() {
1878 let error = ErrorDef {
1879 name: "MyError".to_string(),
1880 rust_path: "my_crate::MyError".to_string(),
1881 original_rust_path: String::new(),
1882 variants: vec![ErrorVariant {
1883 name: "NotFound".to_string(),
1884 message_template: None,
1885 fields: vec![],
1886 has_source: false,
1887 has_from: false,
1888 is_unit: true,
1889 doc: String::new(),
1890 }],
1891 doc: String::new(),
1892 methods: vec![],
1893 binding_excluded: false,
1894 binding_exclusion_reason: None,
1895 };
1896 let output = gen_napi_error_converter(&error, "my_crate");
1897 assert!(output.contains("my_crate::MyError::NotFound =>"));
1898 assert!(!output.contains("NotFound(..)"));
1899 }
1900
1901 #[test]
1906 fn test_gen_wasm_error_converter() {
1907 let error = sample_error();
1908 let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1909 assert!(output.contains(
1911 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1912 ));
1913 assert!(output.contains("js_sys::Object::new()"));
1915 assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1916 assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1917 assert!(output.contains("obj.into()"));
1918 assert!(
1920 output
1921 .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1922 );
1923 assert!(output.contains("\"parse_error\""));
1924 assert!(output.contains("\"io_error\""));
1925 assert!(output.contains("\"other\""));
1926 assert!(output.contains("#[allow(dead_code)]"));
1927 }
1928
1929 #[test]
1934 fn test_gen_php_error_converter() {
1935 let error = sample_error();
1936 let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1937 assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1938 assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1939 assert!(output.contains("#[allow(dead_code)]"));
1940 }
1941
1942 #[test]
1947 fn test_gen_magnus_error_converter() {
1948 let error = sample_error();
1949 let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1950 assert!(
1951 output.contains(
1952 "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1953 )
1954 );
1955 assert!(
1956 output.contains(
1957 "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1958 )
1959 );
1960 assert!(output.contains("#[allow(dead_code)]"));
1961 }
1962
1963 #[test]
1968 fn test_gen_rustler_error_converter() {
1969 let error = sample_error();
1970 let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1971 assert!(
1972 output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1973 );
1974 assert!(output.contains("e.to_string()"));
1975 assert!(output.contains("#[allow(dead_code)]"));
1976 }
1977
1978 #[test]
1983 fn test_gen_go_error_struct_with_methods() {
1984 let error = error_with_methods();
1985 let output = gen_go_error_struct(&error, "literllm");
1986 assert!(output.contains("type Error struct {"), "struct def: {output}");
1988 assert!(output.contains("StatusCode uint16"), "StatusCode field: {output}");
1991 assert!(output.contains("IsTransient bool"), "IsTransient field: {output}");
1992 assert!(output.contains("ErrorType string"), "ErrorType field: {output}");
1993 assert!(
1995 !output.contains("func (e Error) StatusCode()"),
1996 "no StatusCode accessor: {output}"
1997 );
1998 assert!(
1999 !output.contains("func (e Error) IsTransient()"),
2000 "no IsTransient accessor: {output}"
2001 );
2002 assert!(
2003 !output.contains("func (e Error) ErrorType()"),
2004 "no ErrorType accessor: {output}"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_gen_go_error_struct_no_field_method_collision() {
2010 use alef_core::ir::{ErrorDef, ErrorVariant, PrimitiveType, TypeRef};
2013 let error = ErrorDef {
2014 name: "ApiError".to_string(),
2015 rust_path: String::new(),
2016 original_rust_path: String::new(),
2017 doc: String::new(),
2018 variants: vec![ErrorVariant {
2019 name: "Network".to_string(),
2020 message_template: None,
2021 fields: vec![],
2022 has_source: false,
2023 has_from: false,
2024 is_unit: true,
2025 doc: String::new(),
2026 }],
2027 methods: vec![
2028 sample_method("retry_count", TypeRef::Primitive(PrimitiveType::U32)),
2029 sample_method("permanent", TypeRef::Primitive(PrimitiveType::Bool)),
2030 ],
2031 binding_excluded: false,
2032 binding_exclusion_reason: None,
2033 };
2034 let output = gen_go_error_struct(&error, "mypkg");
2035 assert!(output.contains("RetryCount uint32"), "RetryCount field: {output}");
2037 assert!(output.contains("Permanent bool"), "Permanent field: {output}");
2038 assert!(
2041 !output.contains("func (e ApiError) RetryCount()"),
2042 "no RetryCount accessor: {output}"
2043 );
2044 assert!(
2045 !output.contains("func (e ApiError) Permanent()"),
2046 "no Permanent accessor: {output}"
2047 );
2048 }
2049
2050 #[test]
2051 fn test_gen_go_error_struct_no_methods() {
2052 let error = sample_error(); let output = gen_go_error_struct(&error, "mylib");
2054 assert!(output.contains("type ConversionError struct {"), "{output}");
2055 assert!(!output.contains("StatusCode"), "{output}");
2056 assert!(!output.contains("IsTransient"), "{output}");
2057 }
2058
2059 #[test]
2064 fn test_gen_java_error_types_with_methods() {
2065 let error = error_with_methods();
2066 let files = gen_java_error_types(&error, "dev.kreuzberg.literllm");
2067 assert_eq!(files.len(), 1); let base = &files[0].1;
2069 assert!(
2070 base.contains("private final int statusCode;"),
2071 "statusCode field: {base}"
2072 );
2073 assert!(
2074 base.contains("private final boolean isTransient;"),
2075 "isTransient field: {base}"
2076 );
2077 assert!(
2078 base.contains("private final String errorType;"),
2079 "errorType field: {base}"
2080 );
2081 assert!(
2082 base.contains("public int getStatusCode()"),
2083 "getStatusCode getter: {base}"
2084 );
2085 assert!(
2086 base.contains("public boolean isTransient()"),
2087 "isTransient getter: {base}"
2088 );
2089 assert!(
2090 base.contains("public String getErrorType()"),
2091 "getErrorType getter: {base}"
2092 );
2093 assert!(
2095 base.contains("public LiterLlmErrorException(final String message)"),
2096 "simple ctor: {base}"
2097 );
2098 assert!(
2100 base.contains("public LiterLlmErrorException(final String message, final int statusCode, final boolean isTransient, final String errorType)"),
2101 "full ctor: {base}"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_gen_java_error_types_no_methods() {
2107 let error = sample_error(); let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2109 let base = &files[0].1;
2110 assert!(!base.contains("private final"), "no fields when no methods: {base}");
2111 assert!(
2112 base.contains("public ConversionErrorException(final String message)"),
2113 "{base}"
2114 );
2115 }
2116
2117 #[test]
2122 fn test_gen_csharp_error_types_with_methods() {
2123 let error = error_with_methods();
2124 let files = gen_csharp_error_types(&error, "Kreuzberg.LiterLlm", None);
2125 assert_eq!(files.len(), 1); let base = &files[0].1;
2127 assert!(
2128 base.contains("public ushort StatusCode { get; }"),
2129 "StatusCode prop: {base}"
2130 );
2131 assert!(
2132 base.contains("public bool IsTransient { get; }"),
2133 "IsTransient prop: {base}"
2134 );
2135 assert!(
2136 base.contains("public string ErrorType { get; }"),
2137 "ErrorType prop: {base}"
2138 );
2139 assert!(
2141 base.contains("public LiterLlmErrorException(string message) : base(message)"),
2142 "simple ctor: {base}"
2143 );
2144 assert!(
2146 base.contains("public LiterLlmErrorException(string message, ushort statusCode, bool isTransient, string errorType) : base(message)"),
2147 "full ctor: {base}"
2148 );
2149 }
2150
2151 #[test]
2152 fn test_gen_csharp_error_types_no_methods() {
2153 let error = sample_error(); let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2155 let base = &files[0].1;
2156 assert!(!base.contains("{ get; }"), "no properties when no methods: {base}");
2157 assert!(
2158 base.contains("public ConversionErrorException(string message) : base(message) { }"),
2159 "{base}"
2160 );
2161 }
2162
2163 #[test]
2168 fn test_gen_csharp_error_types_strips_rust_idioms_in_doc() {
2169 let mut error = error_with_methods();
2170 error.name = "GraphQLError".to_string();
2171 error.doc = "Errors that can occur during GraphQL operations\n\n\
2172 These errors are compatible with async-graphql error handling.\n"
2173 .to_string();
2174 error.methods[0].doc = "Convert error to HTTP status code\n\n\
2179 Public alias for the same codes returned by [`Self::error_code`].\n\n\
2180 # Examples\n\n\
2181 ```ignore\n\
2182 use spikard_graphql::error::GraphQLError;\n\
2183 let error = GraphQLError::AuthenticationError(\"Invalid token\".to_string());\n\
2184 assert_eq!(error.status_code(), 401);\n\
2185 ```\n"
2186 .to_string();
2187 let files = gen_csharp_error_types(&error, "Spikard", None);
2188 let base = &files[0].1;
2189 assert!(
2192 !base.contains("```"),
2193 "code fence markers must not leak into <summary>: {base}"
2194 );
2195 assert!(!base.contains("# Examples"), "section heading must be stripped: {base}");
2196 assert!(
2197 !base.contains("Self::error_code"),
2198 "Self::method must be normalised: {base}"
2199 );
2200 assert!(!base.contains("[`"), "intra-doc link brackets must be stripped: {base}");
2201 assert!(
2202 !base.contains("GraphQLError::AuthenticationError"),
2203 "rust path inside fence must be dropped: {base}"
2204 );
2205 assert!(
2207 base.contains("Convert error to HTTP status code"),
2208 "first prose line survives: {base}"
2209 );
2210 assert!(
2212 base.contains("Errors that can occur during GraphQL operations"),
2213 "base error prose survives: {base}"
2214 );
2215 }
2216
2217 #[test]
2222 fn test_to_screaming_snake() {
2223 assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
2224 assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
2225 assert_eq!(to_screaming_snake("Other"), "OTHER");
2226 }
2227
2228 #[test]
2229 fn test_strip_thiserror_placeholders_struct_field() {
2230 assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
2231 assert_eq!(
2232 strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
2233 "plugin error in"
2234 );
2235 let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
2238 assert!(!result.contains('{'), "no braces: {result}");
2239 assert!(!result.contains('}'), "no braces: {result}");
2240 assert!(result.starts_with("extraction timed out after"), "{result}");
2241 }
2242
2243 #[test]
2244 fn test_strip_thiserror_placeholders_positional() {
2245 assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
2246 assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
2247 }
2248
2249 #[test]
2250 fn test_strip_thiserror_placeholders_no_placeholder() {
2251 assert_eq!(strip_thiserror_placeholders("not found"), "not found");
2252 assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
2253 }
2254
2255 #[test]
2256 fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
2257 assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
2258 assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
2259 assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
2260 assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
2261 assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
2262 }
2263
2264 #[test]
2265 fn test_acronym_aware_snake_phrase_plain_words() {
2266 assert_eq!(acronym_aware_snake_phrase("Other"), "other");
2267 assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
2268 assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
2269 }
2270
2271 #[test]
2272 fn test_variant_display_message_acronym_first_word() {
2273 let variant = ErrorVariant {
2274 name: "Io".to_string(),
2275 message_template: Some("I/O error: {0}".to_string()),
2276 fields: vec![tuple_field(0)],
2277 has_source: false,
2278 has_from: false,
2279 is_unit: false,
2280 doc: String::new(),
2281 };
2282 let msg = variant_display_message(&variant);
2285 assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
2286 }
2287
2288 #[test]
2289 fn test_variant_display_message_no_template_uses_acronyms() {
2290 let variant = ErrorVariant {
2291 name: "IoError".to_string(),
2292 message_template: None,
2293 fields: vec![],
2294 has_source: false,
2295 has_from: false,
2296 is_unit: false,
2297 doc: String::new(),
2298 };
2299 assert_eq!(variant_display_message(&variant), "IO error");
2300 }
2301
2302 #[test]
2303 fn test_variant_display_message_struct_template_no_leak() {
2304 let variant = ErrorVariant {
2305 name: "Ocr".to_string(),
2306 message_template: Some("OCR error: {message}".to_string()),
2307 fields: vec![named_field("message")],
2308 has_source: false,
2309 has_from: false,
2310 is_unit: false,
2311 doc: String::new(),
2312 };
2313 let msg = variant_display_message(&variant);
2314 assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
2315 }
2316
2317 #[test]
2318 fn test_go_sentinels_no_placeholder_leak() {
2319 let error = ErrorDef {
2320 name: "KreuzbergError".to_string(),
2321 rust_path: "kreuzberg::KreuzbergError".to_string(),
2322 original_rust_path: String::new(),
2323 variants: vec![
2324 ErrorVariant {
2325 name: "Io".to_string(),
2326 message_template: Some("IO error: {message}".to_string()),
2327 fields: vec![named_field("message")],
2328 has_source: false,
2329 has_from: false,
2330 is_unit: false,
2331 doc: String::new(),
2332 },
2333 ErrorVariant {
2334 name: "Ocr".to_string(),
2335 message_template: Some("OCR error: {message}".to_string()),
2336 fields: vec![named_field("message")],
2337 has_source: false,
2338 has_from: false,
2339 is_unit: false,
2340 doc: String::new(),
2341 },
2342 ErrorVariant {
2343 name: "Timeout".to_string(),
2344 message_template: Some(
2345 "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
2346 ),
2347 fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
2348 has_source: false,
2349 has_from: false,
2350 is_unit: false,
2351 doc: String::new(),
2352 },
2353 ],
2354 doc: String::new(),
2355 methods: vec![],
2356 binding_excluded: false,
2357 binding_exclusion_reason: None,
2358 };
2359 let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
2360 assert!(
2361 !output.contains('{'),
2362 "Go sentinels must not contain raw placeholders:\n{output}"
2363 );
2364 assert!(
2365 output.contains("ErrIo = errors.New(\"IO error\")"),
2366 "expected acronym-preserving Io sentinel, got:\n{output}"
2367 );
2368 assert!(
2369 output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
2370 "Go sentinel comments must be emitted on separate lines, got:\n{output}"
2371 );
2372 assert!(
2373 output.contains("ErrOcr = errors.New(\"OCR error\")"),
2374 "expected acronym-preserving Ocr sentinel, got:\n{output}"
2375 );
2376 assert!(
2377 output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
2378 "expected timeout sentinel to start with the prose, got:\n{output}"
2379 );
2380 }
2381
2382 #[test]
2387 fn test_gen_ffi_error_codes() {
2388 let error = sample_error();
2389 let output = gen_ffi_error_codes(&error);
2390 assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
2391 assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
2392 assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
2393 assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
2394 assert!(output.contains("conversion_error_t;"));
2395 assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
2396 }
2397
2398 #[test]
2403 fn test_gen_go_error_types() {
2404 let error = sample_error();
2405 let output = gen_go_error_types(&error, "mylib");
2407 assert!(output.contains("ErrParseError = errors.New("));
2408 assert!(output.contains("ErrIoError = errors.New("));
2409 assert!(output.contains("ErrOther = errors.New("));
2410 assert!(output.contains("type ConversionError struct {"));
2411 assert!(output.contains("Code string"));
2412 assert!(output.contains("func (e ConversionError) Error() string"));
2413 assert!(output.contains("// ErrParseError is returned when"));
2415 assert!(output.contains("// ErrIoError is returned when"));
2416 assert!(output.contains("// ErrOther is returned when"));
2417 }
2418
2419 #[test]
2420 fn test_gen_go_error_types_stutter_strip() {
2421 let error = sample_error();
2422 let output = gen_go_error_types(&error, "conversion");
2425 assert!(
2426 output.contains("type Error struct {"),
2427 "expected stutter strip, got:\n{output}"
2428 );
2429 assert!(
2430 output.contains("func (e Error) Error() string"),
2431 "expected stutter strip, got:\n{output}"
2432 );
2433 assert!(output.contains("ErrParseError = errors.New("));
2435 }
2436
2437 #[test]
2442 fn test_gen_java_error_types() {
2443 let error = sample_error();
2444 let files = gen_java_error_types(&error, "dev.kreuzberg.test");
2445 assert_eq!(files.len(), 4);
2447 assert_eq!(files[0].0, "ConversionErrorException");
2449 assert!(
2450 files[0]
2451 .1
2452 .contains("public class ConversionErrorException extends Exception")
2453 );
2454 assert!(files[0].1.contains("package dev.kreuzberg.test;"));
2455 assert_eq!(files[1].0, "ParseErrorException");
2457 assert!(
2458 files[1]
2459 .1
2460 .contains("public class ParseErrorException extends ConversionErrorException")
2461 );
2462 assert_eq!(files[2].0, "IoErrorException");
2463 assert_eq!(files[3].0, "OtherException");
2464 }
2465
2466 #[test]
2471 fn test_gen_csharp_error_types() {
2472 let error = sample_error();
2473 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
2475 assert_eq!(files.len(), 4);
2476 assert_eq!(files[0].0, "ConversionErrorException");
2477 assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
2478 assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
2479 assert_eq!(files[1].0, "ParseErrorException");
2480 assert!(
2481 files[1]
2482 .1
2483 .contains("public class ParseErrorException : ConversionErrorException")
2484 );
2485 assert_eq!(files[2].0, "IoErrorException");
2486 assert_eq!(files[3].0, "OtherException");
2487 }
2488
2489 #[test]
2490 fn test_gen_csharp_error_types_with_fallback() {
2491 let error = sample_error();
2492 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
2494 assert_eq!(files.len(), 4);
2495 assert!(
2496 files[0]
2497 .1
2498 .contains("public class ConversionErrorException : TestLibException")
2499 );
2500 assert!(
2502 files[1]
2503 .1
2504 .contains("public class ParseErrorException : ConversionErrorException")
2505 );
2506 }
2507
2508 #[test]
2513 fn test_python_exception_name_no_conflict() {
2514 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
2516 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
2518 }
2519
2520 #[test]
2521 fn test_python_exception_name_shadows_builtin() {
2522 assert_eq!(
2524 python_exception_name("Connection", "CrawlError"),
2525 "CrawlConnectionError"
2526 );
2527 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
2529 assert_eq!(
2531 python_exception_name("ConnectionError", "CrawlError"),
2532 "CrawlConnectionError"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_python_exception_name_no_double_prefix() {
2538 assert_eq!(
2540 python_exception_name("CrawlConnectionError", "CrawlError"),
2541 "CrawlConnectionError"
2542 );
2543 }
2544
2545 fn sample_method(name: &str, return_type: TypeRef) -> alef_core::ir::MethodDef {
2550 alef_core::ir::MethodDef {
2551 name: name.to_string(),
2552 params: vec![],
2553 return_type,
2554 is_async: false,
2555 is_static: false,
2556 error_type: None,
2557 doc: String::new(),
2558 receiver: Some(alef_core::ir::ReceiverKind::Ref),
2559 sanitized: false,
2560 trait_source: None,
2561 returns_ref: false,
2562 returns_cow: false,
2563 return_newtype_wrapper: None,
2564 has_default_impl: false,
2565 binding_excluded: false,
2566 binding_exclusion_reason: None,
2567 }
2568 }
2569
2570 fn error_with_methods() -> ErrorDef {
2571 ErrorDef {
2572 name: "LiterLlmError".to_string(),
2573 rust_path: "liter_llm::error::LiterLlmError".to_string(),
2574 original_rust_path: String::new(),
2575 variants: vec![],
2576 doc: String::new(),
2577 methods: vec![
2578 sample_method("status_code", TypeRef::Primitive(alef_core::ir::PrimitiveType::U16)),
2579 sample_method("is_transient", TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool)),
2580 sample_method("error_type", TypeRef::String),
2581 ],
2582 binding_excluded: false,
2583 binding_exclusion_reason: None,
2584 }
2585 }
2586
2587 #[test]
2588 fn test_gen_wasm_error_methods_empty_when_no_methods() {
2589 let error = sample_error(); let output = gen_wasm_error_methods(&error, "html_to_markdown_rs", "");
2591 assert!(output.is_empty(), "should produce no output when methods is empty");
2592 }
2593
2594 #[test]
2595 fn test_gen_wasm_error_methods_struct_and_impl() {
2596 let error = error_with_methods();
2597 let output = gen_wasm_error_methods(&error, "liter_llm", "Wasm");
2600 assert!(
2602 output.contains("pub struct WasmLiterLlmError"),
2603 "must emit opaque struct: {output}"
2604 );
2605 assert!(
2606 output.contains("pub(crate) inner: liter_llm::error::LiterLlmError"),
2607 "{output}"
2608 );
2609 assert!(output.contains("#[wasm_bindgen]\nimpl WasmLiterLlmError"), "{output}");
2611 assert!(output.contains("js_name = \"statusCode\""), "{output}");
2613 assert!(output.contains("pub fn status_code(&self) -> u16"), "{output}");
2614 assert!(output.contains("self.inner.status_code()"), "{output}");
2615 assert!(output.contains("js_name = \"isTransient\""), "{output}");
2616 assert!(output.contains("pub fn is_transient(&self) -> bool"), "{output}");
2617 assert!(output.contains("self.inner.is_transient()"), "{output}");
2618 assert!(output.contains("js_name = \"errorType\""), "{output}");
2619 assert!(output.contains("pub fn error_type(&self) -> String"), "{output}");
2620 assert!(output.contains("self.inner.error_type().to_string()"), "{output}");
2621 }
2622
2623 #[test]
2628 fn test_gen_ffi_error_methods_empty_when_no_methods() {
2629 let error = sample_error(); let output = gen_ffi_error_methods(&error, "html_to_markdown_rs", "h2m");
2631 assert!(output.is_empty(), "should produce no output when methods is empty");
2632 }
2633
2634 #[test]
2635 fn test_gen_ffi_error_methods_status_code() {
2636 let error = error_with_methods();
2637 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2638 assert!(
2639 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_status_code("),
2640 "must emit status_code fn: {output}"
2641 );
2642 assert!(
2643 output.contains("err: *const liter_llm::error::LiterLlmError"),
2644 "{output}"
2645 );
2646 assert!(output.contains("-> u16"), "{output}");
2647 assert!(output.contains("(*err).status_code()"), "{output}");
2648 assert!(output.contains("if err.is_null()"), "{output}");
2649 assert!(output.contains("return 0;"), "{output}");
2650 }
2651
2652 #[test]
2653 fn test_gen_ffi_error_methods_is_transient() {
2654 let error = error_with_methods();
2655 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2656 assert!(
2657 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_is_transient("),
2658 "must emit is_transient fn: {output}"
2659 );
2660 assert!(output.contains("-> bool"), "{output}");
2661 assert!(output.contains("(*err).is_transient()"), "{output}");
2662 assert!(output.contains("return false;"), "{output}");
2663 }
2664
2665 #[test]
2666 fn test_gen_ffi_error_methods_error_type_with_free() {
2667 let error = error_with_methods();
2668 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2669 assert!(
2670 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type("),
2671 "must emit error_type fn: {output}"
2672 );
2673 assert!(output.contains("-> *mut std::ffi::c_char"), "{output}");
2674 assert!(output.contains("(*err).error_type()"), "{output}");
2675 assert!(output.contains("CString::new(s)"), "{output}");
2676 assert!(output.contains(".into_raw()"), "{output}");
2677 assert!(output.contains("return std::ptr::null_mut();"), "{output}");
2678 assert!(
2680 output.contains("pub unsafe extern \"C\" fn literllm_liter_llm_error_error_type_free("),
2681 "must emit _free companion: {output}"
2682 );
2683 assert!(output.contains("drop(std::ffi::CString::from_raw(ptr))"), "{output}");
2684 }
2685
2686 #[test]
2687 fn test_gen_ffi_error_methods_safety_comments() {
2688 let error = error_with_methods();
2689 let output = gen_ffi_error_methods(&error, "liter_llm", "literllm");
2690 assert!(output.contains("// SAFETY:"), "must include SAFETY comments: {output}");
2691 }
2692}