//! Rust target — emits a complete Cargo crate consuming the //! `mizan-rust` kernel. Output shape lives at `templates/rust/*.j2`. use std::path::PathBuf; use askama::Template; use indexmap::IndexMap; use crate::config::{Config, RustKernelSpec}; use crate::emit::CodegenTarget; use crate::emit::EmittedFile; use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case}; use crate::ir::{ IsContext, MizanContext, MizanFunction, MizanIR, NamedType, Primitive, StructField as IrStructField, TypeShape, }; pub struct RustCrate; impl CodegenTarget for RustCrate { fn name(&self) -> &'static str { "rust" } fn emit(&self, ir: &MizanIR, config: &Config) -> Vec { let crate_name = config .rust_crate_name .clone() .unwrap_or_else(|| "mizan_client".to_string()); let kernel_dep = format_kernel_dep(config.rust_kernel.as_ref()); let mut out: Vec = Vec::new(); out.push(EmittedFile::new( "Cargo.toml", CargoTemplate { crate_name: &crate_name, kernel_dep: &kernel_dep } .render().expect("Cargo.toml renders"), )); out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.types))); let mut context_modules: Vec = Vec::new(); for (ctx_name, ctx_meta) in &ir.contexts { let module_name = snake_case(ctx_name); out.push(EmittedFile::new( PathBuf::from("src/contexts").join(format!("{module_name}.rs")), emit_context_file(ctx_name, ctx_meta, &ir.functions), )); context_modules.push(module_name); } if !context_modules.is_empty() { out.push(EmittedFile::new("src/contexts/mod.rs", emit_mod_file(&context_modules))); } let mut mutation_modules: Vec = Vec::new(); let mut function_modules: Vec = Vec::new(); for fn_meta in &ir.functions { if !matches!(fn_meta.is_context, IsContext::No) || fn_meta.is_form { continue; } let is_mutation = !fn_meta.affects.is_empty(); let kind = if is_mutation { "mutations" } else { "functions" }; let module_name = snake_case(&fn_meta.camel_name); out.push(EmittedFile::new( PathBuf::from(format!("src/{kind}")).join(format!("{module_name}.rs")), emit_call_file(fn_meta), )); if is_mutation { mutation_modules.push(module_name); } else { function_modules.push(module_name); } } if !mutation_modules.is_empty() { out.push(EmittedFile::new("src/mutations/mod.rs", emit_mod_file(&mutation_modules))); } if !function_modules.is_empty() { out.push(EmittedFile::new("src/functions/mod.rs", emit_mod_file(&function_modules))); } out.push(EmittedFile::new( "src/lib.rs", LibTemplate { has_contexts: !context_modules.is_empty(), has_mutations: !mutation_modules.is_empty(), has_functions: !function_modules.is_empty(), }.render().expect("lib.rs renders"), )); out } } #[derive(Template)] #[template(path = "rust/Cargo.toml.j2", escape = "none")] struct CargoTemplate<'a> { crate_name: &'a str, kernel_dep: &'a str, } #[derive(Template)] #[template(path = "rust/lib.rs.j2", escape = "none")] struct LibTemplate { has_contexts: bool, has_mutations: bool, has_functions: bool, } #[derive(Template)] #[template(path = "rust/mod.rs.j2", escape = "none")] struct ModTemplate { modules: Vec, } #[derive(Template)] #[template(path = "rust/context.rs.j2", escape = "none")] struct ContextTemplate<'a> { pascal: String, snake: String, ctx_name: &'a str, type_imports: Vec, data_fields: Vec, params: Vec, } #[derive(Template)] #[template(path = "rust/call.rs.j2", escape = "none")] struct CallTemplate<'a> { snake: String, name: &'a str, return_type: String, type_imports: Vec, input_param: String, args_value: &'static str, } #[derive(Template)] #[template(path = "rust/types.rs.j2", escape = "none")] struct TypesTemplate { schemas_block: String, hoisted_enums_block: String, } /// Renderer-side view of a single Rust struct field. Distinct from /// `ir::StructField` (the IR shape) because the renderer carries /// already-rendered identifiers and rename flags. struct RustField { raw_name: String, ident: String, ty: String, has_rename: bool, } fn dedupe_preserving_order(items: impl IntoIterator) -> Vec { let mut seen = std::collections::HashSet::new(); items.into_iter().filter(|s| seen.insert(s.clone())).collect() } // ─── Cargo.toml ──────────────────────────────────────────────────────────── fn format_kernel_dep(spec: Option<&RustKernelSpec>) -> String { match spec { Some(RustKernelSpec::Path { path }) => format!("{{ path = {} }}", json_str(path)), Some(RustKernelSpec::Git { git, tag, rev, branch }) => { let mut parts = vec![format!("git = {}", json_str(git))]; if let Some(t) = tag { parts.push(format!("tag = {}", json_str(t))); } if let Some(r) = rev { parts.push(format!("rev = {}", json_str(r))); } if let Some(b) = branch { parts.push(format!("branch = {}", json_str(b))); } format!("{{ {} }}", parts.join(", ")) } Some(RustKernelSpec::Version { version }) => format!("{{ version = {} }}", json_str(version)), None => "{ version = \"0.1\" }".to_string(), } } fn json_str(s: &str) -> String { serde_json::to_string(s).expect("string literal serializes") } // ─── mod.rs ──────────────────────────────────────────────────────────────── fn emit_mod_file(module_names: &[String]) -> String { let mut sorted = module_names.to_vec(); sorted.sort(); ModTemplate { modules: sorted }.render().expect("mod.rs renders") } // ─── Context file ────────────────────────────────────────────────────────── fn emit_context_file( ctx_name: &str, ctx_meta: &MizanContext, all_functions: &[MizanFunction], ) -> String { let pascal = pascal_case(ctx_name); let snake = snake_case(ctx_name); let ctx_fns: Vec<&MizanFunction> = all_functions .iter() .filter(|f| f.is_context.as_str() == Some(ctx_name)) .collect(); let type_imports = dedupe_preserving_order( ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)), ); let data_fields: Vec = ctx_fns.iter() .map(|f| { let ident = rust_ident(&f.name); RustField { has_rename: ident != f.name, raw_name: f.name.clone(), ident, ty: rust_type_ident(&f.output_type), } }) .collect(); let params: Vec = ctx_meta.params.iter() .map(|(p_name, p_meta)| { let ident = rust_ident(p_name); let base = param_rust_type(p_meta.ty); let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") }; RustField { has_rename: ident != *p_name, raw_name: p_name.clone(), ident, ty, } }) .collect(); ContextTemplate { pascal, snake, ctx_name, type_imports, data_fields, params, }.render().expect("context.rs renders") } fn param_rust_type(p: Primitive) -> &'static str { match p { Primitive::Integer => "i64", Primitive::Number => "f64", Primitive::Boolean => "bool", Primitive::String => "String", } } // ─── Call file ───────────────────────────────────────────────────────────── fn emit_call_file(fn_meta: &MizanFunction) -> String { let output_type = rust_type_ident(&fn_meta.output_type); let return_type = if fn_meta.output_nullable { format!("Option<{output_type}>") } else { output_type.clone() }; let input_type = fn_meta.input_type.as_deref().map(rust_type_ident); let mut used_seed: Vec = vec![output_type.clone()]; if let Some(t) = &input_type { used_seed.push(t.clone()); } let type_imports = dedupe_preserving_order(used_seed); let (input_param, args_value) = if fn_meta.has_input { let it = input_type.as_deref().unwrap_or(""); ( format!(", args: &{it}"), "serde_json::to_value(args).unwrap_or(Value::Object(Default::default()))", ) } else { ( String::new(), "Value::Object(Default::default())", ) }; CallTemplate { snake: snake_case(&fn_meta.name), name: &fn_meta.name, return_type, type_imports, input_param, args_value, }.render().expect("call.rs renders") } // ─── types.rs ────────────────────────────────────────────────────────────── /// Per-types-file context tracking enum names hoisted out of inline /// `field { enum "a" "b" }` declarations into Rust top-level enum types. struct EnumCtx { hoisted: Vec<(String, Vec)>, enum_name: Option, } fn emit_types_rs(types: &IndexMap) -> String { let mut ctx = EnumCtx { hoisted: Vec::new(), enum_name: None }; let schemas_block = types.iter() .map(|(raw_name, ty)| { let name = rust_type_ident(raw_name); match ty { NamedType::Struct(fields) => emit_struct_decl(&name, fields, &mut ctx), NamedType::List(inner) => emit_transparent_array(&name, inner, &mut ctx), NamedType::Enum(variants) => emit_string_enum(&name, variants), NamedType::Alias(inner) => emit_type_alias(&name, inner, &mut ctx), } }) .collect::>() .join("\n"); let hoisted_enums_block = ctx.hoisted.iter() .map(|(n, v)| emit_string_enum(n, v)) .collect::>() .join("\n"); TypesTemplate { schemas_block, hoisted_enums_block } .render().expect("types.rs renders") } fn emit_string_enum(name: &str, variants: &[String]) -> String { let body = variants.iter() .map(|v| { let ident = pascal_case(v); let rename = if ident == *v { String::new() } else { format!(" #[serde(rename = {})]\n", json_str(v)) }; format!("{rename} {ident},") }) .collect::>() .join("\n"); format!( "#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub enum {name} {{\n{body}\n}}\n", name = rust_type_ident(name), ) } fn emit_transparent_array(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String { ctx.enum_name = None; let inner_ty = rust_type_from_shape(inner, ctx); format!( "#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner_ty}>);\n", ) } fn emit_struct_decl( name: &str, fields: &[IrStructField], ctx: &mut EnumCtx, ) -> String { let fields_body = fields.iter() .map(|f| { let field_name = rust_ident(&f.name); ctx.enum_name = Some(format!("{name}_{}", pascal_case(&f.name))); let mut ty = rust_type_from_shape(&f.shape, ctx); let is_required = f.required || f.default.is_some(); if !is_required && !ty.starts_with("Option<") { ty = format!("Option<{ty}>"); } let rename = if field_name == f.name { String::new() } else { format!(" #[serde(rename = \"{raw}\")]\n", raw = f.name) }; format!("{rename} pub {field_name}: {ty},") }) .collect::>() .join("\n"); format!( "#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields_body}\n}}\n", ) } fn emit_type_alias(name: &str, inner: &TypeShape, ctx: &mut EnumCtx) -> String { ctx.enum_name = Some(name.to_string()); let ty = rust_type_from_shape(inner, ctx); format!("pub type {name} = {ty};\n") } fn rust_type_from_shape(shape: &TypeShape, ctx: &mut EnumCtx) -> String { match shape { TypeShape::Ref(name) => rust_type_ident(name), TypeShape::Primitive(Primitive::Integer) => "i64".to_string(), TypeShape::Primitive(Primitive::Number) => "f64".to_string(), TypeShape::Primitive(Primitive::Boolean) => "bool".to_string(), TypeShape::Primitive(Primitive::String) => "String".to_string(), TypeShape::List(inner) => { ctx.enum_name = None; format!("Vec<{}>", rust_type_from_shape(inner, ctx)) } TypeShape::Optional(inner) => { ctx.enum_name = None; format!("Option<{}>", rust_type_from_shape(inner, ctx)) } TypeShape::Enum(variants) => { // Inline enums hoist out into top-level Rust enum types so the // generated struct field can reference them by name. let enum_name = ctx .enum_name .clone() .unwrap_or_else(|| "Enum_inline".to_string()); ctx.hoisted.push((enum_name.clone(), variants.clone())); enum_name } TypeShape::Union(_branches) => { // Rust serde doesn't have a clean way to deserialize an untagged // multi-arm union without losing type info; fall back to a JSON // Value so the consumer can match on the runtime variant. "serde_json::Value".to_string() } } }