1use crate::Emitter;
48use crate::ir;
49
50use super::case::{to_pascal_case, to_snake_case};
51
52#[derive(Debug, Default, Clone, Copy)]
54pub struct RustEmitter;
55
56impl RustEmitter {
57 pub fn new() -> Self {
59 Self
60 }
61}
62
63impl Emitter for RustEmitter {
64 fn emit(&self, schema: &ir::Schema) -> String {
65 let mut out = String::new();
66 out.push_str(IMPORTS);
67
68 for ty in &schema.types {
70 out.push('\n');
71 out.push_str(&render_typedef(ty));
72 }
73
74 for record in &schema.records {
76 out.push('\n');
77 out.push_str(&render_record(record));
78 }
79 for relation in &schema.relations {
80 out.push('\n');
81 out.push_str(&render_relation(relation));
82 }
83
84 if let Some(protocol) = &schema.protocol {
86 for channel in &protocol.channels {
87 out.push_str(&render_channel(channel));
88 }
89 }
90
91 out
92 }
93}
94
95const IMPORTS: &str = "\
97use serde::{Deserialize, Serialize};
98use anyhow::Result;
99use chrono::{DateTime, Utc};
100use uuid::Uuid;
101use std::collections::HashMap;
102";
103
104fn render_typedef(ty: &ir::TypeDef) -> String {
106 match ty {
107 ir::TypeDef::Struct {
108 name,
109 description,
110 fields,
111 } => render_struct(name, description.as_deref(), fields),
112 ir::TypeDef::Enum {
113 name,
114 description,
115 variants,
116 } => render_enum(name, description.as_deref(), variants),
117 }
118}
119
120fn render_doc(description: Option<&str>, indent: &str) -> String {
123 match description {
124 Some(text) => text
125 .lines()
126 .map(|line| format!("{indent}/// {line}\n"))
127 .collect(),
128 None => String::new(),
129 }
130}
131
132fn render_struct(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
135 let derive = "#[derive(Debug, Clone, Serialize, Deserialize)]\n";
136 let doc = render_doc(description, "");
137 if fields.is_empty() {
138 return format!("{doc}{derive}pub struct {name};\n");
139 }
140 let mut out = String::new();
141 out.push_str(&doc);
142 out.push_str(derive);
143 out.push_str(&format!("pub struct {name} {{\n"));
144 for field in fields {
145 out.push_str(&render_field(field));
146 }
147 out.push_str("}\n");
148 out
149}
150
151fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
153 let mut out = String::new();
154 out.push_str(&render_doc(description, ""));
155 out.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n");
156 out.push_str("#[serde(rename_all = \"snake_case\")]\n");
157 out.push_str(&format!("pub enum {name} {{\n"));
158 for v in variants {
159 out.push_str(&format!(" #[serde(rename = \"{v}\")]\n"));
160 out.push_str(&format!(" {},\n", to_pascal_case(v)));
161 }
162 out.push_str("}\n");
163 out
164}
165
166fn render_field(field: &ir::Field) -> String {
168 let mut out = String::new();
169
170 out.push_str(&render_doc(field.description.as_deref(), " "));
172
173 let snake = to_snake_case(&field.name);
175 if field.name != snake {
176 out.push_str(&format!(" #[serde(rename = \"{}\")]\n", field.name));
177 }
178
179 let base = ty_to_rust(&field.ty);
180 let rust_ty = if field.required {
181 base
182 } else {
183 out.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
184 format!("Option<{base}>")
185 };
186
187 out.push_str(&format!(
188 " pub {}: {rust_ty},\n",
189 field_ident(&field.name)
190 ));
191 out
192}
193
194fn field_ident(name: &str) -> String {
202 const KEYWORDS: &[&str] = &[
203 "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
204 "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
205 "static", "struct", "trait", "true", "type", "unsafe", "use", "where", "while", "async",
206 "await", "gen", "abstract", "become", "box", "do", "final", "macro", "override", "priv",
207 "try", "typeof", "unsized", "virtual", "yield",
208 ];
209 if KEYWORDS.contains(&name) {
210 format!("r#{name}")
211 } else {
212 name.to_string()
213 }
214}
215
216fn ty_to_rust(ty: &ir::Ty) -> String {
218 match ty {
219 ir::Ty::Primitive(p) => prim_to_rust(*p).to_string(),
220 ir::Ty::Array(inner) => format!("Vec<{}>", ty_to_rust(inner)),
221 ir::Ty::Named(name) => name.clone(),
222 ir::Ty::Link(_) => "String".to_string(),
224 ir::Ty::Literal(_) => "String".to_string(),
226 ir::Ty::Union(members) => {
231 if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))) {
232 "String".to_string()
233 } else {
234 let mut mapped: Vec<String> = Vec::new();
235 for m in members {
236 let t = ty_to_rust(m);
237 if !mapped.contains(&t) {
238 mapped.push(t);
239 }
240 }
241 if mapped.len() == 1 {
242 mapped.into_iter().next().unwrap()
243 } else {
244 "serde_json::Value".to_string()
245 }
246 }
247 }
248 }
249}
250
251fn prim_to_rust(p: ir::Prim) -> &'static str {
253 match p {
254 ir::Prim::String => "String",
255 ir::Prim::Int => "i64",
256 ir::Prim::Float => "f64",
257 ir::Prim::Bool => "bool",
258 ir::Prim::Datetime => "DateTime<Utc>",
259 ir::Prim::Json => "serde_json::Value",
260 }
261}
262
263fn render_record(record: &ir::Record) -> String {
267 let mut fields = Vec::with_capacity(record.fields.len() + 1);
268 fields.push(id_field());
269 fields.extend(record.fields.iter().cloned());
270 render_struct(
271 &to_pascal_case(&record.name),
272 record.description.as_deref(),
273 &fields,
274 )
275}
276
277fn render_relation(relation: &ir::Relation) -> String {
280 let mut fields = Vec::with_capacity(relation.fields.len() + 3);
281 fields.push(id_field());
282 fields.push(ir::Field {
283 name: "in".to_string(),
284 ty: ir::Ty::Primitive(ir::Prim::String),
285 required: true,
286 flexible: false,
287 default: None,
288 description: None,
289 constraints: ir::Constraints::default(),
290 });
291 fields.push(ir::Field {
292 name: "out".to_string(),
293 ty: ir::Ty::Primitive(ir::Prim::String),
294 required: true,
295 flexible: false,
296 default: None,
297 description: None,
298 constraints: ir::Constraints::default(),
299 });
300 fields.extend(relation.fields.iter().cloned());
301 render_struct(
302 &to_pascal_case(&relation.name),
303 relation.description.as_deref(),
304 &fields,
305 )
306}
307
308fn id_field() -> ir::Field {
310 ir::Field {
311 name: "id".to_string(),
312 ty: ir::Ty::Primitive(ir::Prim::String),
313 required: true,
314 flexible: false,
315 default: None,
316 description: None,
317 constraints: ir::Constraints::default(),
318 }
319}
320
321fn render_channel(channel: &ir::Channel) -> String {
324 let mut out = String::new();
325 for req in &channel.requests {
326 out.push('\n');
327 out.push_str(&render_struct(&req.name, None, &req.fields));
328 if let Some(returns) = &req.returns {
329 out.push('\n');
330 out.push_str(&render_struct(&returns.name, None, &returns.fields));
331 }
332 }
333 for evt in &channel.events {
334 out.push('\n');
335 out.push_str(&render_struct(&evt.name, None, &evt.fields));
336 }
337 out
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
345 ir::Field {
346 name: name.to_string(),
347 ty,
348 required,
349 flexible: false,
350 default: None,
351 description: None,
352 constraints: ir::Constraints::default(),
353 }
354 }
355
356 #[test]
357 fn emits_import_header() {
358 let out = RustEmitter::new().emit(&ir::Schema::default());
359 assert!(out.contains("use serde::{Deserialize, Serialize};"));
360 assert!(out.contains("use chrono::{DateTime, Utc};"));
361 }
362
363 #[test]
364 fn emits_struct_with_required_field() {
365 let schema = ir::Schema {
366 types: vec![ir::TypeDef::Struct {
367 name: "User".to_string(),
368 description: None,
369 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
370 }],
371 protocol: None,
372 ..Default::default()
373 };
374 let out = RustEmitter::new().emit(&schema);
375 assert!(out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
376 assert!(out.contains("pub struct User {"));
377 assert!(out.contains(" pub name: String,"));
378 }
379
380 #[test]
381 fn keyword_field_name_is_raw_identifier() {
382 let schema = ir::Schema {
383 types: vec![ir::TypeDef::Struct {
384 name: "Node".to_string(),
385 description: None,
386 fields: vec![field("type", ir::Ty::Primitive(ir::Prim::String), true)],
387 }],
388 protocol: None,
389 ..Default::default()
390 };
391 let out = RustEmitter::new().emit(&schema);
392 assert!(out.contains("pub r#type: String,"));
395 }
396
397 #[test]
398 fn optional_field_becomes_option_with_skip() {
399 let schema = ir::Schema {
400 types: vec![ir::TypeDef::Struct {
401 name: "User".to_string(),
402 description: None,
403 fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
404 }],
405 protocol: None,
406 ..Default::default()
407 };
408 let out = RustEmitter::new().emit(&schema);
409 assert!(out.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
410 assert!(out.contains("pub nick: Option<String>,"));
411 }
412
413 #[test]
414 fn non_snake_field_gets_serde_rename() {
415 let schema = ir::Schema {
416 types: vec![ir::TypeDef::Struct {
417 name: "User".to_string(),
418 description: None,
419 fields: vec![field(
420 "displayName",
421 ir::Ty::Primitive(ir::Prim::String),
422 true,
423 )],
424 }],
425 protocol: None,
426 ..Default::default()
427 };
428 let out = RustEmitter::new().emit(&schema);
429 assert!(out.contains("#[serde(rename = \"displayName\")]"));
430 assert!(out.contains("pub displayName: String,"));
431 }
432
433 #[test]
434 fn fieldless_struct_is_unit() {
435 let schema = ir::Schema {
436 types: vec![ir::TypeDef::Struct {
437 name: "Empty".to_string(),
438 description: None,
439 fields: vec![],
440 }],
441 protocol: None,
442 ..Default::default()
443 };
444 let out = RustEmitter::new().emit(&schema);
445 assert!(out.contains("pub struct Empty;"));
446 }
447
448 #[test]
449 fn emits_enum_with_rename() {
450 let schema = ir::Schema {
451 types: vec![ir::TypeDef::Enum {
452 name: "Role".to_string(),
453 description: None,
454 variants: vec!["admin".to_string(), "guest_user".to_string()],
455 }],
456 protocol: None,
457 ..Default::default()
458 };
459 let out = RustEmitter::new().emit(&schema);
460 assert!(out.contains("#[serde(rename_all = \"snake_case\")]"));
461 assert!(out.contains("pub enum Role {"));
462 assert!(out.contains("#[serde(rename = \"admin\")]"));
463 assert!(out.contains(" Admin,"));
464 assert!(out.contains("#[serde(rename = \"guest_user\")]"));
465 assert!(out.contains(" GuestUser,"));
466 }
467
468 #[test]
469 fn maps_primitive_and_compound_types() {
470 let schema = ir::Schema {
471 types: vec![ir::TypeDef::Struct {
472 name: "T".to_string(),
473 description: None,
474 fields: vec![
475 field("n", ir::Ty::Primitive(ir::Prim::Int), true),
476 field("f", ir::Ty::Primitive(ir::Prim::Float), true),
477 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
478 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
479 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
480 field(
481 "tags",
482 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
483 true,
484 ),
485 field("owner", ir::Ty::Named("User".to_string()), true),
486 ],
487 }],
488 protocol: None,
489 ..Default::default()
490 };
491 let out = RustEmitter::new().emit(&schema);
492 assert!(out.contains("pub n: i64,"));
493 assert!(out.contains("pub f: f64,"));
494 assert!(out.contains("pub b: bool,"));
495 assert!(out.contains("pub at: DateTime<Utc>,"));
496 assert!(out.contains("pub blob: serde_json::Value,"));
497 assert!(out.contains("pub tags: Vec<String>,"));
498 assert!(out.contains("pub owner: User,"));
499 }
500
501 #[test]
502 fn emits_channel_request_returns_and_event_structs() {
503 let schema = ir::Schema {
504 types: vec![],
505 records: vec![],
506 relations: vec![],
507 protocol: Some(ir::Protocol {
508 name: "ping-pong".to_string(),
509 version: "2.0.0".to_string(),
510 namespace: None,
511 description: None,
512 channels: vec![ir::Channel {
513 name: "ping-pong".to_string(),
514 from: ir::ChannelFrom::Client,
515 lifetime: ir::ChannelLifetime::Persistent,
516 backend: ir::ChannelBackend::Stream,
517 channel_id: None,
518 requests: vec![ir::Request {
519 name: "Ping".to_string(),
520 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
521 returns: Some(ir::Message {
522 name: "Pong".to_string(),
523 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
524 }),
525 }],
526 events: vec![ir::Event {
527 name: "Tick".to_string(),
528 fields: vec![],
529 }],
530 }],
531 }),
532 };
533 let out = RustEmitter::new().emit(&schema);
534 assert!(out.contains("pub struct Ping {"));
535 assert!(out.contains("pub struct Pong {"));
536 assert!(out.contains("pub struct Tick;"));
537 }
538
539 #[test]
544 fn record_becomes_struct_with_id_field() {
545 let schema = ir::Schema {
546 records: vec![ir::Record {
547 name: "Atlas".to_string(),
548 description: None,
549 id_strategy: ir::IdStrategy::Uuidv7,
550 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
551 }],
552 ..Default::default()
553 };
554 let out = RustEmitter::new().emit(&schema);
555 assert!(out.contains("pub struct Atlas {"));
556 assert!(out.contains("pub id: String,"), "record gets an id field");
557 assert!(out.contains("pub name: String,"));
558 }
559
560 #[test]
561 fn relation_becomes_edge_struct_with_in_out() {
562 let schema = ir::Schema {
563 relations: vec![ir::Relation {
564 name: "derivedFrom".to_string(),
565 description: None,
566 from: "Memory".to_string(),
567 to: "Memory".to_string(),
568 unique: true,
569 fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
570 }],
571 ..Default::default()
572 };
573 let out = RustEmitter::new().emit(&schema);
574 assert!(out.contains("pub struct DerivedFrom {"));
575 assert!(out.contains("pub id: String,"));
576 assert!(out.contains("pub r#in: String,"));
578 assert!(out.contains("pub out: String,"));
579 assert!(out.contains("pub reason: Option<String>,"));
580 }
581
582 #[test]
583 fn link_field_becomes_string() {
584 let schema = ir::Schema {
585 records: vec![ir::Record {
586 name: "Atlas".to_string(),
587 description: None,
588 id_strategy: ir::IdStrategy::Uuidv7,
589 fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
590 }],
591 ..Default::default()
592 };
593 let out = RustEmitter::new().emit(&schema);
594 assert!(out.contains("pub parent: Option<String>,"));
595 }
596
597 #[test]
598 fn literal_union_degrades_to_string() {
599 let schema = ir::Schema {
600 records: vec![ir::Record {
601 name: "Doc".to_string(),
602 description: None,
603 id_strategy: ir::IdStrategy::Uuidv7,
604 fields: vec![field(
605 "visibility",
606 ir::Ty::Union(vec![
607 ir::Ty::Literal("public".to_string()),
608 ir::Ty::Literal("private".to_string()),
609 ]),
610 true,
611 )],
612 }],
613 ..Default::default()
614 };
615 let out = RustEmitter::new().emit(&schema);
616 assert!(out.contains("pub visibility: String,"));
617 }
618
619 #[test]
620 fn mixed_union_degrades_to_json_value() {
621 let schema = ir::Schema {
622 types: vec![ir::TypeDef::Struct {
623 name: "T".to_string(),
624 description: None,
625 fields: vec![field(
626 "v",
627 ir::Ty::Union(vec![
628 ir::Ty::Primitive(ir::Prim::String),
629 ir::Ty::Primitive(ir::Prim::Int),
630 ]),
631 true,
632 )],
633 }],
634 ..Default::default()
635 };
636 let out = RustEmitter::new().emit(&schema);
637 assert!(out.contains("pub v: serde_json::Value,"));
638 }
639
640 #[test]
645 fn struct_and_field_descriptions_become_doc_comments() {
646 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
647 content.description = Some("Memory content text".to_string());
648 let schema = ir::Schema {
649 types: vec![ir::TypeDef::Struct {
650 name: "Memory".to_string(),
651 description: Some("User memory".to_string()),
652 fields: vec![content],
653 }],
654 ..Default::default()
655 };
656 let out = RustEmitter::new().emit(&schema);
657 assert!(out.contains("/// User memory\n"), "struct doc comment");
658 assert!(
659 out.contains(" /// Memory content text\n"),
660 "field doc comment"
661 );
662 }
663
664 #[test]
665 fn enum_description_becomes_doc_comment() {
666 let schema = ir::Schema {
667 types: vec![ir::TypeDef::Enum {
668 name: "Role".to_string(),
669 description: Some("An access role".to_string()),
670 variants: vec!["admin".to_string()],
671 }],
672 ..Default::default()
673 };
674 let out = RustEmitter::new().emit(&schema);
675 assert!(out.contains("/// An access role\n"));
676 }
677
678 #[test]
679 fn constraints_do_not_appear_in_rust_output() {
680 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
683 f.constraints = ir::Constraints {
684 min: Some(0),
685 max: Some(1),
686 pattern: Some("x".to_string()),
687 ..Default::default()
688 };
689 let schema = ir::Schema {
690 types: vec![ir::TypeDef::Struct {
691 name: "T".to_string(),
692 description: None,
693 fields: vec![f],
694 }],
695 ..Default::default()
696 };
697 let out = RustEmitter::new().emit(&schema);
698 assert!(out.contains("pub confidence: f64,"));
699 assert!(!out.contains("minimum"), "no constraint metadata leaks");
700 }
701}