use alef_backend_csharp::CsharpBackend;
use alef_core::backend::Backend;
use alef_core::config::{NewAlefConfig, ResolvedCrateConfig};
use alef_core::ir::{
ApiSurface, DefaultValue, EnumDef, EnumVariant, FieldDef, FunctionDef, ParamDef, PrimitiveType, TypeDef, TypeRef,
};
#[test]
fn test_basic_generation() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "kreuzberg".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "Config".to_string(),
rust_path: "kreuzberg::Config".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
name: "timeout".to_string(),
ty: TypeRef::Primitive(PrimitiveType::U32),
optional: true,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "backend".to_string(),
ty: TypeRef::String,
optional: true,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: "Extraction configuration".to_string(),
cfg: None,
}],
functions: vec![FunctionDef {
name: "extract_file_sync".to_string(),
rust_path: "kreuzberg::extract_file_sync".to_string(),
original_rust_path: String::new(),
params: vec![
ParamDef {
name: "path".to_string(),
ty: TypeRef::String,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
},
ParamDef {
name: "config".to_string(),
ty: TypeRef::Named("Config".to_string()),
optional: true,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
},
],
return_type: TypeRef::String,
is_async: false,
error_type: Some("Error".to_string()),
doc: "Extract text from file".to_string(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
}],
enums: vec![EnumDef {
name: "OcrBackend".to_string(),
rust_path: "kreuzberg::OcrBackend".to_string(),
original_rust_path: String::new(),
variants: vec![
EnumVariant {
name: "Tesseract".to_string(),
fields: vec![],
is_tuple: false,
doc: "Tesseract OCR".to_string(),
is_default: false,
serde_rename: None,
},
EnumVariant {
name: "PaddleOcr".to_string(),
fields: vec![],
is_tuple: false,
doc: "PaddleOCR backend".to_string(),
is_default: false,
serde_rename: None,
},
],
doc: "Available OCR backends".to_string(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
}],
errors: vec![],
};
let config = make_config("kreuzberg", Some("Kreuzberg"), true);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok(), "Generation should succeed");
let files = result.unwrap();
assert!(!files.is_empty(), "Should generate files");
let file_names: Vec<String> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
assert!(
file_names.iter().any(|f| f.contains("NativeMethods.cs")),
"Should generate NativeMethods.cs"
);
assert!(
file_names.iter().any(|f| f.contains("KreuzbergException.cs")),
"Should generate exception class"
);
assert!(
file_names.iter().any(|f| f.contains("KreuzbergLib.cs")),
"Should generate wrapper class"
);
assert!(
file_names.iter().any(|f| f.contains("Config.cs")),
"Should generate Config type"
);
assert!(
file_names.iter().any(|f| f.contains("OcrBackend.cs")),
"Should generate OcrBackend enum"
);
let native_methods = files
.iter()
.find(|f| f.path.to_string_lossy().contains("NativeMethods.cs"))
.unwrap();
assert!(native_methods.content.contains("DllImport"), "Should contain DllImport");
assert!(
native_methods.content.contains("NativeMethods"),
"Should define NativeMethods class"
);
assert!(
native_methods.content.contains("kreuzberg_ffi"),
"Should reference kreuzberg_ffi library"
);
let wrapper = files
.iter()
.find(|f| f.path.to_string_lossy().contains("KreuzbergLib.cs"))
.unwrap();
assert!(
wrapper.content.contains("public static class KreuzbergLib"),
"Should define wrapper class"
);
assert!(
wrapper.content.contains("ExtractFileSync"),
"Should define wrapper method"
);
let config_type = files
.iter()
.find(|f| f.path.to_string_lossy().contains("Config.cs"))
.unwrap();
assert!(
config_type.content.contains("public sealed class Config"),
"Should define Config sealed class"
);
let enum_type = files
.iter()
.find(|f| f.path.to_string_lossy().contains("OcrBackend.cs"))
.unwrap();
assert!(
enum_type.content.contains("public enum OcrBackend"),
"Should define OcrBackend enum"
);
}
#[test]
fn test_namespace_resolution() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "my-lib".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = make_config("my-lib", Some("MyCompany.MyLib"), false);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok());
let files = result.unwrap();
let file_names: Vec<String> = files.iter().map(|f| f.path.to_string_lossy().to_string()).collect();
assert!(
file_names.iter().any(|f| f.contains("MyCompany/MyLib")),
"Should create nested namespace directories"
);
}
#[test]
fn test_generated_header() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = make_config("test", None, false);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok());
let files = result.unwrap();
for file in &files {
assert!(
file.generated_header,
"All generated files should have generated_header=true"
);
assert!(
file.content.contains("auto-generated"),
"Content should contain auto-generated marker"
);
}
}
#[test]
fn test_type_mapping() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "Numbers".to_string(),
rust_path: "test::Numbers".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
name: "u32_val".to_string(),
ty: TypeRef::Primitive(PrimitiveType::U32),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "i64_val".to_string(),
ty: TypeRef::Primitive(PrimitiveType::I64),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "string_val".to_string(),
ty: TypeRef::String,
optional: true,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "list_val".to_string(),
ty: TypeRef::Vec(Box::new(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: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = make_config("test", None, false);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok());
let files = result.unwrap();
let numbers_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("Numbers.cs"))
.unwrap();
let content = &numbers_file.content;
assert!(content.contains("uint U32Val"), "U32 should map to uint");
assert!(content.contains("long I64Val"), "I64 should map to long");
assert!(
content.contains("string? StringVal"),
"Optional string should be nullable"
);
assert!(
content.contains("List<string> ListVal"),
"Vec<String> should map to List<string>"
);
}
#[test]
fn test_tuple_struct_fields_skipped() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "TupleStruct".to_string(),
rust_path: "test::TupleStruct".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
name: "_0".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: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "_1".to_string(),
ty: TypeRef::Primitive(PrimitiveType::U32),
optional: false,
default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
typed_default: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = make_config("test", None, false);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok());
let files = result.unwrap();
let tuple_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("TupleStruct.cs"));
assert!(
tuple_file.is_none(),
"Tuple struct with only positional fields should not generate a .cs file"
);
}
#[test]
fn test_mixed_struct_skips_tuple_fields_only() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "MixedStruct".to_string(),
rust_path: "test::MixedStruct".to_string(),
original_rust_path: String::new(),
fields: vec![
FieldDef {
name: "_0".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: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
FieldDef {
name: "label".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: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
},
],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_default: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = make_config("test", None, false);
let result = backend.generate_bindings(&api, &config);
assert!(result.is_ok());
let files = result.unwrap();
let mixed_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("MixedStruct.cs"))
.expect("MixedStruct.cs should be generated since it has named fields");
assert!(
mixed_file.content.contains("Label"),
"Named field 'label' should generate a property"
);
assert!(
!mixed_file.content.contains("\"_0\""),
"Tuple field '_0' should not appear in JSON property names"
);
}
fn make_config(crate_name: &str, namespace: Option<&str>, with_ffi: bool) -> ResolvedCrateConfig {
let ns_line = match namespace {
Some(ns) => format!("namespace = \"{ns}\"\n"),
None => String::new(),
};
let ffi_section = if with_ffi {
format!("[crates.ffi]\nprefix = \"{crate_name}\"\nerror_style = \"last_error\"\n")
} else {
String::new()
};
let toml_str = format!(
"[workspace]\nlanguages = [\"csharp\"]\n[[crates]]\nname = \"{crate_name}\"\nsources = [\"src/lib.rs\"]\n[crates.csharp]\n{ns_line}{ffi_section}",
);
let cfg: NewAlefConfig = toml::from_str(&toml_str).unwrap();
cfg.resolve().unwrap().remove(0)
}
fn minimal_csharp_config(crate_name: &str) -> ResolvedCrateConfig {
make_config(crate_name, Some("Test"), true)
}
#[test]
fn test_duration_field_emits_single_nullable_not_double() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "BrowserConfig".to_string(),
rust_path: "test::BrowserConfig".to_string(),
original_rust_path: String::new(),
has_default: true,
fields: vec![FieldDef {
name: "timeout".to_string(),
ty: TypeRef::Duration,
optional: false,
default: None,
typed_default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
}],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = minimal_csharp_config("test");
let files = backend
.generate_bindings(&api, &config)
.expect("generation should succeed");
let cs_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("BrowserConfig.cs"))
.expect("BrowserConfig.cs should be generated");
assert!(
!cs_file.content.contains("ulong??"),
"Duration field must not produce ulong?? (double nullable); got:\n{}",
cs_file.content
);
assert!(
cs_file.content.contains("ulong? Timeout"),
"Duration field should emit `ulong? Timeout`; got:\n{}",
cs_file.content
);
}
#[test]
fn test_optional_ulong_field_emits_single_nullable() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "CrawlConfig".to_string(),
rust_path: "test::CrawlConfig".to_string(),
original_rust_path: String::new(),
has_default: true,
fields: vec![FieldDef {
name: "max_depth".to_string(),
ty: TypeRef::Optional(Box::new(TypeRef::Primitive(PrimitiveType::U64))),
optional: true,
default: None,
typed_default: Some(DefaultValue::None),
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
}],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![],
errors: vec![],
};
let config = minimal_csharp_config("test");
let files = backend
.generate_bindings(&api, &config)
.expect("generation should succeed");
let cs_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("CrawlConfig.cs"))
.expect("CrawlConfig.cs should be generated");
assert!(
!cs_file.content.contains("ulong??"),
"Optional<ulong> field must not produce ulong?? (double nullable); got:\n{}",
cs_file.content
);
assert!(
cs_file.content.contains("ulong? MaxDepth"),
"Optional<ulong> field should emit `ulong? MaxDepth`; got:\n{}",
cs_file.content
);
}
#[test]
fn test_plain_enum_with_default_emits_single_nullable() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "test".to_string(),
version: "0.1.0".to_string(),
types: vec![TypeDef {
name: "Config".to_string(),
rust_path: "test::Config".to_string(),
original_rust_path: String::new(),
has_default: true,
fields: vec![FieldDef {
name: "mode".to_string(),
ty: TypeRef::Named("Mode".to_string()),
optional: false,
default: None,
typed_default: None,
doc: String::new(),
sanitized: false,
is_boxed: false,
type_rust_path: None,
cfg: None,
core_wrapper: alef_core::ir::CoreWrapper::None,
vec_inner_core_wrapper: alef_core::ir::CoreWrapper::None,
newtype_wrapper: None,
serde_rename: None,
}],
methods: vec![],
is_opaque: false,
is_clone: true,
is_copy: false,
is_trait: false,
has_stripped_cfg_fields: false,
is_return_type: false,
serde_rename_all: None,
has_serde: false,
super_traits: vec![],
doc: String::new(),
cfg: None,
}],
functions: vec![],
enums: vec![EnumDef {
name: "Mode".to_string(),
rust_path: "test::Mode".to_string(),
original_rust_path: String::new(),
variants: vec![EnumVariant {
name: "Fast".to_string(),
fields: vec![],
is_tuple: false,
doc: String::new(),
is_default: false,
serde_rename: None,
}],
doc: String::new(),
cfg: None,
is_copy: false,
has_serde: false,
serde_tag: None,
serde_untagged: false,
serde_rename_all: None,
}],
errors: vec![],
};
let config = minimal_csharp_config("test");
let files = backend
.generate_bindings(&api, &config)
.expect("generation should succeed");
let cs_file = files
.iter()
.find(|f| f.path.to_string_lossy().contains("Config.cs"))
.expect("Config.cs should be generated");
assert!(
!cs_file.content.contains("Mode??"),
"Enum field must not produce Mode?? (double nullable); got:\n{}",
cs_file.content
);
assert!(
cs_file.content.contains("Mode?"),
"Enum field with null default should be nullable; got:\n{}",
cs_file.content
);
}
#[test]
fn test_bytes_result_func_emits_out_param_pinvoke_and_wrapper() {
let backend = CsharpBackend;
let api = ApiSurface {
crate_name: "kreuzberg".to_string(),
version: "0.1.0".to_string(),
types: vec![],
functions: vec![FunctionDef {
name: "process_image".to_string(),
rust_path: "kreuzberg::process_image".to_string(),
original_rust_path: String::new(),
params: vec![ParamDef {
name: "data".to_string(),
ty: TypeRef::Bytes,
optional: false,
default: None,
sanitized: false,
typed_default: None,
is_ref: false,
is_mut: false,
newtype_wrapper: None,
original_type: None,
}],
return_type: TypeRef::Bytes,
is_async: false,
error_type: Some("KreuzbergError".to_string()),
doc: String::new(),
cfg: None,
sanitized: false,
return_sanitized: false,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
}],
enums: vec![],
errors: vec![],
};
let config = make_config("kreuzberg", Some("Kreuzberg"), true);
let files = backend
.generate_bindings(&api, &config)
.expect("generation must succeed");
let native = files
.iter()
.find(|f| f.path.to_string_lossy().contains("NativeMethods.cs"))
.expect("NativeMethods.cs must be generated");
assert!(
native.content.contains("internal static extern int ProcessImage"),
"P/Invoke return must be int for bytes_result; got:\n{}",
native.content
);
assert!(
native.content.contains("IntPtr data") && native.content.contains("UIntPtr dataLen"),
"P/Invoke must have byte-slice length parameter; got:\n{}",
native.content
);
assert!(
native.content.contains("out IntPtr outPtr"),
"P/Invoke must have out IntPtr outPtr; got:\n{}",
native.content
);
assert!(
native.content.contains("out UIntPtr outLen"),
"P/Invoke must have out UIntPtr outLen; got:\n{}",
native.content
);
assert!(
native.content.contains("out UIntPtr outCap"),
"P/Invoke must have out UIntPtr outCap; got:\n{}",
native.content
);
assert!(
native.content.contains("internal static extern void FreeBytes"),
"NativeMethods.cs must have FreeBytes; got:\n{}",
native.content
);
let wrapper = files
.iter()
.find(|f| f.path.to_string_lossy().contains("KreuzbergLib.cs"))
.expect("KreuzbergLib.cs must be generated");
assert!(
wrapper.content.contains("public static byte[] ProcessImage"),
"Wrapper return must be byte[] for bytes_result; got:\n{}",
wrapper.content
);
assert!(
wrapper.content.contains("(UIntPtr)data.Length"),
"Wrapper must pass byte-length argument (UIntPtr)data.Length; got:\n{}",
wrapper.content
);
assert!(
wrapper.content.contains("rc != 0"),
"Wrapper must check rc != 0; got:\n{}",
wrapper.content
);
assert!(
wrapper.content.contains("Marshal.Copy"),
"Wrapper must call Marshal.Copy; got:\n{}",
wrapper.content
);
assert!(
wrapper.content.contains("FreeBytes"),
"Wrapper must call NativeMethods.FreeBytes; got:\n{}",
wrapper.content
);
}