use crate::Emitter;
use crate::ir;
use super::case::{to_pascal_case, to_snake_case};
#[derive(Debug, Default, Clone, Copy)]
pub struct RustEmitter;
impl RustEmitter {
pub fn new() -> Self {
Self
}
}
impl Emitter for RustEmitter {
fn emit(&self, schema: &ir::Schema) -> String {
let mut out = String::new();
out.push_str(IMPORTS);
for ty in &schema.types {
out.push('\n');
out.push_str(&render_typedef(ty));
}
for record in &schema.records {
out.push('\n');
out.push_str(&render_record(record));
}
for relation in &schema.relations {
out.push('\n');
out.push_str(&render_relation(relation));
}
if let Some(protocol) = &schema.protocol {
for channel in &protocol.channels {
out.push_str(&render_channel(channel));
}
}
out
}
}
const IMPORTS: &str = "\
use serde::{Deserialize, Serialize};
use anyhow::Result;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use std::collections::HashMap;
";
fn render_typedef(ty: &ir::TypeDef) -> String {
match ty {
ir::TypeDef::Struct {
name,
description,
fields,
} => render_struct(name, description.as_deref(), fields),
ir::TypeDef::Enum {
name,
description,
variants,
} => render_enum(name, description.as_deref(), variants),
}
}
fn render_doc(description: Option<&str>, indent: &str) -> String {
match description {
Some(text) => text
.lines()
.map(|line| format!("{indent}/// {line}\n"))
.collect(),
None => String::new(),
}
}
fn render_struct(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
let derive = "#[derive(Debug, Clone, Serialize, Deserialize)]\n";
let doc = render_doc(description, "");
if fields.is_empty() {
return format!("{doc}{derive}pub struct {name};\n");
}
let mut out = String::new();
out.push_str(&doc);
out.push_str(derive);
out.push_str(&format!("pub struct {name} {{\n"));
for field in fields {
out.push_str(&render_field(field));
}
out.push_str("}\n");
out
}
fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
let mut out = String::new();
out.push_str(&render_doc(description, ""));
out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n");
out.push_str("#[serde(rename_all = \"snake_case\")]\n");
out.push_str(&format!("pub enum {name} {{\n"));
for v in variants {
out.push_str(&format!(" #[serde(rename = \"{v}\")]\n"));
out.push_str(&format!(" {},\n", to_pascal_case(v)));
}
out.push_str("}\n");
out
}
fn render_field(field: &ir::Field) -> String {
let mut out = String::new();
out.push_str(&render_doc(field.description.as_deref(), " "));
let snake = to_snake_case(&field.name);
if field.name != snake {
out.push_str(&format!(" #[serde(rename = \"{}\")]\n", field.name));
}
let base = ty_to_rust(&field.ty);
let rust_ty = if field.required {
base
} else {
out.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
format!("Option<{base}>")
};
out.push_str(&format!(
" pub {}: {rust_ty},\n",
field_ident(&field.name)
));
out
}
fn field_ident(name: &str) -> String {
const KEYWORDS: &[&str] = &[
"as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
"if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
"static", "struct", "trait", "true", "type", "unsafe", "use", "where", "while", "async",
"await", "gen", "abstract", "become", "box", "do", "final", "macro", "override", "priv",
"try", "typeof", "unsized", "virtual", "yield",
];
if KEYWORDS.contains(&name) {
format!("r#{name}")
} else {
name.to_string()
}
}
fn ty_to_rust(ty: &ir::Ty) -> String {
match ty {
ir::Ty::Primitive(p) => prim_to_rust(*p).to_string(),
ir::Ty::Array(inner) => format!("Vec<{}>", ty_to_rust(inner)),
ir::Ty::Named(name) => name.clone(),
ir::Ty::Link(_) => "String".to_string(),
ir::Ty::Literal(_) => "String".to_string(),
ir::Ty::Union(members) => {
if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))) {
"String".to_string()
} else {
let mut mapped: Vec<String> = Vec::new();
for m in members {
let t = ty_to_rust(m);
if !mapped.contains(&t) {
mapped.push(t);
}
}
if mapped.len() == 1 {
mapped.into_iter().next().unwrap()
} else {
"serde_json::Value".to_string()
}
}
}
}
}
fn prim_to_rust(p: ir::Prim) -> &'static str {
match p {
ir::Prim::String => "String",
ir::Prim::Int => "i64",
ir::Prim::Float => "f64",
ir::Prim::Bool => "bool",
ir::Prim::Datetime => "DateTime<Utc>",
ir::Prim::Json => "serde_json::Value",
}
}
fn render_record(record: &ir::Record) -> String {
let mut fields = Vec::with_capacity(record.fields.len() + 1);
fields.push(id_field());
fields.extend(record.fields.iter().cloned());
render_struct(
&to_pascal_case(&record.name),
record.description.as_deref(),
&fields,
)
}
fn render_relation(relation: &ir::Relation) -> String {
let mut fields = Vec::with_capacity(relation.fields.len() + 3);
fields.push(id_field());
fields.push(ir::Field {
name: "in".to_string(),
ty: ir::Ty::Primitive(ir::Prim::String),
required: true,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
});
fields.push(ir::Field {
name: "out".to_string(),
ty: ir::Ty::Primitive(ir::Prim::String),
required: true,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
});
fields.extend(relation.fields.iter().cloned());
render_struct(
&to_pascal_case(&relation.name),
relation.description.as_deref(),
&fields,
)
}
fn id_field() -> ir::Field {
ir::Field {
name: "id".to_string(),
ty: ir::Ty::Primitive(ir::Prim::String),
required: true,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
}
}
fn render_channel(channel: &ir::Channel) -> String {
let mut out = String::new();
for req in &channel.requests {
out.push('\n');
out.push_str(&render_struct(
&to_pascal_case(&req.name),
None,
&req.fields,
));
if let Some(returns) = &req.returns {
out.push('\n');
out.push_str(&render_struct(
&to_pascal_case(&returns.name),
None,
&returns.fields,
));
}
}
for evt in &channel.events {
out.push('\n');
out.push_str(&render_struct(
&to_pascal_case(&evt.name),
None,
&evt.fields,
));
}
if let Some(tag) = &channel.envelope
&& !channel.requests.is_empty()
{
out.push('\n');
out.push_str(&render_envelope_enum(channel, tag));
}
out
}
fn render_envelope_enum(channel: &ir::Channel, tag: &str) -> String {
let enum_name = format!("{}Envelope", to_pascal_case(&channel.name));
let mut out = String::new();
out.push_str(&format!(
"/// Envelope enum for channel {:?} — a discriminated union over its\n\
/// requests, internally tagged by the {tag:?} field.\n",
channel.name
));
out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
out.push_str(&format!("#[serde(tag = \"{tag}\")]\n"));
out.push_str(&format!("pub enum {enum_name} {{\n"));
for req in &channel.requests {
let variant = to_pascal_case(&req.name);
if variant != req.name {
out.push_str(&format!(" #[serde(rename = \"{}\")]\n", req.name));
}
if req.fields.is_empty() {
out.push_str(&format!(" {variant},\n"));
} else {
out.push_str(&format!(" {variant}({variant}),\n"));
}
}
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
ir::Field {
name: name.to_string(),
ty,
required,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
}
}
#[test]
fn emits_import_header() {
let out = RustEmitter::new().emit(&ir::Schema::default());
assert!(out.contains("use serde::{Deserialize, Serialize};"));
assert!(out.contains("use chrono::{DateTime, Utc};"));
}
#[test]
fn emits_struct_with_required_field() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
assert!(out.contains("pub struct User {"));
assert!(out.contains(" pub name: String,"));
}
#[test]
fn keyword_field_name_is_raw_identifier() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "Node".to_string(),
description: None,
fields: vec![field("type", ir::Ty::Primitive(ir::Prim::String), true)],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub r#type: String,"));
}
#[test]
fn optional_field_becomes_option_with_skip() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
assert!(out.contains("pub nick: Option<String>,"));
}
#[test]
fn non_snake_field_gets_serde_rename() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field(
"displayName",
ir::Ty::Primitive(ir::Prim::String),
true,
)],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("#[serde(rename = \"displayName\")]"));
assert!(out.contains("pub displayName: String,"));
}
#[test]
fn fieldless_struct_is_unit() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "Empty".to_string(),
description: None,
fields: vec![],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub struct Empty;"));
}
#[test]
fn emits_enum_with_rename() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Enum {
name: "Role".to_string(),
description: None,
variants: vec!["admin".to_string(), "guest_user".to_string()],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("#[serde(rename_all = \"snake_case\")]"));
assert!(out.contains("pub enum Role {"));
assert!(out.contains("#[serde(rename = \"admin\")]"));
assert!(out.contains(" Admin,"));
assert!(out.contains("#[serde(rename = \"guest_user\")]"));
assert!(out.contains(" GuestUser,"));
}
#[test]
fn maps_primitive_and_compound_types() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![
field("n", ir::Ty::Primitive(ir::Prim::Int), true),
field("f", ir::Ty::Primitive(ir::Prim::Float), true),
field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
field(
"tags",
ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
true,
),
field("owner", ir::Ty::Named("User".to_string()), true),
],
}],
protocol: None,
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub n: i64,"));
assert!(out.contains("pub f: f64,"));
assert!(out.contains("pub b: bool,"));
assert!(out.contains("pub at: DateTime<Utc>,"));
assert!(out.contains("pub blob: serde_json::Value,"));
assert!(out.contains("pub tags: Vec<String>,"));
assert!(out.contains("pub owner: User,"));
}
#[test]
fn emits_channel_request_returns_and_event_structs() {
let schema = ir::Schema {
types: vec![],
records: vec![],
relations: vec![],
protocol: Some(ir::Protocol {
name: "ping-pong".to_string(),
version: "2.0.0".to_string(),
namespace: None,
description: None,
channels: vec![ir::Channel {
name: "ping-pong".to_string(),
from: ir::ChannelFrom::Client,
lifetime: ir::ChannelLifetime::Persistent,
backend: ir::ChannelBackend::Stream,
channel_id: None,
envelope: None,
requests: vec![ir::Request {
name: "Ping".to_string(),
fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
returns: Some(ir::Message {
name: "Pong".to_string(),
fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
}),
}],
events: vec![ir::Event {
name: "Tick".to_string(),
fields: vec![],
}],
}],
}),
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub struct Ping {"));
assert!(out.contains("pub struct Pong {"));
assert!(out.contains("pub struct Tick;"));
}
fn sidebar_channel(envelope: Option<&str>) -> ir::Channel {
ir::Channel {
name: "ipc".to_string(),
from: ir::ChannelFrom::Client,
lifetime: ir::ChannelLifetime::Transient,
backend: ir::ChannelBackend::Stream,
channel_id: None,
envelope: envelope.map(str::to_string),
requests: vec![
ir::Request {
name: "process:toggle".to_string(),
fields: vec![
field("path", ir::Ty::Primitive(ir::Prim::String), true),
field("expanded", ir::Ty::Primitive(ir::Prim::Bool), true),
],
returns: None,
},
ir::Request {
name: "process:add".to_string(),
fields: vec![],
returns: None,
},
],
events: vec![],
}
}
fn protocol_schema(channel: ir::Channel) -> ir::Schema {
ir::Schema {
protocol: Some(ir::Protocol {
name: "sidebar".to_string(),
version: "1.0.0".to_string(),
namespace: None,
description: None,
channels: vec![channel],
}),
..Default::default()
}
}
#[test]
fn channel_request_names_are_sanitized_to_valid_identifiers() {
let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
assert!(out.contains("pub struct ProcessToggle {"));
assert!(out.contains("pub struct ProcessAdd;"));
assert!(
!out.contains("process:toggle"),
"raw `:` name must not leak"
);
}
#[test]
fn channel_without_envelope_emits_no_enum() {
let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
assert!(!out.contains("pub enum"), "no envelope ⇒ no enum");
}
#[test]
fn envelope_channel_emits_internally_tagged_enum() {
let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(Some("t"))));
assert!(out.contains("#[serde(tag = \"t\")]"), "internally tagged");
assert!(
out.contains("pub enum IpcEnvelope {"),
"enum named <Channel>Envelope"
);
assert!(out.contains(" #[serde(rename = \"process:toggle\")]"));
assert!(out.contains(" ProcessToggle(ProcessToggle),"));
assert!(out.contains(" #[serde(rename = \"process:add\")]"));
assert!(out.contains(" ProcessAdd,\n"));
assert!(
!out.contains("ProcessAdd(ProcessAdd)"),
"fieldless request must not become a newtype variant"
);
}
#[test]
fn envelope_variant_without_colon_name_needs_no_rename() {
let mut channel = sidebar_channel(Some("t"));
channel.requests = vec![ir::Request {
name: "Ping".to_string(),
fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
returns: None,
}];
let out = RustEmitter::new().emit(&protocol_schema(channel));
assert!(out.contains(" Ping(Ping),"));
let variant_line = out.find(" Ping(Ping),").unwrap();
let preceding = &out[..variant_line];
assert!(
!preceding.trim_end().ends_with("rename = \"Ping\")]"),
"an already-PascalCase name needs no rename"
);
}
#[test]
fn record_becomes_struct_with_id_field() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub struct Atlas {"));
assert!(out.contains("pub id: String,"), "record gets an id field");
assert!(out.contains("pub name: String,"));
}
#[test]
fn relation_becomes_edge_struct_with_in_out() {
let schema = ir::Schema {
relations: vec![ir::Relation {
name: "derivedFrom".to_string(),
description: None,
from: "Memory".to_string(),
to: "Memory".to_string(),
unique: true,
fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub struct DerivedFrom {"));
assert!(out.contains("pub id: String,"));
assert!(out.contains("pub r#in: String,"));
assert!(out.contains("pub out: String,"));
assert!(out.contains("pub reason: Option<String>,"));
}
#[test]
fn link_field_becomes_string() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub parent: Option<String>,"));
}
#[test]
fn literal_union_degrades_to_string() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Doc".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field(
"visibility",
ir::Ty::Union(vec![
ir::Ty::Literal("public".to_string()),
ir::Ty::Literal("private".to_string()),
]),
true,
)],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub visibility: String,"));
}
#[test]
fn mixed_union_degrades_to_json_value() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![field(
"v",
ir::Ty::Union(vec![
ir::Ty::Primitive(ir::Prim::String),
ir::Ty::Primitive(ir::Prim::Int),
]),
true,
)],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub v: serde_json::Value,"));
}
#[test]
fn struct_and_field_descriptions_become_doc_comments() {
let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
content.description = Some("Memory content text".to_string());
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "Memory".to_string(),
description: Some("User memory".to_string()),
fields: vec![content],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("/// User memory\n"), "struct doc comment");
assert!(
out.contains(" /// Memory content text\n"),
"field doc comment"
);
}
#[test]
fn enum_description_becomes_doc_comment() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Enum {
name: "Role".to_string(),
description: Some("An access role".to_string()),
variants: vec!["admin".to_string()],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("/// An access role\n"));
}
#[test]
fn constraints_do_not_appear_in_rust_output() {
let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
f.constraints = ir::Constraints {
min: Some(0),
max: Some(1),
pattern: Some("x".to_string()),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = RustEmitter::new().emit(&schema);
assert!(out.contains("pub confidence: f64,"));
assert!(!out.contains("minimum"), "no constraint metadata leaks");
}
}