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