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