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 }
829 }
830
831 fn named_field(name: &str) -> FieldDef {
833 FieldDef {
834 name: name.to_string(),
835 ty: TypeRef::String,
836 optional: false,
837 default: None,
838 doc: String::new(),
839 sanitized: false,
840 is_boxed: false,
841 type_rust_path: None,
842 cfg: None,
843 typed_default: None,
844 core_wrapper: CoreWrapper::None,
845 vec_inner_core_wrapper: CoreWrapper::None,
846 newtype_wrapper: None,
847 serde_rename: None,
848 serde_flatten: false,
849 }
850 }
851
852 fn sample_error() -> ErrorDef {
853 ErrorDef {
854 name: "ConversionError".to_string(),
855 rust_path: "html_to_markdown_rs::ConversionError".to_string(),
856 original_rust_path: String::new(),
857 variants: vec![
858 ErrorVariant {
859 name: "ParseError".to_string(),
860 message_template: Some("HTML parsing error: {0}".to_string()),
861 fields: vec![tuple_field(0)],
862 has_source: false,
863 has_from: false,
864 is_unit: false,
865 doc: String::new(),
866 },
867 ErrorVariant {
868 name: "IoError".to_string(),
869 message_template: Some("I/O error: {0}".to_string()),
870 fields: vec![tuple_field(0)],
871 has_source: false,
872 has_from: true,
873 is_unit: false,
874 doc: String::new(),
875 },
876 ErrorVariant {
877 name: "Other".to_string(),
878 message_template: Some("Conversion error: {0}".to_string()),
879 fields: vec![tuple_field(0)],
880 has_source: false,
881 has_from: false,
882 is_unit: false,
883 doc: String::new(),
884 },
885 ],
886 doc: "Error type for conversion operations.".to_string(),
887 }
888 }
889
890 #[test]
891 fn test_gen_error_types() {
892 let error = sample_error();
893 let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
894 assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
895 assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
896 assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
897 assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
898 }
899
900 #[test]
901 fn test_gen_error_converter() {
902 let error = sample_error();
903 let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
904 assert!(
905 output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
906 );
907 assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
908 assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
909 }
910
911 #[test]
912 fn test_gen_error_registration() {
913 let error = sample_error();
914 let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
915 assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
917 assert!(regs[3].contains("\"ConversionError\""));
918 }
919
920 #[test]
921 fn test_unit_variant_pattern() {
922 let error = ErrorDef {
923 name: "MyError".to_string(),
924 rust_path: "my_crate::MyError".to_string(),
925 original_rust_path: String::new(),
926 variants: vec![ErrorVariant {
927 name: "NotFound".to_string(),
928 message_template: Some("not found".to_string()),
929 fields: vec![],
930 has_source: false,
931 has_from: false,
932 is_unit: true,
933 doc: String::new(),
934 }],
935 doc: String::new(),
936 };
937 let output = gen_pyo3_error_converter(&error, "my_crate");
938 assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
939 assert!(!output.contains("NotFound(..)"));
941 }
942
943 #[test]
944 fn test_struct_variant_pattern() {
945 let error = ErrorDef {
946 name: "MyError".to_string(),
947 rust_path: "my_crate::MyError".to_string(),
948 original_rust_path: String::new(),
949 variants: vec![ErrorVariant {
950 name: "Parsing".to_string(),
951 message_template: Some("parsing error: {message}".to_string()),
952 fields: vec![named_field("message")],
953 has_source: false,
954 has_from: false,
955 is_unit: false,
956 doc: String::new(),
957 }],
958 doc: String::new(),
959 };
960 let output = gen_pyo3_error_converter(&error, "my_crate");
961 assert!(
962 output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
963 "Struct variants must use {{ .. }} pattern, got:\n{output}"
964 );
965 assert!(!output.contains("Parsing(..)"));
967 }
968
969 #[test]
974 fn test_gen_napi_error_types() {
975 let error = sample_error();
976 let output = gen_napi_error_types(&error);
977 assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
978 assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
979 assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
980 }
981
982 #[test]
983 fn test_gen_napi_error_converter() {
984 let error = sample_error();
985 let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
986 assert!(
987 output
988 .contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
989 );
990 assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
991 assert!(output.contains("[ParseError]"));
992 assert!(output.contains("[IoError]"));
993 assert!(output.contains("#[allow(dead_code)]"));
994 }
995
996 #[test]
997 fn test_napi_unit_variant() {
998 let error = ErrorDef {
999 name: "MyError".to_string(),
1000 rust_path: "my_crate::MyError".to_string(),
1001 original_rust_path: String::new(),
1002 variants: vec![ErrorVariant {
1003 name: "NotFound".to_string(),
1004 message_template: None,
1005 fields: vec![],
1006 has_source: false,
1007 has_from: false,
1008 is_unit: true,
1009 doc: String::new(),
1010 }],
1011 doc: String::new(),
1012 };
1013 let output = gen_napi_error_converter(&error, "my_crate");
1014 assert!(output.contains("my_crate::MyError::NotFound =>"));
1015 assert!(!output.contains("NotFound(..)"));
1016 }
1017
1018 #[test]
1023 fn test_gen_wasm_error_converter() {
1024 let error = sample_error();
1025 let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
1026 assert!(output.contains(
1028 "fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
1029 ));
1030 assert!(output.contains("js_sys::Object::new()"));
1032 assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
1033 assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
1034 assert!(output.contains("obj.into()"));
1035 assert!(
1037 output
1038 .contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
1039 );
1040 assert!(output.contains("\"parse_error\""));
1041 assert!(output.contains("\"io_error\""));
1042 assert!(output.contains("\"other\""));
1043 assert!(output.contains("#[allow(dead_code)]"));
1044 }
1045
1046 #[test]
1051 fn test_gen_php_error_converter() {
1052 let error = sample_error();
1053 let output = gen_php_error_converter(&error, "html_to_markdown_rs");
1054 assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
1055 assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
1056 assert!(output.contains("#[allow(dead_code)]"));
1057 }
1058
1059 #[test]
1064 fn test_gen_magnus_error_converter() {
1065 let error = sample_error();
1066 let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
1067 assert!(
1068 output.contains(
1069 "fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
1070 )
1071 );
1072 assert!(
1073 output.contains(
1074 "magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
1075 )
1076 );
1077 assert!(output.contains("#[allow(dead_code)]"));
1078 }
1079
1080 #[test]
1085 fn test_gen_rustler_error_converter() {
1086 let error = sample_error();
1087 let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
1088 assert!(
1089 output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
1090 );
1091 assert!(output.contains("e.to_string()"));
1092 assert!(output.contains("#[allow(dead_code)]"));
1093 }
1094
1095 #[test]
1100 fn test_to_screaming_snake() {
1101 assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
1102 assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
1103 assert_eq!(to_screaming_snake("Other"), "OTHER");
1104 }
1105
1106 #[test]
1107 fn test_strip_thiserror_placeholders_struct_field() {
1108 assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
1109 assert_eq!(
1110 strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
1111 "plugin error in"
1112 );
1113 let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
1116 assert!(!result.contains('{'), "no braces: {result}");
1117 assert!(!result.contains('}'), "no braces: {result}");
1118 assert!(result.starts_with("extraction timed out after"), "{result}");
1119 }
1120
1121 #[test]
1122 fn test_strip_thiserror_placeholders_positional() {
1123 assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
1124 assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
1125 }
1126
1127 #[test]
1128 fn test_strip_thiserror_placeholders_no_placeholder() {
1129 assert_eq!(strip_thiserror_placeholders("not found"), "not found");
1130 assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
1131 }
1132
1133 #[test]
1134 fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
1135 assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
1136 assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
1137 assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
1138 assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
1139 assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
1140 }
1141
1142 #[test]
1143 fn test_acronym_aware_snake_phrase_plain_words() {
1144 assert_eq!(acronym_aware_snake_phrase("Other"), "other");
1145 assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
1146 assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
1147 }
1148
1149 #[test]
1150 fn test_variant_display_message_acronym_first_word() {
1151 let variant = ErrorVariant {
1152 name: "Io".to_string(),
1153 message_template: Some("I/O error: {0}".to_string()),
1154 fields: vec![tuple_field(0)],
1155 has_source: false,
1156 has_from: false,
1157 is_unit: false,
1158 doc: String::new(),
1159 };
1160 let msg = variant_display_message(&variant);
1163 assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
1164 }
1165
1166 #[test]
1167 fn test_variant_display_message_no_template_uses_acronyms() {
1168 let variant = ErrorVariant {
1169 name: "IoError".to_string(),
1170 message_template: None,
1171 fields: vec![],
1172 has_source: false,
1173 has_from: false,
1174 is_unit: false,
1175 doc: String::new(),
1176 };
1177 assert_eq!(variant_display_message(&variant), "IO error");
1178 }
1179
1180 #[test]
1181 fn test_variant_display_message_struct_template_no_leak() {
1182 let variant = ErrorVariant {
1183 name: "Ocr".to_string(),
1184 message_template: Some("OCR error: {message}".to_string()),
1185 fields: vec![named_field("message")],
1186 has_source: false,
1187 has_from: false,
1188 is_unit: false,
1189 doc: String::new(),
1190 };
1191 let msg = variant_display_message(&variant);
1192 assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
1193 }
1194
1195 #[test]
1196 fn test_go_sentinels_no_placeholder_leak() {
1197 let error = ErrorDef {
1198 name: "KreuzbergError".to_string(),
1199 rust_path: "kreuzberg::KreuzbergError".to_string(),
1200 original_rust_path: String::new(),
1201 variants: vec![
1202 ErrorVariant {
1203 name: "Io".to_string(),
1204 message_template: Some("IO error: {message}".to_string()),
1205 fields: vec![named_field("message")],
1206 has_source: false,
1207 has_from: false,
1208 is_unit: false,
1209 doc: String::new(),
1210 },
1211 ErrorVariant {
1212 name: "Ocr".to_string(),
1213 message_template: Some("OCR error: {message}".to_string()),
1214 fields: vec![named_field("message")],
1215 has_source: false,
1216 has_from: false,
1217 is_unit: false,
1218 doc: String::new(),
1219 },
1220 ErrorVariant {
1221 name: "Timeout".to_string(),
1222 message_template: Some(
1223 "extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
1224 ),
1225 fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
1226 has_source: false,
1227 has_from: false,
1228 is_unit: false,
1229 doc: String::new(),
1230 },
1231 ],
1232 doc: String::new(),
1233 };
1234 let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
1235 assert!(
1236 !output.contains('{'),
1237 "Go sentinels must not contain raw placeholders:\n{output}"
1238 );
1239 assert!(
1240 output.contains("ErrIo = errors.New(\"IO error\")"),
1241 "expected acronym-preserving Io sentinel, got:\n{output}"
1242 );
1243 assert!(
1244 output.contains("ErrOcr = errors.New(\"OCR error\")"),
1245 "expected acronym-preserving Ocr sentinel, got:\n{output}"
1246 );
1247 assert!(
1248 output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
1249 "expected timeout sentinel to start with the prose, got:\n{output}"
1250 );
1251 }
1252
1253 #[test]
1258 fn test_gen_ffi_error_codes() {
1259 let error = sample_error();
1260 let output = gen_ffi_error_codes(&error);
1261 assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
1262 assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
1263 assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
1264 assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
1265 assert!(output.contains("conversion_error_t;"));
1266 assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
1267 }
1268
1269 #[test]
1274 fn test_gen_go_error_types() {
1275 let error = sample_error();
1276 let output = gen_go_error_types(&error, "mylib");
1278 assert!(output.contains("ErrParseError = errors.New("));
1279 assert!(output.contains("ErrIoError = errors.New("));
1280 assert!(output.contains("ErrOther = errors.New("));
1281 assert!(output.contains("type ConversionError struct {"));
1282 assert!(output.contains("Code string"));
1283 assert!(output.contains("func (e *ConversionError) Error() string"));
1284 assert!(output.contains("// ErrParseError is returned when"));
1286 assert!(output.contains("// ErrIoError is returned when"));
1287 assert!(output.contains("// ErrOther is returned when"));
1288 }
1289
1290 #[test]
1291 fn test_gen_go_error_types_stutter_strip() {
1292 let error = sample_error();
1293 let output = gen_go_error_types(&error, "conversion");
1296 assert!(
1297 output.contains("type Error struct {"),
1298 "expected stutter strip, got:\n{output}"
1299 );
1300 assert!(
1301 output.contains("func (e *Error) Error() string"),
1302 "expected stutter strip, got:\n{output}"
1303 );
1304 assert!(output.contains("ErrParseError = errors.New("));
1306 }
1307
1308 #[test]
1313 fn test_gen_java_error_types() {
1314 let error = sample_error();
1315 let files = gen_java_error_types(&error, "dev.kreuzberg.test");
1316 assert_eq!(files.len(), 4);
1318 assert_eq!(files[0].0, "ConversionErrorException");
1320 assert!(
1321 files[0]
1322 .1
1323 .contains("public class ConversionErrorException extends Exception")
1324 );
1325 assert!(files[0].1.contains("package dev.kreuzberg.test;"));
1326 assert_eq!(files[1].0, "ParseErrorException");
1328 assert!(
1329 files[1]
1330 .1
1331 .contains("public class ParseErrorException extends ConversionErrorException")
1332 );
1333 assert_eq!(files[2].0, "IoErrorException");
1334 assert_eq!(files[3].0, "OtherException");
1335 }
1336
1337 #[test]
1342 fn test_gen_csharp_error_types() {
1343 let error = sample_error();
1344 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
1346 assert_eq!(files.len(), 4);
1347 assert_eq!(files[0].0, "ConversionErrorException");
1348 assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
1349 assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
1350 assert_eq!(files[1].0, "ParseErrorException");
1351 assert!(
1352 files[1]
1353 .1
1354 .contains("public class ParseErrorException : ConversionErrorException")
1355 );
1356 assert_eq!(files[2].0, "IoErrorException");
1357 assert_eq!(files[3].0, "OtherException");
1358 }
1359
1360 #[test]
1361 fn test_gen_csharp_error_types_with_fallback() {
1362 let error = sample_error();
1363 let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
1365 assert_eq!(files.len(), 4);
1366 assert!(
1367 files[0]
1368 .1
1369 .contains("public class ConversionErrorException : TestLibException")
1370 );
1371 assert!(
1373 files[1]
1374 .1
1375 .contains("public class ParseErrorException : ConversionErrorException")
1376 );
1377 }
1378
1379 #[test]
1384 fn test_python_exception_name_no_conflict() {
1385 assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
1387 assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
1389 }
1390
1391 #[test]
1392 fn test_python_exception_name_shadows_builtin() {
1393 assert_eq!(
1395 python_exception_name("Connection", "CrawlError"),
1396 "CrawlConnectionError"
1397 );
1398 assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
1400 assert_eq!(
1402 python_exception_name("ConnectionError", "CrawlError"),
1403 "CrawlConnectionError"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_python_exception_name_no_double_prefix() {
1409 assert_eq!(
1411 python_exception_name("CrawlConnectionError", "CrawlError"),
1412 "CrawlConnectionError"
1413 );
1414 }
1415}