pub mod assertions;
pub mod cargo_config;
pub mod cargo_toml;
pub mod http;
pub mod mock_server;
pub mod test_file;
mod args;
mod assertion_helpers;
mod assertion_synthetic;
pub use cargo_config::render_cargo_config;
pub use cargo_toml::render_cargo_toml;
pub use mock_server::{render_common_module, render_mock_server_binary, render_mock_server_module};
use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use anyhow::Result;
use std::path::PathBuf;
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::sanitize_filename;
use crate::e2e::fixture::{Fixture, FixtureGroup};
use super::E2eCodegen;
use test_file::{is_skipped, render_test_file};
pub struct RustE2eCodegen;
impl E2eCodegen for RustE2eCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
_type_defs: &[crate::core::ir::TypeDef],
_enums: &[crate::core::ir::EnumDef],
) -> Result<Vec<GeneratedFile>> {
let mut files = Vec::new();
let output_base = PathBuf::from(e2e_config.effective_output()).join("rust");
let crate_name = resolve_crate_name(e2e_config, config);
let crate_path = resolve_crate_path(e2e_config, &crate_name);
let dep_name = crate_name.replace('-', "_");
let all_call_configs = std::iter::once(&e2e_config.call).chain(e2e_config.calls.values());
let needs_serde_json = all_call_configs
.flat_map(|c| c.args.iter())
.any(|a| a.arg_type == "json_object" || a.arg_type == "handle");
let needs_mock_server = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.any(|f| !is_skipped(f, "rust") && f.needs_mock_server());
let needs_http_tests = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.any(|f| !is_skipped(f, "rust") && f.http.is_some());
let needs_tower_http = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.filter(|f| !is_skipped(f, "rust"))
.filter_map(|f| f.http.as_ref())
.filter_map(|h| h.handler.middleware.as_ref())
.any(|m| m.cors.is_some() || m.static_files.is_some());
let any_async_call = std::iter::once(&e2e_config.call)
.chain(e2e_config.calls.values())
.any(|c| c.r#async);
let needs_tokio = needs_mock_server || needs_http_tests || any_async_call;
let all_call_args_for_anyhow = std::iter::once(&e2e_config.call)
.chain(e2e_config.calls.values())
.flat_map(|c| c.args.iter())
.any(|a| a.arg_type == "test_backend");
let any_fixture_test_backend = groups
.iter()
.flat_map(|g| g.fixtures.iter())
.filter(|f| !is_skipped(f, "rust"))
.any(|f| f.args.iter().any(|a| a.arg_type == "test_backend"));
let needs_anyhow = all_call_args_for_anyhow || any_fixture_test_backend;
if let Some(content) = render_cargo_config(&e2e_config.env) {
files.push(GeneratedFile {
path: output_base.join(".cargo").join("config.toml"),
content,
generated_header: false,
});
}
let crate_version = resolve_crate_version(e2e_config).or_else(|| config.resolved_version());
files.push(GeneratedFile {
path: output_base.join("Cargo.toml"),
content: render_cargo_toml(
&crate_name,
&dep_name,
&crate_path,
needs_serde_json,
needs_mock_server,
needs_http_tests,
needs_tokio,
needs_tower_http,
needs_anyhow,
e2e_config.dep_mode,
crate_version.as_deref(),
&config.features,
),
generated_header: true,
});
if needs_mock_server {
files.push(GeneratedFile {
path: output_base.join("tests").join("mock_server.rs"),
content: render_mock_server_module(),
generated_header: true,
});
files.push(GeneratedFile {
path: output_base.join("tests").join("common.rs"),
content: render_common_module(),
generated_header: true,
});
}
if needs_mock_server || needs_http_tests {
files.push(GeneratedFile {
path: output_base.join("src").join("main.rs"),
content: render_mock_server_binary(),
generated_header: true,
});
}
for group in groups {
let fixtures: Vec<&Fixture> = group.fixtures.iter().filter(|f| !is_skipped(f, "rust")).collect();
if fixtures.is_empty() {
continue;
}
let filename = format!("{}_test.rs", sanitize_filename(&group.category));
let content = render_test_file(
&group.category,
&fixtures,
e2e_config,
config,
_type_defs,
&dep_name,
needs_mock_server,
);
files.push(GeneratedFile {
path: output_base.join("tests").join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"rust"
}
}
fn resolve_crate_name(_e2e_config: &E2eConfig, config: &ResolvedCrateConfig) -> String {
config.name.clone()
}
fn resolve_crate_path(e2e_config: &E2eConfig, crate_name: &str) -> String {
e2e_config
.resolve_package("rust")
.and_then(|p| p.path.clone())
.unwrap_or_else(|| format!("../../crates/{crate_name}"))
}
fn resolve_crate_version(e2e_config: &E2eConfig) -> Option<String> {
e2e_config.resolve_package("rust").and_then(|p| p.version.clone())
}
pub fn emit_test_backend(
trait_bridge: &crate::core::config::TraitBridgeConfig,
methods: &[&crate::core::ir::MethodDef],
fixture: &Fixture,
) -> super::TestBackendEmission {
use crate::codegen::defaults::language_defaults;
use std::fmt::Write as FmtWrite;
let stub_name = format!("TestStub{}", fixture_id_to_pascal_case(&fixture.id));
let trait_name = &trait_bridge.trait_name;
let backend_name = extract_backend_name_from_input(&fixture.input, &fixture.id);
let defaults = language_defaults("rust");
let mut type_imports: Vec<String> = Vec::new();
type_imports.push(trait_name.clone());
let mut setup = String::new();
let crate_module: Option<&str> = trait_bridge
.super_trait
.as_deref()
.and_then(|s| s.split("::").next())
.filter(|s| !s.is_empty());
let _ = writeln!(setup, "struct {stub_name} {{ _name: &'static str }}");
if let Some(super_trait) = &trait_bridge.super_trait {
let super_short = super_trait.split("::").last().unwrap_or(super_trait.as_str());
let _ = writeln!(setup, "impl {super_trait} for {stub_name} {{");
let _ = writeln!(setup, " fn name(&self) -> &str {{ self._name }}");
let _ = writeln!(setup, "}}");
if !super_short.is_empty() && !super_trait.contains("::") {
type_imports.push(super_short.to_string());
}
}
let has_async_methods = methods
.iter()
.any(|m| !(m.has_default_impl || trait_bridge.super_trait.is_some() && m.name == "name") && m.is_async);
if has_async_methods {
let _ = writeln!(setup, "#[async_trait::async_trait]");
}
let _ = writeln!(setup, "impl {trait_name} for {stub_name} {{");
for method in methods {
if method.has_default_impl {
continue;
}
if trait_bridge.super_trait.is_some() && method.name == "name" {
continue;
}
emit_rust_stub_method(&mut setup, method, &*defaults, &mut type_imports, crate_module);
}
let _ = writeln!(setup, "}}");
type_imports.sort();
type_imports.dedup();
let arg_expr = format!("std::sync::Arc::new({stub_name} {{ _name: \"{backend_name}\" }})");
let type_imports = type_imports
.into_iter()
.filter(|s| {
!matches!(
s.as_str(),
"bool"
| "u8"
| "u16"
| "u32"
| "u64"
| "i8"
| "i16"
| "i32"
| "i64"
| "f32"
| "f64"
| "usize"
| "isize"
| "String"
| "str"
| "Vec"
| "Option"
| "Result"
| "()"
)
})
.collect();
super::TestBackendEmission {
setup_block: setup,
arg_expr,
type_imports,
teardown_block: String::new(),
}
}
fn collect_named_types(ty: &crate::core::ir::TypeRef, out: &mut Vec<String>) {
use crate::core::ir::TypeRef;
match ty {
TypeRef::Named(name) => out.push(name.clone()),
TypeRef::Optional(inner) | TypeRef::Vec(inner) => collect_named_types(inner, out),
TypeRef::Map(k, v) => {
collect_named_types(k, out);
collect_named_types(v, out);
}
_ => {}
}
}
fn rust_type_name(ty: &crate::core::ir::TypeRef) -> String {
use crate::codegen::type_mapper::{IdentityMapper, TypeMapper};
IdentityMapper.map_type(ty)
}
fn emit_rust_stub_method(
out: &mut String,
method: &crate::core::ir::MethodDef,
defaults: &dyn crate::codegen::defaults::LanguageDefaults,
type_imports: &mut Vec<String>,
crate_module: Option<&str>,
) {
use crate::core::ir::TypeRef;
use std::fmt::Write as FmtWrite;
let params_typed: Vec<String> = method
.params
.iter()
.enumerate()
.map(|(i, param)| {
collect_named_types(¶m.ty, type_imports);
if param.is_ref {
use crate::core::ir::TypeRef;
let mut_kw = if param.is_mut { "mut " } else { "" };
let ref_str = match ¶m.ty {
TypeRef::Bytes => format!("&{mut_kw}[u8]"),
TypeRef::String => format!("&{mut_kw}str"),
other => format!("&{}{}", mut_kw, rust_type_name(other)),
};
format!("_p{i}: {ref_str}")
} else {
let ty_str = rust_type_name(¶m.ty);
format!("_p{i}: {ty_str}")
}
})
.collect();
let params_str = if params_typed.is_empty() {
String::new()
} else {
format!(", {}", params_typed.join(", "))
};
let return_type_str = if method.returns_ref {
use crate::core::ir::TypeRef;
let ref_type = match &method.return_type {
TypeRef::String => "&str".to_string(),
TypeRef::Bytes => "&[u8]".to_string(),
TypeRef::Vec(inner) => match inner.as_ref() {
TypeRef::String => "&[&str]".to_string(),
TypeRef::Bytes => "&[u8]".to_string(),
other => format!("&[{}]", rust_type_name(other)),
},
other => format!("&{}", rust_type_name(other)),
};
Some(ref_type)
} else {
match &method.return_type {
TypeRef::Unit if method.error_type.is_none() => None,
_ => {
let base = rust_type_name(&method.return_type);
collect_named_types(&method.return_type, type_imports);
let full = if let Some(err) = &method.error_type {
if err == "anyhow::Error" {
if let Some(module) = crate_module {
format!("{module}::Result<{base}>")
} else {
format!("Result<{base}, {err}>")
}
} else {
if !err.contains("::") {
type_imports.push(err.clone());
}
format!("Result<{base}, {err}>")
}
} else {
base
};
Some(full)
}
}
};
let body = if method.returns_ref {
use crate::core::ir::TypeRef;
match &method.return_type {
TypeRef::String => "\"\"".to_string(),
TypeRef::Bytes | TypeRef::Vec(_) => "&[]".to_string(),
_ => format!(
"compile_error!(\"alef cannot generate Rust e2e test_backend method `{}` because it returns an \
unsupported reference type\")",
method.name
),
}
} else {
let raw = match &method.return_type {
TypeRef::Unit => "()".to_string(),
_ => defaults.emit_default(&method.return_type),
};
if method.error_type.is_some() {
format!("Ok({raw})")
} else {
raw
}
};
let async_kw = if method.is_async { "async " } else { "" };
let return_annotation = match &return_type_str {
Some(rt) => format!(" -> {rt}"),
None => String::new(),
};
let _ = writeln!(
out,
" {async_kw}fn {name}(&self{params_str}){return_annotation} {{ {body} }}",
name = method.name
);
}
fn fixture_id_to_pascal_case(id: &str) -> String {
id.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().to_string() + chars.as_str(),
}
})
.collect()
}
fn extract_backend_name_from_input(input: &serde_json::Value, fallback: &str) -> String {
if let Some(obj) = input.as_object() {
if let Some(s) = obj.get("name").and_then(|v| v.as_str()) {
return s.to_string();
}
for v in obj.values() {
if let Some(inner) = v.as_object() {
if let Some(s) = inner.get("name").and_then(|v| v.as_str()) {
return s.to_string();
}
}
}
for v in obj.values() {
if let Some(s) = v.as_str() {
return s.to_string();
}
}
}
fallback.to_string()
}
#[cfg(test)]
fn test_method(
name: &str,
return_type: crate::core::ir::TypeRef,
is_async: bool,
error_type: Option<&str>,
) -> crate::core::ir::MethodDef {
crate::core::ir::MethodDef {
name: name.to_string(),
params: Vec::new(),
return_type,
is_async,
is_static: false,
error_type: error_type.map(str::to_string),
doc: String::new(),
receiver: Some(crate::core::ir::ReceiverKind::Ref),
sanitized: false,
trait_source: None,
returns_ref: false,
returns_cow: false,
return_newtype_wrapper: None,
has_default_impl: false,
binding_excluded: false,
binding_exclusion_reason: None,
version: Default::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fixture(id: &str, input: serde_json::Value) -> crate::e2e::fixture::Fixture {
serde_json::from_value(serde_json::json!({
"id": id,
"description": "test fixture",
"input": input,
"assertions": []
}))
.expect("minimal fixture JSON must parse")
}
#[test]
fn resolve_crate_name_uses_config_name() {
use crate::core::config::NewAlefConfig;
let cfg: NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = ["rust"]
[[crates]]
name = "my-lib"
sources = ["src/lib.rs"]
[crates.e2e]
fixtures = "fixtures"
output = "e2e"
[crates.e2e.call]
function = "process"
module = "my_lib"
result_var = "result"
"#,
)
.unwrap();
let e2e = cfg.crates[0].e2e.clone().unwrap();
let resolved = cfg.resolve().unwrap().remove(0);
let name = resolve_crate_name(&e2e, &resolved);
assert_eq!(name, "my-lib");
}
#[test]
fn emit_test_backend_rust_generates_struct_and_arc_expr() {
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::TypeRef;
let bridge = TraitBridgeConfig {
trait_name: "TestTrait".to_string(),
super_trait: Some("Plugin".to_string()),
register_fn: Some("register_test_trait".to_string()),
..Default::default()
};
let m1 = test_method("do_work", TypeRef::String, false, None);
let m2 = test_method(
"process_async",
TypeRef::Named("WorkResult".to_string()),
true,
Some("WorkError"),
);
let methods = [&m1, &m2];
let fixture = make_fixture("my_test_fixture", serde_json::json!({ "name": "my-test-backend" }));
let emission = emit_test_backend(&bridge, &methods, &fixture);
assert!(
emission.setup_block.contains("TestStubMyTestFixture"),
"setup_block should contain stub name, got: {}",
emission.setup_block
);
assert!(
emission.setup_block.contains("TestTrait"),
"setup_block should reference trait by name, got: {}",
emission.setup_block
);
assert!(
!emission.setup_block.contains("OcrBackend"),
"setup_block must not hardcode OcrBackend"
);
assert!(
!emission.setup_block.contains("DocumentExtractor"),
"setup_block must not hardcode DocumentExtractor"
);
assert!(
emission.setup_block.contains("fn name("),
"setup_block should emit name() when super_trait is set"
);
assert!(
emission.setup_block.contains("fn do_work("),
"required method do_work should be in setup_block"
);
assert!(
emission.setup_block.contains("fn process_async("),
"required async method process_async should be in setup_block"
);
assert!(
emission.arg_expr.contains("Arc::new"),
"arg_expr should use Arc::new, got: {}",
emission.arg_expr
);
assert!(
emission.arg_expr.contains("TestStubMyTestFixture"),
"arg_expr should reference stub struct, got: {}",
emission.arg_expr
);
}
#[test]
fn emit_test_backend_rust_skips_default_impl_methods() {
use crate::core::config::TraitBridgeConfig;
use crate::core::ir::TypeRef;
let bridge = TraitBridgeConfig {
trait_name: "TestTrait".to_string(),
..Default::default()
};
let required = test_method("required_method", TypeRef::String, false, None);
let mut optional = test_method("optional_method", TypeRef::String, false, None);
optional.has_default_impl = true;
let methods = [&required, &optional];
let fixture = make_fixture("skip_defaults_fixture", serde_json::json!({}));
let emission = emit_test_backend(&bridge, &methods, &fixture);
assert!(
emission.setup_block.contains("fn required_method("),
"required method should be emitted"
);
assert!(
!emission.setup_block.contains("fn optional_method("),
"method with default impl should be skipped"
);
}
#[test]
fn emit_test_backend_rust_name_extracted_from_input() {
use crate::core::config::TraitBridgeConfig;
let bridge = TraitBridgeConfig {
trait_name: "TestTrait".to_string(),
super_trait: Some("Plugin".to_string()),
..Default::default()
};
let fixture = make_fixture(
"name_extraction_fixture",
serde_json::json!({ "backend": { "name": "extracted-name" } }),
);
let emission = emit_test_backend(&bridge, &[], &fixture);
assert!(
emission.arg_expr.contains("extracted-name"),
"arg_expr should contain the name from input.backend.name, got: {}",
emission.arg_expr
);
}
}