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 {
329 let mut out = String::new();
330 for req in &channel.requests {
331 out.push('\n');
332 out.push_str(&render_struct(
333 &to_pascal_case(&req.name),
334 None,
335 &req.fields,
336 ));
337 if let Some(returns) = &req.returns {
338 out.push('\n');
339 out.push_str(&render_struct(
340 &to_pascal_case(&returns.name),
341 None,
342 &returns.fields,
343 ));
344 }
345 }
346 for evt in &channel.events {
347 out.push('\n');
348 out.push_str(&render_struct(
349 &to_pascal_case(&evt.name),
350 None,
351 &evt.fields,
352 ));
353 }
354 if let Some(tag) = &channel.envelope
355 && !channel.requests.is_empty()
356 {
357 out.push('\n');
358 out.push_str(&render_envelope_enum(channel, tag));
359 }
360 out
361}
362
363fn render_envelope_enum(channel: &ir::Channel, tag: &str) -> String {
375 let enum_name = format!("{}Envelope", to_pascal_case(&channel.name));
376 let mut out = String::new();
377 out.push_str(&format!(
378 "/// Envelope enum for channel {:?} — a discriminated union over its\n\
379 /// requests, internally tagged by the {tag:?} field.\n",
380 channel.name
381 ));
382 out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
383 out.push_str(&format!("#[serde(tag = \"{tag}\")]\n"));
384 out.push_str(&format!("pub enum {enum_name} {{\n"));
385 for req in &channel.requests {
386 let variant = to_pascal_case(&req.name);
387 if variant != req.name {
388 out.push_str(&format!(" #[serde(rename = \"{}\")]\n", req.name));
389 }
390 if req.fields.is_empty() {
391 out.push_str(&format!(" {variant},\n"));
392 } else {
393 out.push_str(&format!(" {variant}({variant}),\n"));
394 }
395 }
396 out.push_str("}\n");
397 out
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
405 ir::Field {
406 name: name.to_string(),
407 ty,
408 required,
409 flexible: false,
410 default: None,
411 description: None,
412 constraints: ir::Constraints::default(),
413 }
414 }
415
416 #[test]
417 fn emits_import_header() {
418 let out = RustEmitter::new().emit(&ir::Schema::default());
419 assert!(out.contains("use serde::{Deserialize, Serialize};"));
420 assert!(out.contains("use chrono::{DateTime, Utc};"));
421 }
422
423 #[test]
424 fn emits_struct_with_required_field() {
425 let schema = ir::Schema {
426 types: vec![ir::TypeDef::Struct {
427 name: "User".to_string(),
428 description: None,
429 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
430 }],
431 protocol: None,
432 ..Default::default()
433 };
434 let out = RustEmitter::new().emit(&schema);
435 assert!(out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
436 assert!(out.contains("pub struct User {"));
437 assert!(out.contains(" pub name: String,"));
438 }
439
440 #[test]
441 fn keyword_field_name_is_raw_identifier() {
442 let schema = ir::Schema {
443 types: vec![ir::TypeDef::Struct {
444 name: "Node".to_string(),
445 description: None,
446 fields: vec![field("type", ir::Ty::Primitive(ir::Prim::String), true)],
447 }],
448 protocol: None,
449 ..Default::default()
450 };
451 let out = RustEmitter::new().emit(&schema);
452 assert!(out.contains("pub r#type: String,"));
455 }
456
457 #[test]
458 fn optional_field_becomes_option_with_skip() {
459 let schema = ir::Schema {
460 types: vec![ir::TypeDef::Struct {
461 name: "User".to_string(),
462 description: None,
463 fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
464 }],
465 protocol: None,
466 ..Default::default()
467 };
468 let out = RustEmitter::new().emit(&schema);
469 assert!(out.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
470 assert!(out.contains("pub nick: Option<String>,"));
471 }
472
473 #[test]
474 fn non_snake_field_gets_serde_rename() {
475 let schema = ir::Schema {
476 types: vec![ir::TypeDef::Struct {
477 name: "User".to_string(),
478 description: None,
479 fields: vec![field(
480 "displayName",
481 ir::Ty::Primitive(ir::Prim::String),
482 true,
483 )],
484 }],
485 protocol: None,
486 ..Default::default()
487 };
488 let out = RustEmitter::new().emit(&schema);
489 assert!(out.contains("#[serde(rename = \"displayName\")]"));
490 assert!(out.contains("pub displayName: String,"));
491 }
492
493 #[test]
494 fn fieldless_struct_is_unit() {
495 let schema = ir::Schema {
496 types: vec![ir::TypeDef::Struct {
497 name: "Empty".to_string(),
498 description: None,
499 fields: vec![],
500 }],
501 protocol: None,
502 ..Default::default()
503 };
504 let out = RustEmitter::new().emit(&schema);
505 assert!(out.contains("pub struct Empty;"));
506 }
507
508 #[test]
509 fn emits_enum_with_rename() {
510 let schema = ir::Schema {
511 types: vec![ir::TypeDef::Enum {
512 name: "Role".to_string(),
513 description: None,
514 variants: vec!["admin".to_string(), "guest_user".to_string()],
515 }],
516 protocol: None,
517 ..Default::default()
518 };
519 let out = RustEmitter::new().emit(&schema);
520 assert!(out.contains("#[serde(rename_all = \"snake_case\")]"));
521 assert!(out.contains("pub enum Role {"));
522 assert!(out.contains("#[serde(rename = \"admin\")]"));
523 assert!(out.contains(" Admin,"));
524 assert!(out.contains("#[serde(rename = \"guest_user\")]"));
525 assert!(out.contains(" GuestUser,"));
526 }
527
528 #[test]
529 fn maps_primitive_and_compound_types() {
530 let schema = ir::Schema {
531 types: vec![ir::TypeDef::Struct {
532 name: "T".to_string(),
533 description: None,
534 fields: vec![
535 field("n", ir::Ty::Primitive(ir::Prim::Int), true),
536 field("f", ir::Ty::Primitive(ir::Prim::Float), true),
537 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
538 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
539 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
540 field(
541 "tags",
542 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
543 true,
544 ),
545 field("owner", ir::Ty::Named("User".to_string()), true),
546 ],
547 }],
548 protocol: None,
549 ..Default::default()
550 };
551 let out = RustEmitter::new().emit(&schema);
552 assert!(out.contains("pub n: i64,"));
553 assert!(out.contains("pub f: f64,"));
554 assert!(out.contains("pub b: bool,"));
555 assert!(out.contains("pub at: DateTime<Utc>,"));
556 assert!(out.contains("pub blob: serde_json::Value,"));
557 assert!(out.contains("pub tags: Vec<String>,"));
558 assert!(out.contains("pub owner: User,"));
559 }
560
561 #[test]
562 fn emits_channel_request_returns_and_event_structs() {
563 let schema = ir::Schema {
564 types: vec![],
565 records: vec![],
566 relations: vec![],
567 protocol: Some(ir::Protocol {
568 name: "ping-pong".to_string(),
569 version: "2.0.0".to_string(),
570 namespace: None,
571 description: None,
572 channels: vec![ir::Channel {
573 name: "ping-pong".to_string(),
574 from: ir::ChannelFrom::Client,
575 lifetime: ir::ChannelLifetime::Persistent,
576 backend: ir::ChannelBackend::Stream,
577 channel_id: None,
578 envelope: None,
579 requests: vec![ir::Request {
580 name: "Ping".to_string(),
581 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
582 returns: Some(ir::Message {
583 name: "Pong".to_string(),
584 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
585 }),
586 }],
587 events: vec![ir::Event {
588 name: "Tick".to_string(),
589 fields: vec![],
590 }],
591 }],
592 }),
593 };
594 let out = RustEmitter::new().emit(&schema);
595 assert!(out.contains("pub struct Ping {"));
596 assert!(out.contains("pub struct Pong {"));
597 assert!(out.contains("pub struct Tick;"));
598 }
599
600 fn sidebar_channel(envelope: Option<&str>) -> ir::Channel {
607 ir::Channel {
608 name: "ipc".to_string(),
609 from: ir::ChannelFrom::Client,
610 lifetime: ir::ChannelLifetime::Transient,
611 backend: ir::ChannelBackend::Stream,
612 channel_id: None,
613 envelope: envelope.map(str::to_string),
614 requests: vec![
615 ir::Request {
616 name: "process:toggle".to_string(),
617 fields: vec![
618 field("path", ir::Ty::Primitive(ir::Prim::String), true),
619 field("expanded", ir::Ty::Primitive(ir::Prim::Bool), true),
620 ],
621 returns: None,
622 },
623 ir::Request {
624 name: "process:add".to_string(),
625 fields: vec![],
626 returns: None,
627 },
628 ],
629 events: vec![],
630 }
631 }
632
633 fn protocol_schema(channel: ir::Channel) -> ir::Schema {
634 ir::Schema {
635 protocol: Some(ir::Protocol {
636 name: "sidebar".to_string(),
637 version: "1.0.0".to_string(),
638 namespace: None,
639 description: None,
640 channels: vec![channel],
641 }),
642 ..Default::default()
643 }
644 }
645
646 #[test]
647 fn channel_request_names_are_sanitized_to_valid_identifiers() {
648 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
650 assert!(out.contains("pub struct ProcessToggle {"));
651 assert!(out.contains("pub struct ProcessAdd;"));
652 assert!(
653 !out.contains("process:toggle"),
654 "raw `:` name must not leak"
655 );
656 }
657
658 #[test]
659 fn channel_without_envelope_emits_no_enum() {
660 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
662 assert!(!out.contains("pub enum"), "no envelope ⇒ no enum");
663 }
664
665 #[test]
666 fn envelope_channel_emits_internally_tagged_enum() {
667 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(Some("t"))));
668 assert!(out.contains("#[serde(tag = \"t\")]"), "internally tagged");
669 assert!(
670 out.contains("pub enum IpcEnvelope {"),
671 "enum named <Channel>Envelope"
672 );
673 assert!(out.contains(" #[serde(rename = \"process:toggle\")]"));
675 assert!(out.contains(" ProcessToggle(ProcessToggle),"));
676 assert!(out.contains(" #[serde(rename = \"process:add\")]"));
678 assert!(out.contains(" ProcessAdd,\n"));
679 assert!(
680 !out.contains("ProcessAdd(ProcessAdd)"),
681 "fieldless request must not become a newtype variant"
682 );
683 }
684
685 #[test]
686 fn envelope_variant_without_colon_name_needs_no_rename() {
687 let mut channel = sidebar_channel(Some("t"));
689 channel.requests = vec![ir::Request {
690 name: "Ping".to_string(),
691 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
692 returns: None,
693 }];
694 let out = RustEmitter::new().emit(&protocol_schema(channel));
695 assert!(out.contains(" Ping(Ping),"));
696 let variant_line = out.find(" Ping(Ping),").unwrap();
698 let preceding = &out[..variant_line];
699 assert!(
700 !preceding.trim_end().ends_with("rename = \"Ping\")]"),
701 "an already-PascalCase name needs no rename"
702 );
703 }
704
705 #[test]
710 fn record_becomes_struct_with_id_field() {
711 let schema = ir::Schema {
712 records: vec![ir::Record {
713 name: "Atlas".to_string(),
714 description: None,
715 id_strategy: ir::IdStrategy::Uuidv7,
716 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
717 }],
718 ..Default::default()
719 };
720 let out = RustEmitter::new().emit(&schema);
721 assert!(out.contains("pub struct Atlas {"));
722 assert!(out.contains("pub id: String,"), "record gets an id field");
723 assert!(out.contains("pub name: String,"));
724 }
725
726 #[test]
727 fn relation_becomes_edge_struct_with_in_out() {
728 let schema = ir::Schema {
729 relations: vec![ir::Relation {
730 name: "derivedFrom".to_string(),
731 description: None,
732 from: "Memory".to_string(),
733 to: "Memory".to_string(),
734 unique: true,
735 fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
736 }],
737 ..Default::default()
738 };
739 let out = RustEmitter::new().emit(&schema);
740 assert!(out.contains("pub struct DerivedFrom {"));
741 assert!(out.contains("pub id: String,"));
742 assert!(out.contains("pub r#in: String,"));
744 assert!(out.contains("pub out: String,"));
745 assert!(out.contains("pub reason: Option<String>,"));
746 }
747
748 #[test]
749 fn link_field_becomes_string() {
750 let schema = ir::Schema {
751 records: vec![ir::Record {
752 name: "Atlas".to_string(),
753 description: None,
754 id_strategy: ir::IdStrategy::Uuidv7,
755 fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
756 }],
757 ..Default::default()
758 };
759 let out = RustEmitter::new().emit(&schema);
760 assert!(out.contains("pub parent: Option<String>,"));
761 }
762
763 #[test]
764 fn literal_union_degrades_to_string() {
765 let schema = ir::Schema {
766 records: vec![ir::Record {
767 name: "Doc".to_string(),
768 description: None,
769 id_strategy: ir::IdStrategy::Uuidv7,
770 fields: vec![field(
771 "visibility",
772 ir::Ty::Union(vec![
773 ir::Ty::Literal("public".to_string()),
774 ir::Ty::Literal("private".to_string()),
775 ]),
776 true,
777 )],
778 }],
779 ..Default::default()
780 };
781 let out = RustEmitter::new().emit(&schema);
782 assert!(out.contains("pub visibility: String,"));
783 }
784
785 #[test]
786 fn mixed_union_degrades_to_json_value() {
787 let schema = ir::Schema {
788 types: vec![ir::TypeDef::Struct {
789 name: "T".to_string(),
790 description: None,
791 fields: vec![field(
792 "v",
793 ir::Ty::Union(vec![
794 ir::Ty::Primitive(ir::Prim::String),
795 ir::Ty::Primitive(ir::Prim::Int),
796 ]),
797 true,
798 )],
799 }],
800 ..Default::default()
801 };
802 let out = RustEmitter::new().emit(&schema);
803 assert!(out.contains("pub v: serde_json::Value,"));
804 }
805
806 #[test]
811 fn struct_and_field_descriptions_become_doc_comments() {
812 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
813 content.description = Some("Memory content text".to_string());
814 let schema = ir::Schema {
815 types: vec![ir::TypeDef::Struct {
816 name: "Memory".to_string(),
817 description: Some("User memory".to_string()),
818 fields: vec![content],
819 }],
820 ..Default::default()
821 };
822 let out = RustEmitter::new().emit(&schema);
823 assert!(out.contains("/// User memory\n"), "struct doc comment");
824 assert!(
825 out.contains(" /// Memory content text\n"),
826 "field doc comment"
827 );
828 }
829
830 #[test]
831 fn enum_description_becomes_doc_comment() {
832 let schema = ir::Schema {
833 types: vec![ir::TypeDef::Enum {
834 name: "Role".to_string(),
835 description: Some("An access role".to_string()),
836 variants: vec!["admin".to_string()],
837 }],
838 ..Default::default()
839 };
840 let out = RustEmitter::new().emit(&schema);
841 assert!(out.contains("/// An access role\n"));
842 }
843
844 #[test]
845 fn constraints_do_not_appear_in_rust_output() {
846 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
849 f.constraints = ir::Constraints {
850 min: Some(0),
851 max: Some(1),
852 pattern: Some("x".to_string()),
853 ..Default::default()
854 };
855 let schema = ir::Schema {
856 types: vec![ir::TypeDef::Struct {
857 name: "T".to_string(),
858 description: None,
859 fields: vec![f],
860 }],
861 ..Default::default()
862 };
863 let out = RustEmitter::new().emit(&schema);
864 assert!(out.contains("pub confidence: f64,"));
865 assert!(!out.contains("minimum"), "no constraint metadata leaks");
866 }
867}