use super::*;
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::{MethodDef, PrimitiveType, TypeDef};
fn make_method(name: &str, return_type: TypeRef) -> MethodDef {
MethodDef {
name: name.to_string(),
return_type,
receiver: Some(crate::core::ir::ReceiverKind::Ref),
cfg: None,
..MethodDef::default()
}
}
fn ocr_shaped_trait() -> TypeDef {
TypeDef {
name: "OcrBackend".to_string(),
rust_path: "sample_core::OcrBackend".to_string(),
is_trait: true,
methods: vec![
make_method("supports_language", TypeRef::Primitive(PrimitiveType::Bool)),
make_method("backend_type", TypeRef::Named("OcrBackendType".to_string())),
make_method("supported_languages", TypeRef::Vec(Box::new(TypeRef::String))),
],
..TypeDef::default()
}
}
fn ocr_bridge_config() -> ResolvedCrateConfig {
ResolvedCrateConfig {
trait_bridges: vec![TraitBridgeConfig {
trait_name: "OcrBackend".to_string(),
super_trait: Some("Plugin".to_string()),
..TraitBridgeConfig::default()
}],
..ResolvedCrateConfig::default()
}
}
fn temp_rooted_bridge_config(temp: &std::path::Path) -> ResolvedCrateConfig {
let mut config = ocr_bridge_config();
config.name = "sample".to_string();
config.trait_bridges[0].context_type = Some("VisitContext".to_string());
config.trait_bridges[0].result_type = Some("VisitOutcome".to_string());
config.output_paths.insert("csharp".to_string(), temp.to_path_buf());
config
}
fn snapshot_tree(root: &std::path::Path) -> std::collections::BTreeMap<PathBuf, Option<Vec<u8>>> {
let mut snapshot = std::collections::BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
snapshot.insert(path.clone(), None);
stack.push(path);
} else {
snapshot.insert(path.clone(), Some(std::fs::read(&path).unwrap_or_default()));
}
}
}
snapshot
}
#[test]
fn absent_ffi_section_deletes_no_visitor_files() {
let temp = tempfile::tempdir().expect("temp output root");
let config = temp_rooted_bridge_config(temp.path());
assert!(
config.ffi.is_none(),
"sanity: this test is only about the absent-[ffi] branch"
);
let victims = files::stale_visitor_filenames(&config);
assert!(
victims.len() > 2,
"sanity: the blast radius must include the consumer-named context/result classes, not \
just the two hardcoded support files; got {victims:?}"
);
let base_path = temp.path().join(config.csharp_namespace());
std::fs::create_dir_all(&base_path).expect("namespace directory");
for filename in &victims {
std::fs::write(base_path.join(filename), "// hand written\n").expect("seed victim file");
}
let api = ApiSurface {
crate_name: "sample".to_string(),
types: vec![ocr_shaped_trait()],
..ApiSurface::default()
};
CsharpBackend
.generate_bindings(&api, &config)
.expect("C# bindings must render");
for filename in &victims {
let path = base_path.join(filename);
assert_eq!(
std::fs::read_to_string(&path).ok().as_deref(),
Some("// hand written\n"),
"{} must survive a render with no [ffi] section, byte for byte",
path.display()
);
}
}
#[test]
fn render_stage_writes_nothing_under_the_output_root() {
let temp = tempfile::tempdir().expect("temp output root");
let config = temp_rooted_bridge_config(temp.path());
let base_path = temp.path().join(config.csharp_namespace());
std::fs::create_dir_all(&base_path).expect("namespace directory");
for filename in files::stale_visitor_filenames(&config) {
std::fs::write(base_path.join(filename), "// hand written\n").expect("seed file");
}
std::fs::write(base_path.join("NativeMethods.cs"), "// stale generated\n").expect("seed emitted path");
let before = snapshot_tree(temp.path());
assert!(
before.len() > 4,
"sanity: an empty tree would make the comparison below vacuous; got {before:?}"
);
let api = ApiSurface {
crate_name: "sample".to_string(),
types: vec![ocr_shaped_trait()],
..ApiSurface::default()
};
CsharpBackend
.generate_bindings(&api, &config)
.expect("C# bindings must render");
assert_eq!(
snapshot_tree(temp.path()),
before,
"generate_bindings must not create, modify, or remove anything under the output root"
);
}
#[test]
fn unemitted_visitor_files_are_reported_not_removed() {
let temp = tempfile::tempdir().expect("temp output root");
let base_path = temp.path();
std::fs::write(base_path.join("IVisitor.cs"), "// hand written\n").expect("seed present file");
let reported = files::report_unemitted_visitor_files(
base_path,
&["IVisitor.cs".to_string(), "VisitorCallbacks.cs".to_string()],
&std::collections::HashSet::new(),
);
assert_eq!(
reported,
vec![base_path.join("IVisitor.cs")],
"only the file that exists is reported"
);
assert!(
base_path.join("IVisitor.cs").is_file(),
"reporting must leave the file on disk"
);
}
#[test]
fn a_file_this_run_emits_is_not_reported_as_unemitted() {
let temp = tempfile::tempdir().expect("temp output root");
let base_path = temp.path();
std::fs::write(base_path.join("NodeContext.cs"), "// emitted last run\n").expect("seed");
std::fs::write(base_path.join("IVisitor.cs"), "// hand written\n").expect("seed");
let emitted = std::collections::HashSet::from([base_path.join("NodeContext.cs")]);
let reported = files::report_unemitted_visitor_files(
base_path,
&["NodeContext.cs".to_string(), "IVisitor.cs".to_string()],
&emitted,
);
assert_eq!(
reported,
vec![base_path.join("IVisitor.cs")],
"a path this run is writing must be excluded; only the genuinely unemitted one remains"
);
}
fn emitted_slot_comments(content: &str) -> Vec<String> {
content
.lines()
.filter_map(|line| line.trim().strip_prefix("// Slot "))
.filter_map(|rest| rest.split_once(": "))
.map(|(_, name)| name.to_string())
.collect()
}
#[test]
fn excluded_return_type_does_not_remove_a_vtable_slot() {
let api = ApiSurface {
crate_name: "sample".to_string(),
types: vec![
ocr_shaped_trait(),
TypeDef {
name: "OcrBackendType".to_string(),
binding_excluded: true,
..TypeDef::default()
},
],
..ApiSurface::default()
};
let files = CsharpBackend
.generate_bindings(&api, &ocr_bridge_config())
.expect("C# bindings");
let bridges = files
.iter()
.find(|file| file.path.ends_with("TraitBridges.cs"))
.expect("TraitBridges.cs");
assert_eq!(
emitted_slot_comments(&bridges.content),
vec![
"name_fn",
"version_fn",
"initialize_fn",
"shutdown_fn",
"supports_language_fn",
"backend_type_fn",
"supported_languages_fn",
"free_string",
"free_user_data",
],
"every Rust vtable field must get a slot, at its own index"
);
assert!(
bridges.content.contains("Marshal.AllocHGlobal(IntPtr.Size * 9)"),
"the block must stay as wide as the Rust vtable struct;\nactual:\n{}",
bridges.content
);
assert!(
bridges.content.contains("string BackendType { get; }"),
"an excluded return type degrades to a JSON string rather than removing the method;\nactual:\n{}",
bridges.content
);
}
#[test]
fn vtable_slot_check_accepts_a_faithful_bridge() {
let trait_def = ocr_shaped_trait();
let api = ApiSurface {
types: vec![trait_def.clone()],
..ApiSurface::default()
};
let emitted = crate::codegen::generators::trait_bridge::vtable_slot_names(&trait_def, true, &[]);
assert_vtable_matches_rust_struct(&api, &trait_def, true, &[], &emitted).expect("matching slot lists must pass");
}
#[test]
fn vtable_slot_check_rejects_a_dropped_slot() {
let source_trait = ocr_shaped_trait();
let api = ApiSurface {
types: vec![source_trait.clone()],
..ApiSurface::default()
};
let mut pruned_trait = source_trait.clone();
pruned_trait.methods.retain(|method| method.name != "backend_type");
let emitted = crate::codegen::generators::trait_bridge::vtable_slot_names(&pruned_trait, true, &[]);
let error = assert_vtable_matches_rust_struct(&api, &pruned_trait, true, &[], &emitted)
.expect_err("a bridge missing a slot must fail generation");
let message = error.to_string();
assert!(
message.contains("Rust slots (9)") && message.contains("C# slots (8)"),
"the failure must report both slot counts;\nactual:\n{message}"
);
assert!(
message.contains("backend_type"),
"the failure must name the slot that disagrees;\nactual:\n{message}"
);
}
#[test]
fn vtable_slot_check_rejects_a_reordered_slot() {
let trait_def = ocr_shaped_trait();
let api = ApiSurface {
types: vec![trait_def.clone()],
..ApiSurface::default()
};
let mut reordered = crate::codegen::generators::trait_bridge::vtable_slot_names(&trait_def, true, &[]);
reordered.swap(5, 6);
let error = assert_vtable_matches_rust_struct(&api, &trait_def, true, &[], &reordered)
.expect_err("a bridge with the right slot count in the wrong order must fail generation");
let message = error.to_string();
assert!(
message.contains("Rust slots (9)") && message.contains("C# slots (9)"),
"a reordering keeps the count, so the counts alone must not be what fails;\nactual:\n{message}"
);
assert!(
message.contains("backend_type, supported_languages") && message.contains("supported_languages, backend_type"),
"the failure must show both orders so the swapped pair is identifiable;\nactual:\n{message}"
);
}
#[test]
fn vtable_slot_check_rejects_a_slot_for_a_skipped_method() {
let trait_def = ocr_shaped_trait();
let api = ApiSurface {
types: vec![trait_def.clone()],
..ApiSurface::default()
};
let skip = vec!["backend_type".to_string()];
let over_counted = crate::codegen::generators::trait_bridge::vtable_slot_names(&trait_def, true, &[]);
let error = assert_vtable_matches_rust_struct(&api, &trait_def, true, &skip, &over_counted)
.expect_err("an extra slot must fail generation");
let message = error.to_string();
assert!(
message.contains("Rust slots (8)") && message.contains("C# slots (9)"),
"the failure must report both slot counts;\nactual:\n{message}"
);
}