use crate::core::backend::GeneratedFile;
use crate::core::config::ResolvedCrateConfig;
use crate::core::ir::{ApiSurface, EntrypointKind, HandlerContractDef, RegistrationDef, ServiceDef, TypeRef};
use heck::{ToSnakeCase, ToUpperCamelCase};
use std::path::PathBuf;
fn ruby_type_annotation(ty: &TypeRef) -> String {
match ty {
TypeRef::String | TypeRef::Char => "String".to_owned(),
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => "true | false".to_owned(),
PrimitiveType::F32 | PrimitiveType::F64 => "Float".to_owned(),
_ => "Integer".to_owned(),
}
}
TypeRef::Bytes => "String".to_owned(),
TypeRef::Optional(inner) => format!("{} | nil", ruby_type_annotation(inner)),
TypeRef::Vec(inner) => format!("Array[{}]", ruby_type_annotation(inner)),
TypeRef::Map(k, v) => format!("Hash[{}, {}]", ruby_type_annotation(k), ruby_type_annotation(v)),
TypeRef::Unit => "void".to_owned(),
TypeRef::Named(n) => n.clone(),
TypeRef::Json => "Object".to_owned(),
TypeRef::Path => "String".to_owned(),
TypeRef::Duration => "Float".to_owned(),
}
}
fn find_contract<'a>(api: &'a ApiSurface, trait_name: &str) -> Option<&'a HandlerContractDef> {
api.handler_contracts.iter().find(|c| c.trait_name == trait_name)
}
fn format_ruby_comment(text: &str, indent: usize) -> String {
let trimmed = text.trim();
let pad = " ".repeat(indent);
if trimmed.is_empty() {
return String::new();
}
let mut out = String::new();
for line in trimmed.lines() {
if line.trim().is_empty() {
out.push_str(&pad);
out.push_str("#\n");
} else {
out.push_str(&pad);
out.push_str("# ");
out.push_str(line);
out.push('\n');
}
}
out
}
pub(super) fn gen_service_rb(api: &ApiSurface, native_module_name: &str, gem_require_name: &str) -> String {
let mut out = String::new();
out.push_str("# frozen_string_literal: true\n\n");
out.push_str(&format!("require \"{gem_require_name}\"\n\n"));
if !api.services.is_empty() {
out.push_str(&format!("module {native_module_name}\n"));
for service in &api.services {
gen_service_class(&mut out, service, api, native_module_name);
}
out.push_str("end\n");
}
out
}
fn gen_service_class(out: &mut String, service: &ServiceDef, api: &ApiSurface, native_module_name: &str) {
let class_name = &service.name;
if !service.doc.is_empty() {
out.push_str(&format_ruby_comment(&service.doc, 2));
}
out.push_str(&format!(" class {class_name}\n"));
{
let ctor = &service.constructor;
let mut init_params = Vec::new();
let mut stored_args = Vec::new();
for p in &ctor.params {
let type_annotation = ruby_type_annotation(&p.ty);
if p.optional {
init_params.push(format!("{}: {} | nil = nil", p.name, type_annotation));
} else {
init_params.push(format!("{}: {}", p.name, type_annotation));
}
stored_args.push(p.name.clone());
}
let param_sig = if init_params.is_empty() {
String::new()
} else {
format!("({})", init_params.join(", "))
};
out.push_str(&format!(" def initialize{param_sig}\n"));
if !ctor.doc.is_empty() {
out.push_str(&format_ruby_comment(&ctor.doc, 6));
}
out.push_str(" @registrations = []\n");
for arg in &stored_args {
out.push_str(&format!(" @{arg} = {arg}\n"));
}
out.push_str(" end\n\n");
}
for method in &service.configurators {
let mut params = Vec::new();
for p in &method.params {
if p.optional {
params.push(format!("{} = nil", p.name));
} else {
params.push(p.name.clone());
}
}
let param_sig = if params.is_empty() {
String::new()
} else {
format!("({})", params.join(", "))
};
let method_name = &method.name;
out.push_str(&format!(" def {method_name}{param_sig}\n"));
if !method.doc.is_empty() {
out.push_str(&format_ruby_comment(&method.doc, 6));
}
for p in &method.params {
out.push_str(&format!(" @{} = {}\n", p.name, p.name));
}
out.push_str(" self\n");
out.push_str(" end\n\n");
}
for reg in &service.registrations {
gen_registration_method(out, reg, service, api, native_module_name);
}
for ep in &service.entrypoints {
let mut params = Vec::new();
for p in &ep.params {
if p.optional {
params.push(format!("{} = nil", p.name));
} else {
params.push(p.name.clone());
}
}
let param_sig = if params.is_empty() {
String::new()
} else {
format!("({})", params.join(", "))
};
let ep_name = &ep.method;
match ep.kind {
EntrypointKind::Run => {
out.push_str(&format!(" def {ep_name}{param_sig}\n"));
if !ep.doc.is_empty() {
out.push_str(&format_ruby_comment(&ep.doc, 6));
}
let native_fn = format!("{service_snake}_{ep_name}", service_snake = class_name.to_snake_case());
out.push_str(&format!(" {native_module_name}.{native_fn}(@registrations"));
for p in &ep.params {
out.push_str(&format!(", {}", p.name));
}
out.push_str(")\n");
out.push_str(" end\n\n");
}
EntrypointKind::Finalize => {
out.push_str(&format!(" def {ep_name}{param_sig}\n"));
if !ep.doc.is_empty() {
out.push_str(&format_ruby_comment(&ep.doc, 6));
}
let native_fn = format!("{service_snake}_{ep_name}", service_snake = class_name.to_snake_case());
out.push_str(&format!(" {native_module_name}.{native_fn}(@registrations"));
for p in &ep.params {
out.push_str(&format!(", {}", p.name));
}
out.push_str(")\n");
out.push_str(" end\n\n");
}
}
}
out.push_str(" end\n\n");
}
fn gen_registration_method(
out: &mut String,
reg: &RegistrationDef,
_service: &ServiceDef,
_api: &ApiSurface,
_native_module_name: &str,
) {
let method_name = ®.method;
let meta_params: Vec<String> = reg
.metadata_params
.iter()
.map(|p| {
let annotation = ruby_type_annotation(&p.ty);
if p.optional {
format!("{}: {} | nil = nil", p.name, annotation)
} else {
format!("{}: {}", p.name, annotation)
}
})
.collect();
let positional_params: Vec<&str> = reg.metadata_params.iter().map(|p| p.name.as_str()).collect();
let param_sig = if positional_params.is_empty() {
"(&block)".to_owned()
} else {
format!("({}, &block)", positional_params.join(", "))
};
out.push_str(&format!(" def {method_name}{param_sig}\n"));
if !reg.doc.is_empty() {
out.push_str(&format_ruby_comment(®.doc, 6));
}
let meta_names: Vec<&str> = reg.metadata_params.iter().map(|p| p.name.as_str()).collect();
let meta_tuple = if meta_names.is_empty() {
"[]".to_owned()
} else {
format!("[{}]", meta_names.join(", "))
};
out.push_str(&format!(
" @registrations.push([\"{method_name}\", {meta_tuple}, block])\n"
));
out.push_str(" self\n");
out.push_str(" end\n\n");
let direct_name = format!("register_{method_name}");
if direct_name != *method_name {
let direct_param_sig = if meta_params.is_empty() {
format!("({callback})", callback = reg.callback_param)
} else {
let positional_meta: Vec<String> = reg.metadata_params.iter().map(|p| p.name.clone()).collect();
format!("({}, {})", positional_meta.join(", "), reg.callback_param)
};
out.push_str(&format!(" def {direct_name}{direct_param_sig}\n"));
out.push_str(&format!(
" # Register a {method_name} callback directly without block syntax.\n"
));
out.push_str(&format!(
" @registrations.push([\"{method_name}\", {meta_tuple}, {callback}])\n",
callback = reg.callback_param
));
out.push_str(" self\n");
out.push_str(" end\n\n");
}
for variant in ®.variants {
gen_registration_variant(out, variant, reg);
}
}
fn gen_registration_variant(
out: &mut String,
variant: &crate::core::ir::RegistrationVariant,
base_reg: &RegistrationDef,
) {
let variant_name = &variant.name;
let _base_method = &base_reg.method;
let mut free_params_sig = Vec::new();
for param in &variant.signature_params {
let annotation = ruby_type_annotation(¶m.ty);
if param.optional {
free_params_sig.push(format!("{}: {} | nil = nil", param.name, annotation));
} else {
free_params_sig.push(format!("{}: {}", param.name, annotation));
}
}
let mut meta_items = Vec::new();
if let Some(wc) = &variant.wrapper_call {
for arg in &wc.args {
match arg {
crate::core::ir::WrapperConstructorArg::Fixed {
param_name: _,
value_expr,
} => {
meta_items.push(value_expr.clone());
}
crate::core::ir::WrapperConstructorArg::Free { param } => {
meta_items.push(param.name.clone());
}
}
}
} else {
for override_ in &variant.overrides {
meta_items.push(override_.value_expr.clone());
}
}
let meta_tuple = if meta_items.is_empty() {
"[]".to_owned()
} else {
format!("[{}]", meta_items.join(", "))
};
let _ = variant.style;
let param_sig = if free_params_sig.is_empty() {
"(&block)".to_owned()
} else {
format!("({}, &block)", free_params_sig.join(", "))
};
out.push_str(&format!(" def {variant_name}{param_sig}\n"));
if let Some(doc) = &variant.doc {
out.push_str(&format_ruby_comment(doc, 6));
} else {
out.push_str(&format!(" # Register a handler for the {variant_name} variant.\n"));
}
out.push_str(&format!(
" @registrations.push([\"{variant_name}\", {meta_tuple}, block])\n"
));
out.push_str(" self\n");
out.push_str(" end\n\n");
}
pub(super) fn gen_service_rs(api: &ApiSurface, config: &ResolvedCrateConfig) -> String {
let core_import = config.core_import_name();
let mut out = String::new();
out.push_str("#![allow(clippy::too_many_arguments, clippy::unused_async)]\n\n");
out.push_str("use magnus::{method, prelude::*, value::{InnerValue, Opaque}, RArray, RHash, Ruby, Value};\n");
out.push_str("use std::sync::Arc;\n\n");
let referenced_contracts: Vec<&HandlerContractDef> = {
let mut names: Vec<&str> = api
.services
.iter()
.flat_map(|s| s.registrations.iter())
.map(|r| r.callback_contract.as_str())
.collect();
names.sort_unstable();
names.dedup();
names.iter().filter_map(|n| find_contract(api, n)).collect()
};
for contract in &referenced_contracts {
gen_handler_bridge(&mut out, contract, &core_import);
}
for service in &api.services {
for ep in &service.entrypoints {
gen_run_function(&mut out, service, ep, api, &core_import);
}
}
out
}
fn gen_handler_bridge(out: &mut String, contract: &HandlerContractDef, core_import: &str) {
let trait_name = &contract.trait_name;
let bridge_name = format!("Rb{}Bridge", trait_name.to_upper_camel_case());
let dispatch_name = &contract.dispatch.name;
let req_type = contract.wire_request_type.as_deref().unwrap_or("serde_json::Value");
let resp_type = contract.wire_response_type.as_deref().unwrap_or("serde_json::Value");
let req_type = if req_type.contains("::") {
req_type.split("::").last().unwrap_or(req_type)
} else {
req_type
};
let resp_type = if resp_type.contains("::") {
resp_type.split("::").last().unwrap_or(resp_type)
} else {
resp_type
};
let extra_param: String = contract
.dispatch_extra_params
.iter()
.map(|p| format!(", {p}"))
.collect();
let wire_name = contract.wire_param_name.as_deref().unwrap_or("request");
out.push_str(&format!(
"/// Generated Magnus bridge for the `{trait_name}` contract.\n\
///\n\
/// Wraps a Ruby proc so it can be used as `Arc<dyn {trait_name}>`\n\
/// from Rust async code. Calls the proc with GVL acquired.\n\
pub struct {bridge_name} {{\n \
proc_handle: Opaque<Value>,\n\
}}\n\n"
));
out.push_str(&format!(
"impl {bridge_name} {{\n \
/// Create a bridge from a Ruby proc.\n \
pub fn new(proc_handle: Opaque<Value>) -> Self {{\n \
Self {{ proc_handle }}\n \
}}\n\
}}\n\n"
));
out.push_str(&format!(
"// SAFETY: Opaque<Value> is Send+Sync; calls acquire the GVL.\n\
unsafe impl Send for {bridge_name} {{}}\n\
unsafe impl Sync for {bridge_name} {{}}\n\n"
));
let req_path = if req_type == "Value" {
"serde_json::Value".to_string()
} else {
format!("{core_import}::{req_type}")
};
let resp_path = if resp_type == "Value" {
"serde_json::Value".to_string()
} else {
format!("{core_import}::{resp_type}")
};
let box_err = "Box<dyn std::error::Error + Send + Sync>";
let wire_output = format!("Result<{resp_path}, {box_err}>");
let output_type = contract
.dispatch_return_type
.clone()
.unwrap_or_else(|| wire_output.clone());
let tail = match &contract.response_adapter {
Some(adapter) => format!("{adapter}(outcome)"),
None => "outcome".to_string(),
};
out.push_str(&format!(
"impl {core_import}::{trait_name} for {bridge_name} {{\n \
fn {dispatch_name}(\n \
&self{extra_param},\n \
{wire_name}: {req_path},\n \
) -> std::pin::Pin<Box<dyn std::future::Future<Output = {output_type}> + Send + '_>> {{\n \
Box::pin(async move {{\n \
// Call the Ruby proc with the GVL re-acquired directly.\n \
// SAFETY: `app_run` releases the GVL via `rb_thread_call_without_gvl`\n \
// and drives a `new_current_thread` Tokio runtime inside that callback.\n \
// Every async task therefore runs on the same OS thread that released\n \
// the GVL; `call_ruby_proc_with_gvl` re-acquires it safely from here.\n \
// Using spawn_blocking would create a non-Ruby OS thread from which\n \
// `rb_thread_call_with_gvl` would abort the process.\n \
let outcome: {wire_output} = (|| {{\n \
// Serialize the request to JSON\n \
let req_json = serde_json::to_string(&{wire_name})\n \
.map_err(|e| Box::new(e) as {box_err})?;\n\n \
let resp_json = call_ruby_proc_with_gvl(&self.proc_handle, &req_json)?;\n\n \
// Deserialize the JSON result back into the wire response DTO.\n \
let response: {resp_path} = serde_json::from_str(&resp_json)\n \
.map_err(|e| Box::new(e) as {box_err})?;\n \
Ok(response)\n \
}})();\n\n \
{tail}\n \
}})\n \
}}\n\
}}\n\n"
));
out.push_str("/// Call a Ruby proc with the GVL acquired via rb_sys.\n");
out.push_str("/// Called from within a `rb_thread_call_without_gvl` callback (same OS thread).\n");
out.push_str("fn call_ruby_proc_with_gvl(\n");
out.push_str(" proc_handle: &Opaque<Value>,\n");
out.push_str(" req_json: &str,\n");
out.push_str(") -> Result<String, Box<dyn std::error::Error + Send + Sync>> {\n");
out.push_str(" let box_err = |e: Box<dyn std::error::Error + Send + Sync>| e;\n");
out.push_str(" \n");
out.push_str(" // SAFETY: rb_thread_call_with_gvl is safe to call from any thread.\n");
out.push_str(" // It acquires the GVL and calls the callback with it held.\n");
out.push_str(" // We use a helper extern fn to bridge the gap.\n");
out.push_str(" unsafe {\n");
out.push_str(" let mut state = RubyProcCallState {\n");
out.push_str(" proc_handle: proc_handle.clone(),\n");
out.push_str(" req_json: req_json.to_string(),\n");
out.push_str(" result: None,\n");
out.push_str(" };\n");
out.push_str(" rb_sys::rb_thread_call_with_gvl(\n");
out.push_str(" Some(ruby_proc_gvl_callback),\n");
out.push_str(" &mut state as *mut _ as *mut std::ffi::c_void,\n");
out.push_str(" );\n");
out.push_str(" state.result.unwrap_or_else(|| {\n");
out.push_str(" Err(Box::new(std::io::Error::new(\n");
out.push_str(" std::io::ErrorKind::Other,\n");
out.push_str(" \"GVL callback failed to set result\",\n");
out.push_str(" )) as Box<dyn std::error::Error + Send + Sync>)\n");
out.push_str(" })\n");
out.push_str(" }\n");
out.push_str("}\n\n");
out.push_str("struct RubyProcCallState {\n");
out.push_str(" proc_handle: Opaque<Value>,\n");
out.push_str(" req_json: String,\n");
out.push_str(" result: Option<Result<String, Box<dyn std::error::Error + Send + Sync>>>,\n");
out.push_str("}\n\n");
out.push_str("// SAFETY: RubyProcCallState is only accessed from within the GVL callback.\n");
out.push_str("unsafe impl Send for RubyProcCallState {}\n");
out.push_str("unsafe impl Sync for RubyProcCallState {}\n\n");
out.push_str("// Callback invoked by rb_thread_call_with_gvl with the GVL held.\n");
out.push_str("extern \"C\" fn ruby_proc_gvl_callback(data: *mut std::ffi::c_void) -> *mut std::ffi::c_void {\n");
out.push_str(" // SAFETY: data is a pointer to our RubyProcCallState, guaranteed valid for the duration of the callback.\n");
out.push_str(" unsafe {\n");
out.push_str(" let state = &mut *(data as *mut RubyProcCallState);\n");
out.push_str(" let box_err = |e: Box<dyn std::error::Error + Send + Sync>| e;\n");
out.push_str(" \n");
out.push_str(" // We are now on a Ruby thread with the GVL held. Safe to call Magnus APIs.\n");
out.push_str(" let ruby = match Ruby::get() {\n");
out.push_str(" Ok(r) => r,\n");
out.push_str(" Err(_) => {\n");
out.push_str(" state.result = Some(Err(Box::new(std::io::Error::new(\n");
out.push_str(" std::io::ErrorKind::Other,\n");
out.push_str(" \"Could not obtain Ruby handle within GVL callback\",\n");
out.push_str(" )) as Box<dyn std::error::Error + Send + Sync>));\n");
out.push_str(" return std::ptr::null_mut();\n");
out.push_str(" }\n");
out.push_str(" };\n");
out.push_str(" \n");
out.push_str(" let proc_value = state.proc_handle.get_inner_with(&ruby);\n");
out.push_str(" \n");
out.push_str(" // Parse request JSON into a Ruby Hash\n");
out.push_str(" let json_mod = match ruby.eval::<Value>(\"JSON\") {\n");
out.push_str(" Ok(m) => m,\n");
out.push_str(" Err(e) => {\n");
out.push_str(" state.result = Some(Err(Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error + Send + Sync>));\n");
out.push_str(" return std::ptr::null_mut();\n");
out.push_str(" }\n");
out.push_str(" };\n");
out.push_str(" \n");
out.push_str(
" let req_hash = match json_mod.funcall::<_, _, Value>(\"parse\", (state.req_json.as_str(),)) {\n",
);
out.push_str(" Ok(h) => h,\n");
out.push_str(" Err(e) => {\n");
out.push_str(" state.result = Some(Err(Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error + Send + Sync>));\n");
out.push_str(" return std::ptr::null_mut();\n");
out.push_str(" }\n");
out.push_str(" };\n");
out.push_str(" \n");
out.push_str(" // Call the proc with the request hash\n");
out.push_str(" let result = match proc_value.funcall::<_, _, Value>(\"call\", (req_hash,)) {\n");
out.push_str(" Ok(r) => r,\n");
out.push_str(" Err(e) => {\n");
out.push_str(" state.result = Some(Err(Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error + Send + Sync>));\n");
out.push_str(" return std::ptr::null_mut();\n");
out.push_str(" }\n");
out.push_str(" };\n");
out.push_str(" \n");
out.push_str(" // Serialize result back to JSON\n");
out.push_str(" match json_mod.funcall::<_, _, String>(\"generate\", (result,)) {\n");
out.push_str(" Ok(resp_json_str) => {\n");
out.push_str(" state.result = Some(Ok(resp_json_str));\n");
out.push_str(" }\n");
out.push_str(" Err(e) => {\n");
out.push_str(" state.result = Some(Err(Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error + Send + Sync>));\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str(" std::ptr::null_mut()\n");
out.push_str("}\n\n");
}
fn gen_variant_match_arm(
out: &mut String,
variant: &crate::core::ir::RegistrationVariant,
base_reg: &RegistrationDef,
contract_name: &str,
bridge_name: &str,
core_import: &str,
api: &ApiSurface,
) {
let variant_name = &variant.name;
let base_method = &base_reg.method;
out.push_str(&format!(" \"{variant_name}\" => {{\n"));
out.push_str(&format!(
" let bridge = {bridge_name}::new(proc_value.into());\n"
));
out.push_str(&format!(
" let handler: Arc<dyn {core_import}::{contract_name}> = Arc::new(bridge);\n"
));
if let Some(wc) = &variant.wrapper_call {
let mut free_params = Vec::new();
for arg in &wc.args {
if let crate::core::ir::WrapperConstructorArg::Free { param } = arg {
free_params.push(param.clone());
}
}
if !free_params.is_empty() {
out.push_str(" let meta_array = RArray::try_convert(\n");
out.push_str(" entry_array\n");
out.push_str(" .entry::<Value>(1 as isize)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?,\n");
out.push_str(" )\n");
out.push_str(
" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n\n",
);
for (i, param) in free_params.iter().enumerate() {
let rust_ty = typeref_to_rust_type(¶m.ty, core_import);
match ¶m.ty {
TypeRef::String | TypeRef::Char => {
out.push_str(&format!(
" let {}: {} = meta_array.entry::<String>({})\n",
param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
let extract_ty = match p {
PrimitiveType::Bool => "bool",
PrimitiveType::F32 | PrimitiveType::F64 => "f64",
_ => "i64",
};
out.push_str(&format!(
" let {}: {} = meta_array.entry::<{}>({})\n",
param.name, rust_ty, extract_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
TypeRef::Named(n) => {
if is_variant_wrapper_type(api, n) {
out.push_str(&format!(
" let {}: &crate::{} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
param.name, n, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(&format!(
" let {} = {}.inner.as_ref().clone();\n",
param.name, param.name
));
} else {
out.push_str(&format!(
" let {}: &{} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(&format!(
" let {} = (*{}).clone();\n",
param.name, param.name
));
}
}
_ => {
out.push_str(&format!(
" let {}: {} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
}
}
}
let mut call_args = Vec::new();
for arg in &wc.args {
match arg {
crate::core::ir::WrapperConstructorArg::Fixed {
param_name: _,
value_expr,
} => {
call_args.push(value_expr.clone());
}
crate::core::ir::WrapperConstructorArg::Free { param } => {
call_args.push(param.name.clone());
}
}
}
let call_expr = format!(
"{}::{}({})",
wc.wrapper_type_path,
wc.constructor_method,
call_args.join(", ")
);
out.push_str(&format!(
" let {}: {} = {};\n",
wc.metadata_param, wc.wrapper_type_path, call_expr
));
out.push_str(&format!(
" owner.{base_method}({}, handler)",
wc.metadata_param
));
} else {
out.push_str(&format!(" owner.{base_method}(handler)"));
}
if base_reg.error_type.is_some() {
out.push_str(
"\n .map_err(|e| magnus::Error::new(ruby.exception_runtime_error(), e.to_string()))?;\n",
);
} else {
out.push_str(";\n");
}
out.push_str(" }\n");
}
fn is_variant_wrapper_type(api: &ApiSurface, type_name: &str) -> bool {
api.types.iter().any(|t| t.name == type_name && t.is_variant_wrapper)
}
fn gen_run_function(
out: &mut String,
service: &ServiceDef,
ep: &crate::core::ir::EntrypointDef,
api: &ApiSurface,
core_import: &str,
) {
let service_snake = service.name.to_snake_case();
let fn_name = format!("{service_snake}_{}", ep.method);
let owner_path = &service.rust_path;
let ep_method = &ep.method;
let mut fn_params = vec!["registrations: Value".to_owned()];
for p in &ep.params {
let rust_ty = typeref_to_rust_type(&p.ty, core_import);
fn_params.push(format!("{}: {}", p.name, rust_ty));
}
let fn_param_sig = fn_params.join(", ");
out.push_str(&format!(
"/// Drive `{owner_path}::{ep_method}` from Ruby.\n\
///\n\
/// Each entry in `registrations` is a `[method_name, metadata_array, proc]` triple\n\
/// produced by the Ruby service class. Constructs an owned service instance,\n\
/// registers all handlers (acquiring GVL for each Ruby proc call), then invokes\n\
/// the entrypoint.\n\
///\n\
/// This function runs on a Ruby thread (entered via function! macro from init), so the GVL is already held.\n\
pub fn {fn_name}({fn_param_sig}) -> magnus::error::Result<()> {{\n"
));
let ctor_call = build_ctor_call(service, owner_path, core_import);
out.push_str(&format!(" let mut owner = {ctor_call};\n\n"));
out.push_str(" let ruby = Ruby::get().expect(\"function! macro callbacks run on a Ruby thread\");\n\n");
out.push_str(" let regs_array = RArray::try_convert(registrations)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n\n");
out.push_str(" for i in 0..regs_array.len() {\n");
out.push_str(" let entry = regs_array\n");
out.push_str(" .entry::<Value>(i as isize)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(" let entry_array = RArray::try_convert(entry)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(" let method_name: String = entry_array\n");
out.push_str(" .entry::<String>(0 as isize)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(" let proc_value = entry_array\n");
out.push_str(" .entry::<Value>(2 as isize)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n\n");
out.push_str(" match method_name.as_str() {\n");
for reg in &service.registrations {
let reg_method = ®.method;
let contract_name = ®.callback_contract;
if let Some(contract) = find_contract(api, contract_name) {
let bridge_name = format!("Rb{}Bridge", contract.trait_name.to_upper_camel_case());
let meta_count = reg.metadata_params.len();
out.push_str(&format!(" \"{reg_method}\" => {{\n"));
out.push_str(&format!(
" let bridge = {bridge_name}::new(proc_value.into());\n"
));
out.push_str(&format!(
" let handler: Arc<dyn {core_import}::{contract_name}> = Arc::new(bridge);\n"
));
if meta_count > 0 {
out.push_str(" let meta_array = RArray::try_convert(\n");
out.push_str(" entry_array\n");
out.push_str(" .entry::<Value>(1 as isize)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?,\n");
out.push_str(" )\n");
out.push_str(
" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n",
);
for (i, meta_param) in reg.metadata_params.iter().enumerate() {
let rust_ty = typeref_to_rust_type(&meta_param.ty, core_import);
match &meta_param.ty {
TypeRef::String | TypeRef::Char => {
out.push_str(&format!(
" let {}: {} = meta_array.entry::<String>({})\n",
meta_param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
let extract_ty = match p {
PrimitiveType::Bool => "bool",
PrimitiveType::F32 | PrimitiveType::F64 => "f64",
_ => "i64",
};
out.push_str(&format!(
" let {}: {} = meta_array.entry::<{}>({})\n",
meta_param.name, rust_ty, extract_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
TypeRef::Named(n) => {
if is_variant_wrapper_type(api, n) {
out.push_str(&format!(
" let {}: &crate::{} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
meta_param.name, n, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(&format!(
" let {} = {}.inner.as_ref().clone();\n",
meta_param.name, meta_param.name
));
} else {
out.push_str(&format!(
" let {}: &{} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
meta_param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
out.push_str(&format!(
" let {} = (*{}).clone();\n",
meta_param.name, meta_param.name
));
}
}
_ => {
out.push_str(&format!(
" let {}: {} = magnus::TryConvert::try_convert(meta_array.entry::<Value>({})\n",
meta_param.name, rust_ty, i as isize
));
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?)\n");
out.push_str(" .map_err(|e| magnus::Error::new(ruby.exception_type_error(), e.to_string()))?;\n");
}
}
}
let meta_args: Vec<String> = reg.metadata_params.iter().map(|p| p.name.clone()).collect();
out.push_str(&format!(
" owner.{reg_method}({}, handler)\n",
meta_args.join(", ")
));
} else {
out.push_str(&format!(" owner.{reg_method}(handler)\n"));
}
if reg.error_type.is_some() {
out.push_str(
" .map_err(|e| magnus::Error::new(ruby.exception_runtime_error(), e.to_string()))?;\n",
);
} else {
out.push_str(" ;\n");
}
out.push_str(" }\n");
for variant in ®.variants {
gen_variant_match_arm(&mut *out, variant, reg, contract_name, &bridge_name, core_import, api);
}
}
}
out.push_str(" _ => {\n");
out.push_str(
" return Err(magnus::Error::new(\n ruby.exception_arg_error(),\n format!(\"unknown registration method: {method_name}\"),\n ));\n",
);
out.push_str(" }\n");
out.push_str(" }\n");
out.push_str(" }\n\n");
let ep_call = build_ep_call(ep, service, core_import);
out.push_str(&ep_call);
out.push_str(" Ok(())\n}\n\n");
}
fn build_ctor_call(service: &ServiceDef, owner_path: &str, _core_import: &str) -> String {
if service.constructor.params.is_empty() {
format!("{owner_path}::{}()", service.constructor.name)
} else {
format!("{owner_path}::{}()", service.constructor.name)
}
}
fn build_ep_call(ep: &crate::core::ir::EntrypointDef, service: &ServiceDef, _core_import: &str) -> String {
let ep_method = &ep.method;
let ep_args: Vec<String> = ep.params.iter().map(|p| p.name.clone()).collect();
let args_str = ep_args.join(", ");
let owner_path = &service.rust_path;
let fn_name = format!("{}_run", service.name.to_snake_case());
if ep.is_async {
format!(
" // SAFETY: `{fn_name}` is called from the Ruby main thread (via `function!` macro),\n \
// so the GVL is currently held. We release the GVL via `rb_thread_call_without_gvl`\n \
// and run a current-thread Tokio runtime inside that callback. This is the SAME\n \
// OS thread that released the GVL, so `rb_thread_call_with_gvl` re-acquisition\n \
// from within the current-thread runtime's tasks is valid.\n \
struct RunState {{\n \
owner: Option<{owner_path}>,\n \
result: Option<Result<(), Box<dyn std::error::Error + Send + Sync + 'static>>>,\n \
}}\n \
// SAFETY: RunState is only accessed from the single callback thread.\n \
unsafe impl Send for RunState {{}}\n \
unsafe impl Sync for RunState {{}}\n\n \
extern \"C\" fn run_without_gvl(data: *mut std::ffi::c_void) -> *mut std::ffi::c_void {{\n \
// SAFETY: data is a valid &mut RunState, valid for the full callback duration.\n \
let state = unsafe {{ &mut *(data as *mut RunState) }};\n \
let app = match state.owner.take() {{\n \
Some(a) => a,\n \
None => {{\n \
state.result = Some(Err(Box::new(std::io::Error::other(\"App already consumed\"))\n \
as Box<dyn std::error::Error + Send + Sync>));\n \
return std::ptr::null_mut();\n \
}}\n \
}};\n \
let rt_result = tokio::runtime::Builder::new_current_thread()\n \
.enable_all()\n \
.build();\n \
state.result = Some(match rt_result {{\n \
Ok(rt) => rt\n \
.block_on(app.{ep_method}({args_str}))\n \
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>),\n \
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),\n \
}});\n \
std::ptr::null_mut()\n \
}}\n \
extern \"C\" fn unblock_run(_data: *mut std::ffi::c_void) {{}}\n\n \
let mut state = RunState {{ owner: Some(owner), result: None }};\n \
// SAFETY: `state` lives until after `rb_thread_call_without_gvl` returns.\n \
unsafe {{\n \
rb_sys::rb_thread_call_without_gvl(\n \
Some(run_without_gvl),\n \
&mut state as *mut RunState as *mut std::ffi::c_void,\n \
Some(unblock_run),\n \
std::ptr::null_mut(),\n \
);\n \
}}\n\n \
state\n \
.result\n \
.unwrap_or_else(|| {{\n \
Err(Box::new(std::io::Error::other(\"server did not run\"))\n \
as Box<dyn std::error::Error + Send + Sync>)\n \
}})\n \
.map_err(|e| magnus::Error::new(ruby.exception_runtime_error(), e.to_string()))?;\n",
fn_name = fn_name,
owner_path = owner_path,
ep_method = ep_method,
args_str = args_str,
)
} else {
if ep.error_type.is_some() {
format!(
" owner.{ep_method}({args_str})\n \
.map_err(|e| magnus::Error::new(ruby.exception_runtime_error(), e.to_string()))?;\n"
)
} else {
format!(" owner.{ep_method}({args_str});\n")
}
}
}
fn typeref_to_rust_type(ty: &TypeRef, core_import: &str) -> String {
match ty {
TypeRef::String | TypeRef::Char => "String".to_owned(),
TypeRef::Primitive(p) => {
use crate::core::ir::PrimitiveType;
match p {
PrimitiveType::Bool => "bool".to_owned(),
PrimitiveType::U8 => "u8".to_owned(),
PrimitiveType::U16 => "u16".to_owned(),
PrimitiveType::U32 => "u32".to_owned(),
PrimitiveType::U64 => "u64".to_owned(),
PrimitiveType::I8 => "i8".to_owned(),
PrimitiveType::I16 => "i16".to_owned(),
PrimitiveType::I32 => "i32".to_owned(),
PrimitiveType::I64 => "i64".to_owned(),
PrimitiveType::F32 => "f32".to_owned(),
PrimitiveType::F64 => "f64".to_owned(),
PrimitiveType::Usize => "usize".to_owned(),
PrimitiveType::Isize => "isize".to_owned(),
}
}
TypeRef::Bytes => "Vec<u8>".to_owned(),
TypeRef::Optional(inner) => format!("Option<{}>", typeref_to_rust_type(inner, core_import)),
TypeRef::Vec(inner) => format!("Vec<{}>", typeref_to_rust_type(inner, core_import)),
TypeRef::Map(k, v) => format!(
"std::collections::HashMap<{}, {}>",
typeref_to_rust_type(k, core_import),
typeref_to_rust_type(v, core_import)
),
TypeRef::Unit => "()".to_owned(),
TypeRef::Named(n) => format!("{core_import}::{n}"),
TypeRef::Json => "serde_json::Value".to_owned(),
TypeRef::Path => "std::path::PathBuf".to_owned(),
TypeRef::Duration => "std::time::Duration".to_owned(),
}
}
pub fn generate(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
if api.services.is_empty() {
return Ok(vec![]);
}
use crate::core::config::resolve_output_dir;
let output_dir = resolve_output_dir(config.output_paths.get("ruby"), &config.name, "crates/{name}-rb/src/");
let native_module_name = api.crate_name.to_upper_camel_case();
let service_rs = gen_service_rs(api, config);
let gem_name = config.ruby_gem_name();
let gem_name_snake = gem_name.replace('-', "_");
let service_rb = gen_service_rb(api, &native_module_name, &gem_name_snake);
let lib_dir = resolve_output_dir(config.output_paths.get("ruby_lib"), &config.name, "packages/ruby/lib/");
let output_base = PathBuf::from(&lib_dir).join(&gem_name_snake);
Ok(vec![
GeneratedFile {
path: PathBuf::from(&output_dir).join("service.rs"),
content: service_rs,
generated_header: true,
},
GeneratedFile {
path: output_base.join("service.rb"),
content: service_rb,
generated_header: true,
},
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ir::{
EntrypointDef, EntrypointKind, HandlerContractDef, MethodDef, ParamDef, PrimitiveType, RegistrationDef,
ServiceDef, TypeRef,
};
fn make_fixture_surface() -> ApiSurface {
let constructor = MethodDef {
name: "new".to_owned(),
params: vec![],
return_type: TypeRef::Unit,
is_async: false,
is_static: true,
error_type: None,
doc: "Create a new service owner.".to_owned(),
receiver: None,
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,
};
let configurator = MethodDef {
name: "with_timeout".to_owned(),
params: vec![ParamDef {
name: "timeout_ms".to_owned(),
ty: TypeRef::Primitive(PrimitiveType::U64),
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Named("TestService".to_owned()),
is_async: false,
is_static: false,
error_type: None,
doc: "Set request timeout.".to_owned(),
receiver: Some(crate::core::ir::ReceiverKind::RefMut),
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,
};
use crate::core::ir::{RegistrationVariant, WrapperConstructorArg, WrapperConstructorCall};
let registration = RegistrationDef {
method: "add_handler".to_owned(),
callback_param: "handler".to_owned(),
callback_contract: "RequestHandler".to_owned(),
metadata_params: vec![
ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
ParamDef {
name: "method".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
],
receiver: Some(crate::core::ir::ReceiverKind::RefMut),
return_type: TypeRef::Unit,
error_type: None,
doc: "Register a request handler for a path and method.".to_owned(),
variants: vec![RegistrationVariant {
name: "get".to_owned(),
overrides: vec![],
wrapper_call: Some(WrapperConstructorCall {
metadata_param: "builder".to_owned(),
wrapper_type_path: "my_crate::RouteBuilder".to_owned(),
wrapper_type_name: "RouteBuilder".to_owned(),
constructor_method: "new".to_owned(),
args: vec![
WrapperConstructorArg::Fixed {
param_name: "method".to_owned(),
value_expr: "my_crate::Method::GET".to_owned(),
},
WrapperConstructorArg::Free {
param: ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
},
},
],
}),
signature_params: vec![ParamDef {
name: "path".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
}],
doc: Some("Register a GET handler for a path.".to_owned()),
style: crate::core::ir::RegistrationVariantStyle::Hybrid,
}],
};
let run_ep = EntrypointDef {
method: "run".to_owned(),
kind: EntrypointKind::Run,
is_async: true,
params: vec![ParamDef {
name: "addr".to_owned(),
ty: TypeRef::String,
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Unit,
error_type: Some("ServiceError".to_owned()),
doc: "Run the service.".to_owned(),
};
let finalize_ep = EntrypointDef {
method: "into_router".to_owned(),
kind: EntrypointKind::Finalize,
is_async: false,
params: vec![],
return_type: TypeRef::Named("Router".to_owned()),
error_type: None,
doc: "Consume and convert into a router.".to_owned(),
};
let service = ServiceDef {
name: "TestService".to_owned(),
rust_path: "my_crate::TestService".to_owned(),
constructor,
configurators: vec![configurator],
registrations: vec![registration],
entrypoints: vec![run_ep, finalize_ep],
doc: "A test service owner.".to_owned(),
cfg: None,
};
let dispatch_method = MethodDef {
name: "handle".to_owned(),
params: vec![ParamDef {
name: "request".to_owned(),
ty: TypeRef::Named("RequestData".to_owned()),
optional: false,
default: None,
..ParamDef::default()
}],
return_type: TypeRef::Named("ResponseData".to_owned()),
is_async: true,
is_static: false,
error_type: Some("HandlerError".to_owned()),
doc: "Dispatch a request.".to_owned(),
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,
};
let contract = HandlerContractDef {
trait_name: "RequestHandler".to_owned(),
rust_path: "my_crate::RequestHandler".to_owned(),
dispatch: dispatch_method,
optional_methods: vec![],
wire_request_type: Some("RequestData".to_owned()),
wire_response_type: Some("ResponseData".to_owned()),
dispatch_extra_params: vec![],
wire_param_name: None,
dispatch_return_type: None,
response_adapter: None,
doc: "Async trait for handling requests.".to_owned(),
};
ApiSurface {
crate_name: "my_crate".to_owned(),
version: "0.1.0".to_owned(),
services: vec![service],
handler_contracts: vec![contract],
..ApiSurface::default()
}
}
#[test]
fn ruby_output_contains_service_class() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(
output.contains("class TestService"),
"expected `class TestService` in output:\n{output}"
);
}
#[test]
fn ruby_output_contains_initialize_with_registrations() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(
output.contains("def initialize"),
"expected `def initialize` in output:\n{output}"
);
assert!(
output.contains("@registrations = []"),
"expected `@registrations = []` in output:\n{output}"
);
}
#[test]
fn ruby_output_contains_configurator() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(
output.contains("def with_timeout(timeout_ms)"),
"expected `with_timeout` configurator with positional param:\n{output}"
);
assert!(
output.contains("self"),
"expected `self` return in configurator:\n{output}"
);
}
#[test]
fn ruby_output_contains_registration_block_param() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(
output.contains("def add_handler("),
"expected `add_handler` registration method:\n{output}"
);
assert!(
output.contains("&block"),
"expected `&block` parameter in registration:\n{output}"
);
assert!(
output.contains("@registrations.push"),
"expected `@registrations.push` in registration:\n{output}"
);
}
#[test]
fn ruby_output_contains_registration_variant() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(
output.contains("def get("),
"expected `def get(` variant method:\n{output}"
);
assert!(
output.contains("&block"),
"expected `&block` parameter in variant:\n{output}"
);
assert!(
output.contains("@registrations.push"),
"expected `@registrations.push` in variant:\n{output}"
);
}
#[test]
fn ruby_output_contains_run_entrypoint() {
let surface = make_fixture_surface();
let output = gen_service_rb(&surface, "MyCrate", "my_crate");
assert!(output.contains("def run("), "expected `def run(` entrypoint:\n{output}");
assert!(
output.contains(".test_service_run("),
"expected native call `.test_service_run(` in run:\n{output}"
);
}
#[test]
fn rust_output_contains_handler_bridge_struct() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("pub struct RbRequestHandlerBridge"),
"expected `RbRequestHandlerBridge` struct:\n{output}"
);
}
#[test]
fn rust_output_contains_handler_bridge_impl() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("impl my_crate::RequestHandler for RbRequestHandlerBridge"),
"expected trait impl:\n{output}"
);
assert!(
output.contains("fn handle(") && output.contains("Pin<Box<dyn std::future::Future<Output"),
"expected boxed-future dispatch method:\n{output}"
);
}
#[test]
fn rust_output_contains_gvl_handling() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
let ruby_get_count = output.matches("Ruby::get()").count();
assert!(
ruby_get_count >= 2,
"expected at least 2 `Ruby::get()` calls (main function + GVL callback): count={}, output:\n{}",
ruby_get_count,
output
);
assert!(
output.contains("rb_sys::rb_thread_call_with_gvl"),
"expected `rb_sys::rb_thread_call_with_gvl` for handler bridge GVL:\n{output}"
);
}
#[test]
fn rust_output_contains_magnus_function_run() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("function! macro callbacks run on a Ruby thread"),
"expected `function!` callback handling in run entry point:\n{output}"
);
assert!(
output.contains("pub fn test_service_run("),
"expected `test_service_run` function:\n{output}"
);
assert!(
output.contains("rb_sys::rb_thread_call_without_gvl"),
"expected `rb_thread_call_without_gvl` for GVL-safe async run:\n{output}"
);
assert!(
output.contains("new_current_thread"),
"expected `new_current_thread` Tokio runtime in run:\n{output}"
);
}
#[test]
fn rust_output_contains_registration_dispatch() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("\"add_handler\""),
"expected `\"add_handler\"` match arm:\n{output}"
);
assert!(
output.contains("Arc<dyn my_crate::RequestHandler>"),
"expected Arc wrapping of handler:\n{output}"
);
}
#[test]
fn rust_output_contains_variant_dispatch() {
let surface = make_fixture_surface();
let config = make_test_config();
let output = gen_service_rs(&surface, &config);
assert!(
output.contains("\"get\""),
"expected `\"get\"` variant match arm:\n{output}"
);
assert!(
output.contains("RouteBuilder::new"),
"expected `RouteBuilder::new` wrapper constructor:\n{output}"
);
}
#[test]
fn generate_returns_two_files_for_non_empty_services() {
let surface = make_fixture_surface();
let config = make_test_config();
let files = generate(&surface, &config).expect("generate should not fail");
assert_eq!(files.len(), 2, "expected 2 generated files, got {}", files.len());
let paths: Vec<&str> = files
.iter()
.map(|f| f.path.file_name().unwrap().to_str().unwrap())
.collect();
assert!(paths.contains(&"service.rs"), "expected service.rs in output");
assert!(paths.contains(&"service.rb"), "expected service.rb in output");
}
#[test]
fn generate_returns_empty_for_no_services() {
let surface = ApiSurface::default();
let config = make_test_config();
let files = generate(&surface, &config).expect("generate should not fail");
assert!(files.is_empty(), "expected no files for surface without services");
}
fn make_test_config() -> ResolvedCrateConfig {
use crate::core::config::resolved::ResolvedCrateConfig;
ResolvedCrateConfig {
name: "my-crate".to_owned(),
..ResolvedCrateConfig::default()
}
}
}