use ahash::AHashSet;
use alef_core::ir::{ErrorDef, ErrorVariant};
use crate::conversions::is_tuple_variant;
fn error_variant_wildcard_pattern(rust_path: &str, variant: &ErrorVariant) -> String {
if variant.is_unit {
format!("{rust_path}::{}", variant.name)
} else if is_tuple_variant(&variant.fields) {
format!("{rust_path}::{}(..)", variant.name)
} else {
format!("{rust_path}::{} {{ .. }}", variant.name)
}
}
const PYTHON_BUILTIN_EXCEPTIONS: &[&str] = &[
"ConnectionError",
"TimeoutError",
"PermissionError",
"FileNotFoundError",
"ValueError",
"TypeError",
"RuntimeError",
"OSError",
"IOError",
"KeyError",
"IndexError",
"AttributeError",
"ImportError",
"MemoryError",
"OverflowError",
"StopIteration",
"RecursionError",
"SystemError",
"ReferenceError",
"BufferError",
"EOFError",
"LookupError",
"ArithmeticError",
"AssertionError",
"BlockingIOError",
"BrokenPipeError",
"ChildProcessError",
"FileExistsError",
"InterruptedError",
"IsADirectoryError",
"NotADirectoryError",
"ProcessLookupError",
"UnicodeError",
];
fn error_base_prefix(error_name: &str) -> &str {
error_name.strip_suffix("Error").unwrap_or(error_name)
}
pub fn python_exception_name(variant_name: &str, error_name: &str) -> String {
let candidate = if variant_name.ends_with("Error") {
variant_name.to_string()
} else {
format!("{}Error", variant_name)
};
if PYTHON_BUILTIN_EXCEPTIONS.contains(&candidate.as_str()) {
let prefix = error_base_prefix(error_name);
if candidate.starts_with(prefix) {
candidate
} else {
format!("{}{}", prefix, candidate)
}
} else {
candidate
}
}
pub fn gen_pyo3_error_types(error: &ErrorDef, module_name: &str, seen_exceptions: &mut AHashSet<String>) -> String {
let mut variant_names = Vec::new();
for variant in &error.variants {
let variant_name = python_exception_name(&variant.name, &error.name);
if seen_exceptions.insert(variant_name.clone()) {
variant_names.push(variant_name);
}
}
let include_base = seen_exceptions.insert(error.name.clone());
crate::template_env::render(
"error_gen/pyo3_error_types.jinja",
minijinja::context! {
variant_names => variant_names,
module_name => module_name,
error_name => error.name.as_str(),
include_base => include_base,
},
)
}
pub fn gen_pyo3_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
let normalized = error.rust_path.replace('-', "_");
let segments: Vec<&str> = normalized.split("::").collect();
if segments.len() > 2 {
let crate_name = segments[0];
let error_name = segments[segments.len() - 1];
format!("{crate_name}::{error_name}")
} else {
normalized
}
};
let fn_name = format!("{}_to_py_err", to_snake_case(&error.name));
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
let variant_exc_name = python_exception_name(&variant.name, &error.name);
variants.push((pattern, variant_exc_name));
}
crate::template_env::render(
"error_gen/pyo3_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
error_name => error.name.as_str(),
variants => variants,
},
)
}
pub fn gen_pyo3_error_registration(error: &ErrorDef, seen_registrations: &mut AHashSet<String>) -> Vec<String> {
let mut registrations = Vec::with_capacity(error.variants.len() + 1);
for variant in &error.variants {
let variant_exc_name = python_exception_name(&variant.name, &error.name);
if seen_registrations.insert(variant_exc_name.clone()) {
registrations.push(format!(
" m.add(\"{}\", m.py().get_type::<{}>())?;",
variant_exc_name, variant_exc_name
));
}
}
if seen_registrations.insert(error.name.clone()) {
registrations.push(format!(
" m.add(\"{}\", m.py().get_type::<{}>())?;",
error.name, error.name
));
}
registrations
}
pub fn converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_py_err", to_snake_case(&error.name))
}
fn to_snake_case(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
pub fn gen_napi_error_types(error: &ErrorDef) -> String {
let mut variants = Vec::new();
let error_screaming = to_screaming_snake(&error.name);
for variant in &error.variants {
let variant_const = format!("{}_ERROR_{}", error_screaming, to_screaming_snake(&variant.name));
variants.push((variant_const, variant.name.clone()));
}
crate::template_env::render(
"error_gen/napi_error_types.jinja",
minijinja::context! {
variants => variants,
},
)
}
pub fn gen_napi_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_napi_err", to_snake_case(&error.name));
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
variants.push((pattern, variant.name.clone()));
}
crate::template_env::render(
"error_gen/napi_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
variants => variants,
},
)
}
pub fn napi_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_napi_err", to_snake_case(&error.name))
}
pub fn gen_wasm_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_js_value", to_snake_case(&error.name));
let code_fn_name = format!("{}_error_code", to_snake_case(&error.name));
let mut code_variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
let code = to_snake_case(&variant.name);
code_variants.push((pattern, code));
}
let default_code = to_snake_case(&error.name);
let code_fn = crate::template_env::render(
"error_gen/wasm_error_code_fn.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
code_fn_name => code_fn_name.as_str(),
variants => code_variants,
default_code => default_code.as_str(),
},
);
let converter_fn = crate::template_env::render(
"error_gen/wasm_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
code_fn_name => code_fn_name.as_str(),
},
);
format!("{}\n\n{}", code_fn, converter_fn)
}
pub fn wasm_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_js_value", to_snake_case(&error.name))
}
pub fn gen_php_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_php_err", to_snake_case(&error.name));
let mut variants = Vec::new();
for variant in &error.variants {
let pattern = error_variant_wildcard_pattern(&rust_path, variant);
variants.push((pattern, variant.name.clone()));
}
crate::template_env::render(
"error_gen/php_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
variants => variants,
},
)
}
pub fn php_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_php_err", to_snake_case(&error.name))
}
pub fn gen_magnus_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_magnus_err", to_snake_case(&error.name));
crate::template_env::render(
"error_gen/magnus_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
},
)
}
pub fn magnus_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_magnus_err", to_snake_case(&error.name))
}
pub fn gen_rustler_error_converter(error: &ErrorDef, core_import: &str) -> String {
let rust_path = if error.rust_path.is_empty() {
format!("{core_import}::{}", error.name)
} else {
error.rust_path.replace('-', "_")
};
let fn_name = format!("{}_to_rustler_err", to_snake_case(&error.name));
crate::template_env::render(
"error_gen/rustler_error_converter.jinja",
minijinja::context! {
rust_path => rust_path.as_str(),
fn_name => fn_name.as_str(),
},
)
}
pub fn rustler_converter_fn_name(error: &ErrorDef) -> String {
format!("{}_to_rustler_err", to_snake_case(&error.name))
}
pub fn gen_ffi_error_codes(error: &ErrorDef) -> String {
let prefix = to_screaming_snake(&error.name);
let prefix_lower = to_snake_case(&error.name);
let mut variant_variants = Vec::new();
for (i, variant) in error.variants.iter().enumerate() {
let variant_screaming = to_screaming_snake(&variant.name);
variant_variants.push((variant_screaming, (i + 1).to_string()));
}
crate::template_env::render(
"error_gen/ffi_error_codes.jinja",
minijinja::context! {
error_name => error.name.as_str(),
prefix => prefix.as_str(),
prefix_lower => prefix_lower.as_str(),
variant_variants => variant_variants,
},
)
}
pub fn gen_go_error_types(error: &ErrorDef, pkg_name: &str) -> String {
let sentinels = gen_go_sentinel_errors(std::slice::from_ref(error));
let structured = gen_go_error_struct(error, pkg_name);
format!("{}\n\n{}", sentinels, structured)
}
pub fn gen_go_sentinel_errors(errors: &[ErrorDef]) -> String {
if errors.is_empty() {
return String::new();
}
let mut variant_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for err in errors {
for v in &err.variants {
*variant_counts.entry(v.name.as_str()).or_insert(0) += 1;
}
}
let mut seen = std::collections::HashSet::new();
let mut sentinels = Vec::new();
for err in errors {
let parent_base = error_base_prefix(&err.name);
for variant in &err.variants {
let collides = variant_counts.get(variant.name.as_str()).copied().unwrap_or(0) > 1;
let const_name = if collides {
format!("Err{}{}", parent_base, variant.name)
} else {
format!("Err{}", variant.name)
};
if !seen.insert(const_name.clone()) {
continue;
}
let msg = variant_display_message(variant);
sentinels.push((const_name, msg));
}
}
crate::template_env::render(
"error_gen/go_sentinel_errors.jinja",
minijinja::context! {
sentinels => sentinels,
},
)
}
pub fn gen_go_error_struct(error: &ErrorDef, pkg_name: &str) -> String {
let go_type_name = strip_package_prefix(&error.name, pkg_name);
crate::template_env::render(
"error_gen/go_error_struct.jinja",
minijinja::context! {
go_type_name => go_type_name.as_str(),
},
)
}
fn strip_package_prefix(type_name: &str, pkg_name: &str) -> String {
let type_lower = type_name.to_lowercase();
let pkg_lower = pkg_name.to_lowercase();
if type_lower.starts_with(&pkg_lower) && type_lower.len() > pkg_lower.len() {
type_name[pkg_lower.len()..].to_string()
} else {
type_name.to_string()
}
}
pub fn gen_java_error_types(error: &ErrorDef, package: &str) -> Vec<(String, String)> {
let mut files = Vec::with_capacity(error.variants.len() + 1);
let base_name = format!("{}Exception", error.name);
let doc_lines: Vec<&str> = error.doc.lines().collect();
let base = crate::template_env::render(
"error_gen/java_error_base.jinja",
minijinja::context! {
package => package,
base_name => base_name.as_str(),
doc => !error.doc.is_empty(),
doc_lines => doc_lines,
},
);
files.push((base_name.clone(), base));
for variant in &error.variants {
let class_name = format!("{}Exception", variant.name);
let doc_lines: Vec<&str> = variant.doc.lines().collect();
let content = crate::template_env::render(
"error_gen/java_error_variant.jinja",
minijinja::context! {
package => package,
class_name => class_name.as_str(),
base_name => base_name.as_str(),
doc => !variant.doc.is_empty(),
doc_lines => doc_lines,
},
);
files.push((class_name, content));
}
files
}
pub fn gen_csharp_error_types(
error: &ErrorDef,
namespace: &str,
fallback_class: Option<&str>,
) -> Vec<(String, String)> {
let mut files = Vec::with_capacity(error.variants.len() + 1);
let base_name = format!("{}Exception", error.name);
let base_parent = fallback_class.unwrap_or("Exception");
let error_doc_lines: Vec<&str> = error.doc.lines().collect();
{
let out = crate::template_env::render(
"error_gen/csharp_error_base.jinja",
minijinja::context! {
namespace => namespace,
base_name => base_name.as_str(),
base_parent => base_parent,
doc => !error.doc.is_empty(),
doc_lines => error_doc_lines,
},
);
files.push((base_name.clone(), out));
}
for variant in &error.variants {
let class_name = format!("{}Exception", variant.name);
let variant_doc_lines: Vec<&str> = variant.doc.lines().collect();
let out = crate::template_env::render(
"error_gen/csharp_error_variant.jinja",
minijinja::context! {
namespace => namespace,
class_name => class_name.as_str(),
base_name => base_name.as_str(),
doc => !variant.doc.is_empty(),
doc_lines => variant_doc_lines,
},
);
files.push((class_name, out));
}
files
}
fn to_screaming_snake(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
result.push('_');
}
result.push(c.to_ascii_uppercase());
} else {
result.push(c.to_ascii_uppercase());
}
}
result
}
const TECHNICAL_ACRONYMS: &[&str] = &[
"API", "ASCII", "CPU", "CSS", "CSV", "DNS", "EOF", "FFI", "FTP", "GID", "GPU", "GUI", "HTML", "HTTP", "HTTPS",
"ID", "IO", "IP", "JSON", "JWT", "LDAP", "MFA", "MIME", "OCR", "OS", "PDF", "PID", "PNG", "QPS", "RAM", "RGB",
"RPC", "RTF", "SDK", "SLA", "SMTP", "SQL", "SSH", "SSL", "SVG", "TCP", "TLS", "TOML", "TTL", "UDP", "UI", "UID",
"URI", "URL", "UTF8", "UUID", "VM", "XML", "XMPP", "XSRF", "XSS", "YAML", "ZIP",
];
pub fn strip_thiserror_placeholders(template: &str) -> String {
let mut without_placeholders = String::with_capacity(template.len());
let mut depth = 0u32;
for ch in template.chars() {
match ch {
'{' => depth = depth.saturating_add(1),
'}' => depth = depth.saturating_sub(1),
other if depth == 0 => without_placeholders.push(other),
_ => {}
}
}
let mut compacted = String::with_capacity(without_placeholders.len());
let mut last_was_space = false;
for ch in without_placeholders.chars() {
if ch.is_whitespace() {
if !last_was_space && !compacted.is_empty() {
compacted.push(' ');
}
last_was_space = true;
} else {
compacted.push(ch);
last_was_space = false;
}
}
let trimmed = compacted
.trim()
.trim_end_matches([':', ',', '-', ';', '(', '\'', '"', ' '])
.trim();
let cleaned = trimmed
.replace("()", "")
.replace("''", "")
.replace("\"\"", "")
.replace(" ", " ");
cleaned.trim().to_string()
}
pub fn acronym_aware_snake_phrase(variant_name: &str) -> String {
if variant_name.is_empty() {
return String::new();
}
let bytes = variant_name.as_bytes();
let mut words: Vec<&str> = Vec::new();
let mut start = 0usize;
for i in 1..bytes.len() {
if bytes[i].is_ascii_uppercase() {
words.push(&variant_name[start..i]);
start = i;
}
}
words.push(&variant_name[start..]);
let mut rendered: Vec<String> = Vec::with_capacity(words.len());
for word in &words {
let upper = word.to_ascii_uppercase();
if TECHNICAL_ACRONYMS.contains(&upper.as_str()) {
rendered.push(upper);
} else {
rendered.push(word.to_ascii_lowercase());
}
}
rendered.join(" ")
}
fn variant_display_message(variant: &ErrorVariant) -> String {
if let Some(tmpl) = &variant.message_template {
let stripped = strip_thiserror_placeholders(tmpl);
if stripped.is_empty() {
return acronym_aware_snake_phrase(&variant.name);
}
let mut tokens = stripped.splitn(2, ' ');
let head = tokens.next().unwrap_or("").to_string();
let tail = tokens.next().unwrap_or("");
let head_upper = head.to_ascii_uppercase();
let head_rendered = if TECHNICAL_ACRONYMS.contains(&head_upper.as_str()) {
head_upper
} else {
let mut chars = head.chars();
match chars.next() {
Some(c) => c.to_lowercase().to_string() + chars.as_str(),
None => head,
}
};
if tail.is_empty() {
head_rendered
} else {
format!("{} {}", head_rendered, tail)
}
} else {
acronym_aware_snake_phrase(&variant.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use alef_core::ir::{ErrorDef, ErrorVariant};
use alef_core::ir::{CoreWrapper, FieldDef, TypeRef};
fn tuple_field(index: usize) -> FieldDef {
FieldDef {
name: format!("_{index}"),
ty: TypeRef::String,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
}
}
fn named_field(name: &str) -> FieldDef {
FieldDef {
name: name.to_string(),
ty: TypeRef::String,
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: CoreWrapper::None,
vec_inner_core_wrapper: CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
serde_flatten: false,
}
}
fn sample_error() -> ErrorDef {
ErrorDef {
name: "ConversionError".to_string(),
rust_path: "html_to_markdown_rs::ConversionError".to_string(),
original_rust_path: String::new(),
variants: vec![
ErrorVariant {
name: "ParseError".to_string(),
message_template: Some("HTML parsing error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "IoError".to_string(),
message_template: Some("I/O error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: true,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Other".to_string(),
message_template: Some("Conversion error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
],
doc: "Error type for conversion operations.".to_string(),
}
}
#[test]
fn test_gen_error_types() {
let error = sample_error();
let output = gen_pyo3_error_types(&error, "_module", &mut AHashSet::new());
assert!(output.contains("pyo3::create_exception!(_module, ParseError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, IoError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, OtherError, pyo3::exceptions::PyException);"));
assert!(output.contains("pyo3::create_exception!(_module, ConversionError, pyo3::exceptions::PyException);"));
}
#[test]
fn test_gen_error_converter() {
let error = sample_error();
let output = gen_pyo3_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains("fn conversion_error_to_py_err(e: html_to_markdown_rs::ConversionError) -> pyo3::PyErr {")
);
assert!(output.contains("html_to_markdown_rs::ConversionError::ParseError(..) => ParseError::new_err(msg),"));
assert!(output.contains("html_to_markdown_rs::ConversionError::IoError(..) => IoError::new_err(msg),"));
}
#[test]
fn test_gen_error_registration() {
let error = sample_error();
let regs = gen_pyo3_error_registration(&error, &mut AHashSet::new());
assert_eq!(regs.len(), 4); assert!(regs[0].contains("\"ParseError\""));
assert!(regs[3].contains("\"ConversionError\""));
}
#[test]
fn test_unit_variant_pattern() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "NotFound".to_string(),
message_template: Some("not found".to_string()),
fields: vec![],
has_source: false,
has_from: false,
is_unit: true,
doc: String::new(),
}],
doc: String::new(),
};
let output = gen_pyo3_error_converter(&error, "my_crate");
assert!(output.contains("my_crate::MyError::NotFound => NotFoundError::new_err(msg),"));
assert!(!output.contains("NotFound(..)"));
}
#[test]
fn test_struct_variant_pattern() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "Parsing".to_string(),
message_template: Some("parsing error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
}],
doc: String::new(),
};
let output = gen_pyo3_error_converter(&error, "my_crate");
assert!(
output.contains("my_crate::MyError::Parsing { .. } => ParsingError::new_err(msg),"),
"Struct variants must use {{ .. }} pattern, got:\n{output}"
);
assert!(!output.contains("Parsing(..)"));
}
#[test]
fn test_gen_napi_error_types() {
let error = sample_error();
let output = gen_napi_error_types(&error);
assert!(output.contains("CONVERSION_ERROR_ERROR_PARSE_ERROR"));
assert!(output.contains("CONVERSION_ERROR_ERROR_IO_ERROR"));
assert!(output.contains("CONVERSION_ERROR_ERROR_OTHER"));
}
#[test]
fn test_gen_napi_error_converter() {
let error = sample_error();
let output = gen_napi_error_converter(&error, "html_to_markdown_rs");
assert!(
output
.contains("fn conversion_error_to_napi_err(e: html_to_markdown_rs::ConversionError) -> napi::Error {")
);
assert!(output.contains("napi::Error::new(napi::Status::GenericFailure,"));
assert!(output.contains("[ParseError]"));
assert!(output.contains("[IoError]"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_napi_unit_variant() {
let error = ErrorDef {
name: "MyError".to_string(),
rust_path: "my_crate::MyError".to_string(),
original_rust_path: String::new(),
variants: vec![ErrorVariant {
name: "NotFound".to_string(),
message_template: None,
fields: vec![],
has_source: false,
has_from: false,
is_unit: true,
doc: String::new(),
}],
doc: String::new(),
};
let output = gen_napi_error_converter(&error, "my_crate");
assert!(output.contains("my_crate::MyError::NotFound =>"));
assert!(!output.contains("NotFound(..)"));
}
#[test]
fn test_gen_wasm_error_converter() {
let error = sample_error();
let output = gen_wasm_error_converter(&error, "html_to_markdown_rs");
assert!(output.contains(
"fn conversion_error_to_js_value(e: html_to_markdown_rs::ConversionError) -> wasm_bindgen::JsValue {"
));
assert!(output.contains("js_sys::Object::new()"));
assert!(output.contains("js_sys::Reflect::set(&obj, &\"code\".into(), &code.into()).ok()"));
assert!(output.contains("js_sys::Reflect::set(&obj, &\"message\".into(), &message.into()).ok()"));
assert!(output.contains("obj.into()"));
assert!(
output
.contains("fn conversion_error_error_code(e: &html_to_markdown_rs::ConversionError) -> &'static str {")
);
assert!(output.contains("\"parse_error\""));
assert!(output.contains("\"io_error\""));
assert!(output.contains("\"other\""));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_php_error_converter() {
let error = sample_error();
let output = gen_php_error_converter(&error, "html_to_markdown_rs");
assert!(output.contains("fn conversion_error_to_php_err(e: html_to_markdown_rs::ConversionError) -> ext_php_rs::exception::PhpException {"));
assert!(output.contains("PhpException::default(format!(\"[ParseError] {}\", msg))"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_magnus_error_converter() {
let error = sample_error();
let output = gen_magnus_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains(
"fn conversion_error_to_magnus_err(e: html_to_markdown_rs::ConversionError) -> magnus::Error {"
)
);
assert!(
output.contains(
"magnus::Error::new(unsafe { magnus::Ruby::get_unchecked() }.exception_runtime_error(), msg)"
)
);
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_gen_rustler_error_converter() {
let error = sample_error();
let output = gen_rustler_error_converter(&error, "html_to_markdown_rs");
assert!(
output.contains("fn conversion_error_to_rustler_err(e: html_to_markdown_rs::ConversionError) -> String {")
);
assert!(output.contains("e.to_string()"));
assert!(output.contains("#[allow(dead_code)]"));
}
#[test]
fn test_to_screaming_snake() {
assert_eq!(to_screaming_snake("ConversionError"), "CONVERSION_ERROR");
assert_eq!(to_screaming_snake("IoError"), "IO_ERROR");
assert_eq!(to_screaming_snake("Other"), "OTHER");
}
#[test]
fn test_strip_thiserror_placeholders_struct_field() {
assert_eq!(strip_thiserror_placeholders("OCR error: {message}"), "OCR error");
assert_eq!(
strip_thiserror_placeholders("plugin error in '{plugin_name}': {message}"),
"plugin error in"
);
let result = strip_thiserror_placeholders("extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)");
assert!(!result.contains('{'), "no braces: {result}");
assert!(!result.contains('}'), "no braces: {result}");
assert!(result.starts_with("extraction timed out after"), "{result}");
}
#[test]
fn test_strip_thiserror_placeholders_positional() {
assert_eq!(strip_thiserror_placeholders("I/O error: {0}"), "I/O error");
assert_eq!(strip_thiserror_placeholders("Parse error: {0}"), "Parse error");
}
#[test]
fn test_strip_thiserror_placeholders_no_placeholder() {
assert_eq!(strip_thiserror_placeholders("not found"), "not found");
assert_eq!(strip_thiserror_placeholders("lock poisoned"), "lock poisoned");
}
#[test]
fn test_acronym_aware_snake_phrase_recognizes_acronyms() {
assert_eq!(acronym_aware_snake_phrase("IoError"), "IO error");
assert_eq!(acronym_aware_snake_phrase("OcrError"), "OCR error");
assert_eq!(acronym_aware_snake_phrase("PdfParse"), "PDF parse");
assert_eq!(acronym_aware_snake_phrase("HttpRequestFailed"), "HTTP request failed");
assert_eq!(acronym_aware_snake_phrase("UrlInvalid"), "URL invalid");
}
#[test]
fn test_acronym_aware_snake_phrase_plain_words() {
assert_eq!(acronym_aware_snake_phrase("Other"), "other");
assert_eq!(acronym_aware_snake_phrase("ParseError"), "parse error");
assert_eq!(acronym_aware_snake_phrase("LockPoisoned"), "lock poisoned");
}
#[test]
fn test_variant_display_message_acronym_first_word() {
let variant = ErrorVariant {
name: "Io".to_string(),
message_template: Some("I/O error: {0}".to_string()),
fields: vec![tuple_field(0)],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
let msg = variant_display_message(&variant);
assert!(!msg.contains('{'), "no placeholders allowed: {msg}");
}
#[test]
fn test_variant_display_message_no_template_uses_acronyms() {
let variant = ErrorVariant {
name: "IoError".to_string(),
message_template: None,
fields: vec![],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
assert_eq!(variant_display_message(&variant), "IO error");
}
#[test]
fn test_variant_display_message_struct_template_no_leak() {
let variant = ErrorVariant {
name: "Ocr".to_string(),
message_template: Some("OCR error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
};
let msg = variant_display_message(&variant);
assert_eq!(msg, "OCR error", "must not leak {{message}} placeholder: {msg}");
}
#[test]
fn test_go_sentinels_no_placeholder_leak() {
let error = ErrorDef {
name: "KreuzbergError".to_string(),
rust_path: "kreuzberg::KreuzbergError".to_string(),
original_rust_path: String::new(),
variants: vec![
ErrorVariant {
name: "Io".to_string(),
message_template: Some("IO error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Ocr".to_string(),
message_template: Some("OCR error: {message}".to_string()),
fields: vec![named_field("message")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
ErrorVariant {
name: "Timeout".to_string(),
message_template: Some(
"extraction timed out after {elapsed_ms}ms (limit: {limit_ms}ms)".to_string(),
),
fields: vec![named_field("elapsed_ms"), named_field("limit_ms")],
has_source: false,
has_from: false,
is_unit: false,
doc: String::new(),
},
],
doc: String::new(),
};
let output = gen_go_sentinel_errors(std::slice::from_ref(&error));
assert!(
!output.contains('{'),
"Go sentinels must not contain raw placeholders:\n{output}"
);
assert!(
output.contains("ErrIo = errors.New(\"IO error\")"),
"expected acronym-preserving Io sentinel, got:\n{output}"
);
assert!(
output.contains("ErrOcr = errors.New(\"OCR error\")"),
"expected acronym-preserving Ocr sentinel, got:\n{output}"
);
assert!(
output.contains("ErrTimeout = errors.New(\"extraction timed out after"),
"expected timeout sentinel to start with the prose, got:\n{output}"
);
}
#[test]
fn test_gen_ffi_error_codes() {
let error = sample_error();
let output = gen_ffi_error_codes(&error);
assert!(output.contains("CONVERSION_ERROR_NONE = 0"));
assert!(output.contains("CONVERSION_ERROR_PARSE_ERROR = 1"));
assert!(output.contains("CONVERSION_ERROR_IO_ERROR = 2"));
assert!(output.contains("CONVERSION_ERROR_OTHER = 3"));
assert!(output.contains("conversion_error_t;"));
assert!(output.contains("conversion_error_error_message(conversion_error_t code)"));
}
#[test]
fn test_gen_go_error_types() {
let error = sample_error();
let output = gen_go_error_types(&error, "mylib");
assert!(output.contains("ErrParseError = errors.New("));
assert!(output.contains("ErrIoError = errors.New("));
assert!(output.contains("ErrOther = errors.New("));
assert!(output.contains("type ConversionError struct {"));
assert!(output.contains("Code string"));
assert!(output.contains("func (e *ConversionError) Error() string"));
assert!(output.contains("// ErrParseError is returned when"));
assert!(output.contains("// ErrIoError is returned when"));
assert!(output.contains("// ErrOther is returned when"));
}
#[test]
fn test_gen_go_error_types_stutter_strip() {
let error = sample_error();
let output = gen_go_error_types(&error, "conversion");
assert!(
output.contains("type Error struct {"),
"expected stutter strip, got:\n{output}"
);
assert!(
output.contains("func (e *Error) Error() string"),
"expected stutter strip, got:\n{output}"
);
assert!(output.contains("ErrParseError = errors.New("));
}
#[test]
fn test_gen_java_error_types() {
let error = sample_error();
let files = gen_java_error_types(&error, "dev.kreuzberg.test");
assert_eq!(files.len(), 4);
assert_eq!(files[0].0, "ConversionErrorException");
assert!(
files[0]
.1
.contains("public class ConversionErrorException extends Exception")
);
assert!(files[0].1.contains("package dev.kreuzberg.test;"));
assert_eq!(files[1].0, "ParseErrorException");
assert!(
files[1]
.1
.contains("public class ParseErrorException extends ConversionErrorException")
);
assert_eq!(files[2].0, "IoErrorException");
assert_eq!(files[3].0, "OtherException");
}
#[test]
fn test_gen_csharp_error_types() {
let error = sample_error();
let files = gen_csharp_error_types(&error, "Kreuzberg.Test", None);
assert_eq!(files.len(), 4);
assert_eq!(files[0].0, "ConversionErrorException");
assert!(files[0].1.contains("public class ConversionErrorException : Exception"));
assert!(files[0].1.contains("namespace Kreuzberg.Test;"));
assert_eq!(files[1].0, "ParseErrorException");
assert!(
files[1]
.1
.contains("public class ParseErrorException : ConversionErrorException")
);
assert_eq!(files[2].0, "IoErrorException");
assert_eq!(files[3].0, "OtherException");
}
#[test]
fn test_gen_csharp_error_types_with_fallback() {
let error = sample_error();
let files = gen_csharp_error_types(&error, "Kreuzberg.Test", Some("TestLibException"));
assert_eq!(files.len(), 4);
assert!(
files[0]
.1
.contains("public class ConversionErrorException : TestLibException")
);
assert!(
files[1]
.1
.contains("public class ParseErrorException : ConversionErrorException")
);
}
#[test]
fn test_python_exception_name_no_conflict() {
assert_eq!(python_exception_name("ParseError", "ConversionError"), "ParseError");
assert_eq!(python_exception_name("Other", "ConversionError"), "OtherError");
}
#[test]
fn test_python_exception_name_shadows_builtin() {
assert_eq!(
python_exception_name("Connection", "CrawlError"),
"CrawlConnectionError"
);
assert_eq!(python_exception_name("Timeout", "CrawlError"), "CrawlTimeoutError");
assert_eq!(
python_exception_name("ConnectionError", "CrawlError"),
"CrawlConnectionError"
);
}
#[test]
fn test_python_exception_name_no_double_prefix() {
assert_eq!(
python_exception_name("CrawlConnectionError", "CrawlError"),
"CrawlConnectionError"
);
}
}