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 {
116 let rust_path = if error.rust_path.is_empty() {
117 format!("{core_import}::{}", error.name)
118 } else {
119 let normalized = error.rust_path.replace('-', "_");
120 let segments: Vec<&str> = normalized.split("::").collect();
124 if segments.len() > 2 {
125 let crate_name = segments[0];
126 let error_name = segments[segments.len() - 1];
127 format!("{crate_name}::{error_name}")
128 } else {
129 normalized
130 }
131 };
132
133 let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
134
135 let mut variants = Vec::new();
137 for variant in &error.variants {
138 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
139 let variant_exc_name = python_exception_name(&variant.name, &error.name);
140 variants.push((pattern, variant_exc_name));
141 }
142
143 crate::template_env::render(
144 "error_gen/pyo3_error_converter.jinja",
145 minijinja::context! {
146 rust_path => rust_path.as_str(),
147 fn_name => fn_name.as_str(),
148 error_name => error.name.as_str(),
149 variants => variants,
150 },
151 )
152}
153
154pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
158 let mut registrations = Vec::with_capacity(error.variants.len() + 1);
159
160 for variant in &error.variants {
161 let variant_exc_name = python_exception_name(&variant.name, &error.name);
162 if seen_registrations.insert(variant_exc_name.clone()) {
163 registrations.push(format!(
164 " m.add(\"{}\", m.py().get_type::<{}>())?;",
165 variant_exc_name, variant_exc_name
166 ));
167 }
168 }
169
170 if seen_registrations.insert(error.name.clone()) {
172 registrations.push(format!(
173 " m.add(\"{}\", m.py().get_type::<{}>())?;",
174 error.name, error.name
175 ));
176 }
177
178 registrations
179}
180
181pub fn converter_fn_name(error: &ErrorDef) -> String {
183 format!("{}_to_py_err", to_snake_case(&error.name))
184}
185
186fn to_snake_case(s: &str) -> String {
188 let mut result = String::with_capacity(s.len() + 4);
189 for (i, c) in s.chars().enumerate() {
190 if c.is_uppercase() {
191 if i > 0 {
192 result.push('_');
193 }
194 result.push(c.to_ascii_lowercase());
195 } else {
196 result.push(c);
197 }
198 }
199 result
200}
201
202pub fn gen_napi_error_types(error: &ErrorDef) -> String {
208 let mut variants = Vec::new();
210 let error_screaming = to_screaming_snake(&error.name);
211 for variant in &error.variants {
212 let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
213 variants.push((variant_const, variant.name.clone()));
214 }
215
216 crate::template_env::render(
217 "error_gen/napi_error_types.jinja",
218 minijinja::context! {
219 variants => variants,
220 },
221 )
222}
223
224pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
226 let rust_path = if error.rust_path.is_empty() {
227 format!("{core_import}::{}", error.name)
228 } else {
229 error.rust_path.replace('-', "_")
230 };
231
232 let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
233
234 let mut variants = Vec::new();
236 for variant in &error.variants {
237 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
238 variants.push((pattern, variant.name.clone()));
239 }
240
241 crate::template_env::render(
242 "error_gen/napi_error_converter.jinja",
243 minijinja::context! {
244 rust_path => rust_path.as_str(),
245 fn_name => fn_name.as_str(),
246 variants => variants,
247 },
248 )
249}
250
251pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
253 format!("{}_to_napi_err", to_snake_case(&error.name))
254}
255
256pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
264 let rust_path = if error.rust_path.is_empty() {
265 format!("{core_import}::{}", error.name)
266 } else {
267 error.rust_path.replace('-', "_")
268 };
269
270 let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
271 let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
272
273 let mut code_variants = Vec::new();
275 for variant in &error.variants {
276 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
277 let code = to_snake_case(&variant.name);
278 code_variants.push((pattern, code));
279 }
280 let default_code = to_snake_case(&error.name);
281
282 let code_fn = crate::template_env::render(
283 "error_gen/wasm_error_code_fn.jinja",
284 minijinja::context! {
285 rust_path => rust_path.as_str(),
286 code_fn_name => code_fn_name.as_str(),
287 variants => code_variants,
288 default_code => default_code.as_str(),
289 },
290 );
291
292 let converter_fn = crate::template_env::render(
293 "error_gen/wasm_error_converter.jinja",
294 minijinja::context! {
295 rust_path => rust_path.as_str(),
296 fn_name => fn_name.as_str(),
297 code_fn_name => code_fn_name.as_str(),
298 },
299 );
300
301 format!("{}\n\n{}", code_fn, converter_fn)
302}
303
304pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
306 format!("{}_to_js_value", to_snake_case(&error.name))
307}
308
309pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
315 let rust_path = if error.rust_path.is_empty() {
316 format!("{core_import}::{}", error.name)
317 } else {
318 error.rust_path.replace('-', "_")
319 };
320
321 let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
322
323 let mut variants = Vec::new();
325 for variant in &error.variants {
326 let pattern = error_variant_wildcard_pattern(&rust_path, variant);
327 variants.push((pattern, variant.name.clone()));
328 }
329
330 crate::template_env::render(
331 "error_gen/php_error_converter.jinja",
332 minijinja::context! {
333 rust_path => rust_path.as_str(),
334 fn_name => fn_name.as_str(),
335 variants => variants,
336 },
337 )
338}
339
340pub fn php_converter_fn_name(error: &ErrorDef) -> String {
342 format!("{}_to_php_err", to_snake_case(&error.name))
343}
344
345pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
351 let rust_path = if error.rust_path.is_empty() {
352 format!("{core_import}::{}", error.name)
353 } else {
354 error.rust_path.replace('-', "_")
355 };
356
357 let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
358
359 crate::template_env::render(
360 "error_gen/magnus_error_converter.jinja",
361 minijinja::context! {
362 rust_path => rust_path.as_str(),
363 fn_name => fn_name.as_str(),
364 },
365 )
366}
367
368pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
370 format!("{}_to_magnus_err", to_snake_case(&error.name))
371}
372
373pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
379 let rust_path = if error.rust_path.is_empty() {
380 format!("{core_import}::{}", error.name)
381 } else {
382 error.rust_path.replace('-', "_")
383 };
384
385 let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
386
387 crate::template_env::render(
388 "error_gen/rustler_error_converter.jinja",
389 minijinja::context! {
390 rust_path => rust_path.as_str(),
391 fn_name => fn_name.as_str(),
392 },
393 )
394}
395
396pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
398 format!("{}_to_rustler_err", to_snake_case(&error.name))
399}
400
401pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
410 let prefix = to_screaming_snake(&error.name);
411 let prefix_lower = to_snake_case(&error.name);
412
413 let mut variant_variants = Vec::new();
415 for (i, variant) in error.variants.iter().enumerate() {
416 let variant_screaming = to_screaming_snake(&variant.name);
417 variant_variants.push((variant_screaming, (i + 1).to_string()));
418 }
419
420 crate::template_env::render(
421 "error_gen/ffi_error_codes.jinja",
422 minijinja::context! {
423 error_name => error.name.as_str(),
424 prefix => prefix.as_str(),
425 prefix_lower => prefix_lower.as_str(),
426 variant_variants => variant_variants,
427 },
428 )
429}
430
431pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
442 let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
443 let structured = gen_go_error_struct(error, pkg_name);
444 format!("{}\n\n{}", sentinels, structured)
445}
446
447pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
458 if errors.is_empty() {
459 return String::new();
460 }
461 let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
462 for err in errors {
463 for v in &err.variants {
464 *variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
465 }
466 }
467 let mut seen = std::collections::HashSet::new();
468 let mut sentinels = Vec::new();
469 for err in errors {
470 let parent_base = error_base_prefix(&err.name);
471 for variant in &err.variants {
472 let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
473 let const_name = if collides {
474 format!("Err{}{}", parent_base, variant.name)
475 } else {
476 format!("Err{}", variant.name)
477 };
478 if !seen.insert(const_name.clone()) {
479 continue;
480 }
481 let msg = variant_display_message(variant);
482 sentinels.push((const_name, msg));
483 }
484 }
485
486 crate::template_env::render(
487 "error_gen/go_sentinel_errors.jinja",
488 minijinja::context! {
489 sentinels => sentinels,
490 },
491 )
492}
493
494pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
498 let go_type_name = strip_package_prefix(&error.name, pkg_name);
499
500 crate::template_env::render(
501 "error_gen/go_error_struct.jinja",
502 minijinja::context! {
503 go_type_name => go_type_name.as_str(),
504 },
505 )
506}
507
508fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
520 let type_lower = type_name.to_lowercase();
521 let pkg_lower = pkg_name.to_lowercase();
522 if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
523 type_name[pkg_lower.len()..].to_string()
525 } else {
526 type_name.to_string()
527 }
528}
529
530pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
540 let mut files = Vec::with_capacity(error.variants.len() + 1);
541
542 let base_name = format!("{}Exception", error.name);
544 let doc_lines: Vec<&str> = error.doc.lines().collect();
545
546 let base = crate::template_env::render(
547 "error_gen/java_error_base.jinja",
548 minijinja::context! {
549 package => package,
550 base_name => base_name.as_str(),
551 doc => !error.doc.is_empty(),
552 doc_lines => doc_lines,
553 },
554 );
555 files.push((base_name.clone(), base));
556
557 for variant in &error.variants {
559 let class_name = format!("{}Exception", variant.name);
560 let doc_lines: Vec<&str> = variant.doc.lines().collect();
561
562 let content = crate::template_env::render(
563 "error_gen/java_error_variant.jinja",
564 minijinja::context! {
565 package => package,
566 class_name => class_name.as_str(),
567 base_name => base_name.as_str(),
568 doc => !variant.doc.is_empty(),
569 doc_lines => doc_lines,
570 },
571 );
572 files.push((class_name, content));
573 }
574
575 files
576}
577
578pub fn gen_csharp_error_types(
592 error: &ErrorDef,
593 namespace: &str,
594 fallback_class: Option<&str>,
595) -> Vec<(String, String)> {
596 let mut files = Vec::with_capacity(error.variants.len() + 1);
597
598 let base_name = format!("{}Exception", error.name);
599 let base_parent = fallback_class.unwrap_or("Exception");
602 let error_doc_lines: Vec<&str> = error.doc.lines().collect();
603
604 {
606 let out = crate::template_env::render(
607 "error_gen/csharp_error_base.jinja",
608 minijinja::context! {
609 namespace => namespace,
610 base_name => base_name.as_str(),
611 base_parent => base_parent,
612 doc => !error.doc.is_empty(),
613 doc_lines => error_doc_lines,
614 },
615 );
616 files.push((base_name.clone(), out));
617 }
618
619 for variant in &error.variants {
621 let class_name = format!("{}Exception", variant.name);
622 let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
623
624 let out = crate::template_env::render(
625 "error_gen/csharp_error_variant.jinja",
626 minijinja::context! {
627 namespace => namespace,
628 class_name => class_name.as_str(),
629 base_name => base_name.as_str(),
630 doc => !variant.doc.is_empty(),
631 doc_lines => variant_doc_lines,
632 },
633 );
634 files.push((class_name, out));
635 }
636
637 files
638}
639
640fn to_screaming_snake(s: &str) -> String {
646 let mut result = String::with_capacity(s.len() + 4);
647 for (i, c) in s.chars().enumerate() {
648 if c.is_uppercase() {
649 if i > 0 {
650 result.push('_');
651 }
652 result.push(c.to_ascii_uppercase());
653 } else {
654 result.push(c.to_ascii_uppercase());
655 }
656 }
657 result
658}
659
660const TECHNICAL_ACRONYMS: &[&str] = &[
667 "API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
668 "ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
669 "RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
670 "URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
671];
672
673pub fn strip_thiserror_placeholders(template: &str) -> String {
687 let mut without_placeholders = String::with_capacity(template.len());
689 let mut depth = 0u32;
690 for ch in template.chars() {
691 match ch {
692 '{' => depth = depth.saturating_add(1),
693 '}' => depth = depth.saturating_sub(1),
694 other if depth == 0 => without_placeholders.push(other),
695 _ => {}
696 }
697 }
698 let mut compacted = String::with_capacity(without_placeholders.len());
702 let mut last_was_space = false;
703 for ch in without_placeholders.chars() {
704 if ch.is_whitespace() {
705 if !last_was_space && !compacted.is_empty() {
706 compacted.push(' ');
707 }
708 last_was_space = true;
709 } else {
710 compacted.push(ch);
711 last_was_space = false;
712 }
713 }
714 let trimmed = compacted
716 .trim()
717 .trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
718 .trim();
719 let cleaned = trimmed
722 .replace("()", "")
723 .replace("''", "")
724 .replace("\"\"", "")
725 .replace(" ", " ");
726 cleaned.trim().to_string()
727}
728
729pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
739 if variant_name.is_empty() {
740 return String::new();
741 }
742 let bytes = variant_name.as_bytes();
744 let mut words: Vec<&str> = Vec::new();
745 let mut start = 0usize;
746 for i in 1..bytes.len() {
747 if bytes[i].is_ascii_uppercase() {
748 words.push(&variant_name[start..i]);
749 start = i;
750 }
751 }
752 words.push(&variant_name[start..]);
753
754 let mut rendered: Vec<String> = Vec::with_capacity(words.len());
755 for word in &words {
756 let upper = word.to_ascii_uppercase();
757 if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
758 rendered.push(upper);
759 } else {
760 rendered.push(word.to_ascii_lowercase());
761 }
762 }
763 rendered.join(" ")
764}
765
766fn variant_display_message(variant: &ErrorVariant) -> String {
771 if let Some(tmpl) = &variant.message_template {
772 let stripped = strip_thiserror_placeholders(tmpl);
773 if stripped.is_empty() {
774 return acronym_aware_snake_phrase(&variant.name);
775 }
776 let mut tokens = stripped.splitn(2, ' ');
781 let head = tokens.next().unwrap_or("").to_string();
782 let tail = tokens.next().unwrap_or("");
783 let head_upper = head.to_ascii_uppercase();
784 let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
785 head_upper
786 } else {
787 let mut chars = head.chars();
788 match chars.next() {
789 Some(c) => c.to_lowercase().to_string() + chars.as_str(),
790 None => head,
791 }
792 };
793 if tail.is_empty() {
794 head_rendered
795 } else {
796 format!("{} {}", head_rendered, tail)
797 }
798 } else {
799 acronym_aware_snake_phrase(&variant.name)
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use alef_core::ir::{ErrorDef, ErrorVariant};
807
808 use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
809
810 fn tuple_field(index: usize) -> FieldDef {
812 FieldDef {
813 name: format!("_{index}"),
814 ty: TypeRef::String,
815 optional: false,
816 default: None,
817 doc: String::new(),
818 sanitized: false,
819 is_boxed: false,
820 type_rust_path: None,
821 cfg: None,
822 typed_default: None,
823 core_wrapper: CoreWrapper::None,
824 vec_inner_core_wrapper: CoreWrapper::None,
825 newtype_wrapper: None,
826 serde_rename: None,
827 serde_flatten: false,
828 binding_excluded: false,
829 binding_exclusion_reason: None,
830 original_type: None,
831 }
832 }
833
834 fn named_field(name: &str) -> FieldDef {
836 FieldDef {
837 name: name.to_string(),
838 ty: TypeRef::String,
839 optional: false,
840 default: None,
841 doc: String::new(),
842 sanitized: false,
843 is_boxed: false,
844 type_rust_path: None,
845 cfg: None,
846 typed_default: None,
847 core_wrapper: CoreWrapper::None,
848 vec_inner_core_wrapper: CoreWrapper::None,
849 newtype_wrapper: None,
850 serde_rename: None,
851 serde_flatten: false,
852 binding_excluded: false,
853 binding_exclusion_reason: None,
854 original_type: None,
855 }
856 }
857
858 fn sample_error() -> ErrorDef {
859 ErrorDef {
860 name: "ConversionError".to_string(),
861 rust_path: "html_to_markdown_rs::ConversionError".to_string(),
862 original_rust_path: String::new(),
863 variants: vec![
864 ErrorVariant {
865 name: "ParseError".to_string(),
866 message_template: Some("HTML parsing error: {0}".to_string()),
867 fields: vec![tuple_field(0)],
868 has_source: false,
869 has_from: false,
870 is_unit: false,
871 doc: String::new(),
872 },
873 ErrorVariant {
874 name: "IoError".to_string(),
875 message_template: Some("I/O error: {0}".to_string()),
876 fields: vec![tuple_field(0)],
877 has_source: false,
878 has_from: true,
879 is_unit: false,
880 doc: String::new(),
881 },
882 ErrorVariant {
883 name: "Other".to_string(),
884 message_template: Some("Conversion error: {0}".to_string()),
885 fields: vec![tuple_field(0)],
886 has_source: false,
887 has_from: false,
888 is_unit: false,
889 doc: String::new(),
890 },
891 ],
892 doc: "Error type for conversion operations.".to_string(),
893 binding_excluded: false,
894 binding_exclusion_reason: None,
895 }
896 }
897
898 #[test]
899 fn test_gen_error_types() {
900 let error = sample_error();
901 let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
902 assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
903 assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
904 assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
905 assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
906 }
907
908 #[test]
909 fn test_gen_error_converter() {
910 let error = sample_error();
911 let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
912 assert!(
913 output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
914 );
915 assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
916 assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
917 }
918
919 #[test]
920 fn test_gen_error_registration() {
921 let error = sample_error();
922 let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
923 assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
925 assert!(regs[3].contains("\"ConversionError\""));
926 }
927
928 #[test]
929 fn test_unit_variant_pattern() {
930 let error = ErrorDef {
931 name: "MyError".to_string(),
932 rust_path: "my_crate::MyError".to_string(),
933 original_rust_path: String::new(),
934 variants: vec![ErrorVariant {
935 name: "NotFound".to_string(),
936 message_template: Some("not found".to_string()),
937 fields: vec![],
938 has_source: false,
939 has_from: false,
940 is_unit: true,
941 doc: String::new(),
942 }],
943 doc: String::new(),
944 binding_excluded: false,
945 binding_exclusion_reason: None,
946 };
947 let output = gen_pyo3_error_converter(&error, "my_crate");
948 assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
949 assert!(!output.contains("NotFound(..)"));
951 }
952
953 #[test]
954 fn test_struct_variant_pattern() {
955 let error = ErrorDef {
956 name: "MyError".to_string(),
957 rust_path: "my_crate::MyError".to_string(),
958 original_rust_path: String::new(),
959 variants: vec![ErrorVariant {
960 name: "Parsing".to_string(),
961 message_template: Some("parsing error: {message}".to_string()),
962 fields: vec![named_field("message")],
963 has_source: false,
964 has_from: false,
965 is_unit: false,
966 doc: String::new(),
967 }],
968 doc: String::new(),
969 binding_excluded: false,
970 binding_exclusion_reason: None,
971 };
972 let output = gen_pyo3_error_converter(&error, "my_crate");
973 assert!(
974 output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
975 "Struct variants must use {{ .. }} pattern, got:\n{output}"
976 );
977 assert!(!output.contains("Parsing(..)"));
979 }
980
981 #[test]
986 fn test_gen_napi_error_types() {
987 let error = sample_error();
988 let output = gen_napi_error_types(&error);
989 assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
990 assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
991 assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
992 }
993
994 #[test]
995 fn test_gen_napi_error_converter() {
996 let error = sample_error();
997 let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
998 assert!(
999 output
1000 .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
1001 );
1002 assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
1003 assert!(output.contains("[ParseError]"));
1004 assert!(output.contains("[IoError]"));
1005 assert!(output.contains("#[allow(dead_code)]"));
1006 }
1007
1008 #[test]
1009 fn test_napi_unit_variant() {
1010 let error = ErrorDef {
1011 name: "MyError".to_string(),
1012 rust_path: "my_crate::MyError".to_string(),
1013 original_rust_path: String::new(),
1014 variants: vec![ErrorVariant {
1015 name: "NotFound".to_string(),
1016 message_template: None,
1017 fields: vec![],
1018 has_source: false,
1019 has_from: false,
1020 is_unit: true,
1021 doc: String::new(),
1022 }],
1023 doc: String::new(),
1024 binding_excluded: false,
1025 binding_exclusion_reason: None,
1026 };
1027 let output = gen_napi_error_converter(&error, "my_crate");
1028 assert!(output.contains("my_crate::MyError::NotFound =>"));
1029 assert!(!output.contains("NotFound(..)"));
1030 }
1031
1032 #[test]
1037 fn test_gen_wasm_error_converter() {
1038 let error = sample_error();
1039 let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1040 assert!(output.contains(
1042 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1043 ));
1044 assert!(output.contains("js_sys::Object::new()"));
1046 assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1047 assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1048 assert!(output.contains("obj.into()"));
1049 assert!(
1051 output
1052 .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1053 );
1054 assert!(output.contains("\"parse_error\""));
1055 assert!(output.contains("\"io_error\""));
1056 assert!(output.contains("\"other\""));
1057 assert!(output.contains("#[allow(dead_code)]"));
1058 }
1059
1060 #[test]
1065 fn test_gen_php_error_converter() {
1066 let error = sample_error();
1067 let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1068 assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1069 assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1070 assert!(output.contains("#[allow(dead_code)]"));
1071 }
1072
1073 #[test]
1078 fn test_gen_magnus_error_converter() {
1079 let error = sample_error();
1080 let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1081 assert!(
1082 output.contains(
1083 "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1084 )
1085 );
1086 assert!(
1087 output.contains(
1088 "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1089 )
1090 );
1091 assert!(output.contains("#[allow(dead_code)]"));
1092 }
1093
1094 #[test]
1099 fn test_gen_rustler_error_converter() {
1100 let error = sample_error();
1101 let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1102 assert!(
1103 output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1104 );
1105 assert!(output.contains("e.to_string()"));
1106 assert!(output.contains("#[allow(dead_code)]"));
1107 }
1108
1109 #[test]
1114 fn test_to_screaming_snake() {
1115 assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1116 assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1117 assert_eq!(to_screaming_snake("Other"), "OTHER");
1118 }
1119
1120 #[test]
1121 fn test_strip_thiserror_placeholders_struct_field() {
1122 assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
1123 assert_eq!(
1124 strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
1125 "plugin error in"
1126 );
1127 let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
1130 assert!(!result.contains('{'), "no braces: {result}");
1131 assert!(!result.contains('}'), "no braces: {result}");
1132 assert!(result.starts_with("extraction timed out after"), "{result}");
1133 }
1134
1135 #[test]
1136 fn test_strip_thiserror_placeholders_positional() {
1137 assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
1138 assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
1139 }
1140
1141 #[test]
1142 fn test_strip_thiserror_placeholders_no_placeholder() {
1143 assert_eq!(strip_thiserror_placeholders("not found"), "not found");
1144 assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
1145 }
1146
1147 #[test]
1148 fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
1149 assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
1150 assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
1151 assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
1152 assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
1153 assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
1154 }
1155
1156 #[test]
1157 fn test_acronym_aware_snake_phrase_plain_words() {
1158 assert_eq!(acronym_aware_snake_phrase("Other"), "other");
1159 assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
1160 assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
1161 }
1162
1163 #[test]
1164 fn test_variant_display_message_acronym_first_word() {
1165 let variant = ErrorVariant {
1166 name: "Io".to_string(),
1167 message_template: Some("I/O error: {0}".to_string()),
1168 fields: vec![tuple_field(0)],
1169 has_source: false,
1170 has_from: false,
1171 is_unit: false,
1172 doc: String::new(),
1173 };
1174 let msg = variant_display_message(&variant);
1177 assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
1178 }
1179
1180 #[test]
1181 fn test_variant_display_message_no_template_uses_acronyms() {
1182 let variant = ErrorVariant {
1183 name: "IoError".to_string(),
1184 message_template: None,
1185 fields: vec![],
1186 has_source: false,
1187 has_from: false,
1188 is_unit: false,
1189 doc: String::new(),
1190 };
1191 assert_eq!(variant_display_message(&variant), "IO error");
1192 }
1193
1194 #[test]
1195 fn test_variant_display_message_struct_template_no_leak() {
1196 let variant = ErrorVariant {
1197 name: "Ocr".to_string(),
1198 message_template: Some("OCR error: {message}".to_string()),
1199 fields: vec![named_field("message")],
1200 has_source: false,
1201 has_from: false,
1202 is_unit: false,
1203 doc: String::new(),
1204 };
1205 let msg = variant_display_message(&variant);
1206 assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
1207 }
1208
1209 #[test]
1210 fn test_go_sentinels_no_placeholder_leak() {
1211 let error = ErrorDef {
1212 name: "KreuzbergError".to_string(),
1213 rust_path: "kreuzberg::KreuzbergError".to_string(),
1214 original_rust_path: String::new(),
1215 variants: vec![
1216 ErrorVariant {
1217 name: "Io".to_string(),
1218 message_template: Some("IO error: {message}".to_string()),
1219 fields: vec![named_field("message")],
1220 has_source: false,
1221 has_from: false,
1222 is_unit: false,
1223 doc: String::new(),
1224 },
1225 ErrorVariant {
1226 name: "Ocr".to_string(),
1227 message_template: Some("OCR error: {message}".to_string()),
1228 fields: vec![named_field("message")],
1229 has_source: false,
1230 has_from: false,
1231 is_unit: false,
1232 doc: String::new(),
1233 },
1234 ErrorVariant {
1235 name: "Timeout".to_string(),
1236 message_template: Some(
1237 "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
1238 ),
1239 fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
1240 has_source: false,
1241 has_from: false,
1242 is_unit: false,
1243 doc: String::new(),
1244 },
1245 ],
1246 doc: String::new(),
1247 binding_excluded: false,
1248 binding_exclusion_reason: None,
1249 };
1250 let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
1251 assert!(
1252 !output.contains('{'),
1253 "Go sentinels must not contain raw placeholders:\n{output}"
1254 );
1255 assert!(
1256 output.contains("ErrIo = errors.New(\"IO error\")"),
1257 "expected acronym-preserving Io sentinel, got:\n{output}"
1258 );
1259 assert!(
1260 output.contains("var (\n\t// ErrIo is returned when IO error.\n\tErrIo = errors.New(\"IO error\")\n"),
1261 "Go sentinel comments must be emitted on separate lines, got:\n{output}"
1262 );
1263 assert!(
1264 output.contains("ErrOcr = errors.New(\"OCR error\")"),
1265 "expected acronym-preserving Ocr sentinel, got:\n{output}"
1266 );
1267 assert!(
1268 output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
1269 "expected timeout sentinel to start with the prose, got:\n{output}"
1270 );
1271 }
1272
1273 #[test]
1278 fn test_gen_ffi_error_codes() {
1279 let error = sample_error();
1280 let output = gen_ffi_error_codes(&error);
1281 assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1282 assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1283 assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1284 assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1285 assert!(output.contains("conversion_error_t;"));
1286 assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1287 }
1288
1289 #[test]
1294 fn test_gen_go_error_types() {
1295 let error = sample_error();
1296 let output = gen_go_error_types(&error, "mylib");
1298 assert!(output.contains("ErrParseError = errors.New("));
1299 assert!(output.contains("ErrIoError = errors.New("));
1300 assert!(output.contains("ErrOther = errors.New("));
1301 assert!(output.contains("type ConversionError struct {"));
1302 assert!(output.contains("Code string"));
1303 assert!(output.contains("func (e ConversionError) Error() string"));
1304 assert!(output.contains("// ErrParseError is returned when"));
1306 assert!(output.contains("// ErrIoError is returned when"));
1307 assert!(output.contains("// ErrOther is returned when"));
1308 }
1309
1310 #[test]
1311 fn test_gen_go_error_types_stutter_strip() {
1312 let error = sample_error();
1313 let output = gen_go_error_types(&error, "conversion");
1316 assert!(
1317 output.contains("type Error struct {"),
1318 "expected stutter strip, got:\n{output}"
1319 );
1320 assert!(
1321 output.contains("func (e Error) Error() string"),
1322 "expected stutter strip, got:\n{output}"
1323 );
1324 assert!(output.contains("ErrParseError = errors.New("));
1326 }
1327
1328 #[test]
1333 fn test_gen_java_error_types() {
1334 let error = sample_error();
1335 let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1336 assert_eq!(files.len(), 4);
1338 assert_eq!(files[0].0, "ConversionErrorException");
1340 assert!(
1341 files[0]
1342 .1
1343 .contains("public class ConversionErrorException extends Exception")
1344 );
1345 assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1346 assert_eq!(files[1].0, "ParseErrorException");
1348 assert!(
1349 files[1]
1350 .1
1351 .contains("public class ParseErrorException extends ConversionErrorException")
1352 );
1353 assert_eq!(files[2].0, "IoErrorException");
1354 assert_eq!(files[3].0, "OtherException");
1355 }
1356
1357 #[test]
1362 fn test_gen_csharp_error_types() {
1363 let error = sample_error();
1364 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
1366 assert_eq!(files.len(), 4);
1367 assert_eq!(files[0].0, "ConversionErrorException");
1368 assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1369 assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1370 assert_eq!(files[1].0, "ParseErrorException");
1371 assert!(
1372 files[1]
1373 .1
1374 .contains("public class ParseErrorException : ConversionErrorException")
1375 );
1376 assert_eq!(files[2].0, "IoErrorException");
1377 assert_eq!(files[3].0, "OtherException");
1378 }
1379
1380 #[test]
1381 fn test_gen_csharp_error_types_with_fallback() {
1382 let error = sample_error();
1383 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
1385 assert_eq!(files.len(), 4);
1386 assert!(
1387 files[0]
1388 .1
1389 .contains("public class ConversionErrorException : TestLibException")
1390 );
1391 assert!(
1393 files[1]
1394 .1
1395 .contains("public class ParseErrorException : ConversionErrorException")
1396 );
1397 }
1398
1399 #[test]
1404 fn test_python_exception_name_no_conflict() {
1405 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1407 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1409 }
1410
1411 #[test]
1412 fn test_python_exception_name_shadows_builtin() {
1413 assert_eq!(
1415 python_exception_name("Connection", "CrawlError"),
1416 "CrawlConnectionError"
1417 );
1418 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1420 assert_eq!(
1422 python_exception_name("ConnectionError", "CrawlError"),
1423 "CrawlConnectionError"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_python_exception_name_no_double_prefix() {
1429 assert_eq!(
1431 python_exception_name("CrawlConnectionError", "CrawlError"),
1432 "CrawlConnectionError"
1433 );
1434 }
1435}