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 {
331 let mut out = String::new();
332 for req in &channel.requests {
333 out.push('\n');
334 out.push_str(&render_struct(
335 &to_pascal_case(&req.name),
336 None,
337 &req.fields,
338 ));
339 if let Some(returns) = &req.returns {
340 out.push('\n');
341 out.push_str(&render_struct(
342 &to_pascal_case(&returns.name),
343 None,
344 &returns.fields,
345 ));
346 }
347 }
348 for evt in &channel.events {
349 out.push('\n');
350 out.push_str(&render_struct(
351 &to_pascal_case(&evt.name),
352 None,
353 &evt.fields,
354 ));
355 }
356 if let Some(tag) = &channel.envelope {
357 if !channel.requests.is_empty() {
358 let members: Vec<(&str, &[ir::Field])> = channel
359 .requests
360 .iter()
361 .map(|req| (req.name.as_str(), req.fields.as_slice()))
362 .collect();
363 out.push('\n');
364 out.push_str(&render_envelope_enum(
365 &channel.name,
366 tag,
367 &format!("{}Envelope", to_pascal_case(&channel.name)),
368 "requests",
369 &members,
370 ));
371 }
372 if !channel.events.is_empty() {
377 let members: Vec<(&str, &[ir::Field])> = channel
378 .events
379 .iter()
380 .map(|evt| (evt.name.as_str(), evt.fields.as_slice()))
381 .collect();
382 out.push('\n');
383 out.push_str(&render_envelope_enum(
384 &channel.name,
385 tag,
386 &format!("{}EventEnvelope", to_pascal_case(&channel.name)),
387 "events",
388 &members,
389 ));
390 }
391 }
392 out
393}
394
395fn render_envelope_enum(
412 channel_name: &str,
413 tag: &str,
414 enum_name: &str,
415 member_kind: &str,
416 members: &[(&str, &[ir::Field])],
417) -> String {
418 let mut out = String::new();
419 out.push_str(&format!(
420 "/// Envelope enum for channel {channel_name:?} — a discriminated union over its\n\
421 /// {member_kind}, internally tagged by the {tag:?} field.\n"
422 ));
423 out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
424 out.push_str(&format!("#[serde(tag = \"{tag}\")]\n"));
425 out.push_str(&format!("pub enum {enum_name} {{\n"));
426 for (name, fields) in members {
427 let variant = to_pascal_case(name);
428 if variant != *name {
429 out.push_str(&format!(" #[serde(rename = \"{name}\")]\n"));
430 }
431 if fields.is_empty() {
432 out.push_str(&format!(" {variant},\n"));
433 } else {
434 out.push_str(&format!(" {variant}({variant}),\n"));
435 }
436 }
437 out.push_str("}\n");
438 out
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
446 ir::Field {
447 name: name.to_string(),
448 ty,
449 required,
450 flexible: false,
451 default: None,
452 description: None,
453 constraints: ir::Constraints::default(),
454 }
455 }
456
457 #[test]
458 fn emits_import_header() {
459 let out = RustEmitter::new().emit(&ir::Schema::default());
460 assert!(out.contains("use serde::{Deserialize, Serialize};"));
461 assert!(out.contains("use chrono::{DateTime, Utc};"));
462 }
463
464 #[test]
465 fn emits_struct_with_required_field() {
466 let schema = ir::Schema {
467 types: vec![ir::TypeDef::Struct {
468 name: "User".to_string(),
469 description: None,
470 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
471 }],
472 protocol: None,
473 ..Default::default()
474 };
475 let out = RustEmitter::new().emit(&schema);
476 assert!(out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"));
477 assert!(out.contains("pub struct User {"));
478 assert!(out.contains(" pub name: String,"));
479 }
480
481 #[test]
482 fn keyword_field_name_is_raw_identifier() {
483 let schema = ir::Schema {
484 types: vec![ir::TypeDef::Struct {
485 name: "Node".to_string(),
486 description: None,
487 fields: vec![field("type", ir::Ty::Primitive(ir::Prim::String), true)],
488 }],
489 protocol: None,
490 ..Default::default()
491 };
492 let out = RustEmitter::new().emit(&schema);
493 assert!(out.contains("pub r#type: String,"));
496 }
497
498 #[test]
499 fn optional_field_becomes_option_with_skip() {
500 let schema = ir::Schema {
501 types: vec![ir::TypeDef::Struct {
502 name: "User".to_string(),
503 description: None,
504 fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
505 }],
506 protocol: None,
507 ..Default::default()
508 };
509 let out = RustEmitter::new().emit(&schema);
510 assert!(out.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
511 assert!(out.contains("pub nick: Option<String>,"));
512 }
513
514 #[test]
515 fn non_snake_field_gets_serde_rename() {
516 let schema = ir::Schema {
517 types: vec![ir::TypeDef::Struct {
518 name: "User".to_string(),
519 description: None,
520 fields: vec![field(
521 "displayName",
522 ir::Ty::Primitive(ir::Prim::String),
523 true,
524 )],
525 }],
526 protocol: None,
527 ..Default::default()
528 };
529 let out = RustEmitter::new().emit(&schema);
530 assert!(out.contains("#[serde(rename = \"displayName\")]"));
531 assert!(out.contains("pub displayName: String,"));
532 }
533
534 #[test]
535 fn fieldless_struct_is_unit() {
536 let schema = ir::Schema {
537 types: vec![ir::TypeDef::Struct {
538 name: "Empty".to_string(),
539 description: None,
540 fields: vec![],
541 }],
542 protocol: None,
543 ..Default::default()
544 };
545 let out = RustEmitter::new().emit(&schema);
546 assert!(out.contains("pub struct Empty;"));
547 }
548
549 #[test]
550 fn emits_enum_with_rename() {
551 let schema = ir::Schema {
552 types: vec![ir::TypeDef::Enum {
553 name: "Role".to_string(),
554 description: None,
555 variants: vec!["admin".to_string(), "guest_user".to_string()],
556 }],
557 protocol: None,
558 ..Default::default()
559 };
560 let out = RustEmitter::new().emit(&schema);
561 assert!(out.contains("#[serde(rename_all = \"snake_case\")]"));
562 assert!(out.contains("pub enum Role {"));
563 assert!(out.contains("#[serde(rename = \"admin\")]"));
564 assert!(out.contains(" Admin,"));
565 assert!(out.contains("#[serde(rename = \"guest_user\")]"));
566 assert!(out.contains(" GuestUser,"));
567 }
568
569 #[test]
570 fn maps_primitive_and_compound_types() {
571 let schema = ir::Schema {
572 types: vec![ir::TypeDef::Struct {
573 name: "T".to_string(),
574 description: None,
575 fields: vec![
576 field("n", ir::Ty::Primitive(ir::Prim::Int), true),
577 field("f", ir::Ty::Primitive(ir::Prim::Float), true),
578 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
579 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
580 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
581 field(
582 "tags",
583 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
584 true,
585 ),
586 field("owner", ir::Ty::Named("User".to_string()), true),
587 ],
588 }],
589 protocol: None,
590 ..Default::default()
591 };
592 let out = RustEmitter::new().emit(&schema);
593 assert!(out.contains("pub n: i64,"));
594 assert!(out.contains("pub f: f64,"));
595 assert!(out.contains("pub b: bool,"));
596 assert!(out.contains("pub at: DateTime<Utc>,"));
597 assert!(out.contains("pub blob: serde_json::Value,"));
598 assert!(out.contains("pub tags: Vec<String>,"));
599 assert!(out.contains("pub owner: User,"));
600 }
601
602 #[test]
603 fn emits_channel_request_returns_and_event_structs() {
604 let schema = ir::Schema {
605 types: vec![],
606 records: vec![],
607 relations: vec![],
608 protocol: Some(ir::Protocol {
609 name: "ping-pong".to_string(),
610 version: "2.0.0".to_string(),
611 namespace: None,
612 description: None,
613 channels: vec![ir::Channel {
614 name: "ping-pong".to_string(),
615 from: ir::ChannelFrom::Client,
616 lifetime: ir::ChannelLifetime::Persistent,
617 backend: ir::ChannelBackend::Stream,
618 channel_id: None,
619 envelope: None,
620 requests: vec![ir::Request {
621 name: "Ping".to_string(),
622 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
623 returns: Some(ir::Message {
624 name: "Pong".to_string(),
625 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
626 }),
627 }],
628 events: vec![ir::Event {
629 name: "Tick".to_string(),
630 fields: vec![],
631 }],
632 }],
633 }),
634 };
635 let out = RustEmitter::new().emit(&schema);
636 assert!(out.contains("pub struct Ping {"));
637 assert!(out.contains("pub struct Pong {"));
638 assert!(out.contains("pub struct Tick;"));
639 }
640
641 fn sidebar_channel(envelope: Option<&str>) -> ir::Channel {
648 ir::Channel {
649 name: "ipc".to_string(),
650 from: ir::ChannelFrom::Client,
651 lifetime: ir::ChannelLifetime::Transient,
652 backend: ir::ChannelBackend::Stream,
653 channel_id: None,
654 envelope: envelope.map(str::to_string),
655 requests: vec![
656 ir::Request {
657 name: "process:toggle".to_string(),
658 fields: vec![
659 field("path", ir::Ty::Primitive(ir::Prim::String), true),
660 field("expanded", ir::Ty::Primitive(ir::Prim::Bool), true),
661 ],
662 returns: None,
663 },
664 ir::Request {
665 name: "process:add".to_string(),
666 fields: vec![],
667 returns: None,
668 },
669 ],
670 events: vec![],
671 }
672 }
673
674 fn protocol_schema(channel: ir::Channel) -> ir::Schema {
675 ir::Schema {
676 protocol: Some(ir::Protocol {
677 name: "sidebar".to_string(),
678 version: "1.0.0".to_string(),
679 namespace: None,
680 description: None,
681 channels: vec![channel],
682 }),
683 ..Default::default()
684 }
685 }
686
687 #[test]
688 fn channel_request_names_are_sanitized_to_valid_identifiers() {
689 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
691 assert!(out.contains("pub struct ProcessToggle {"));
692 assert!(out.contains("pub struct ProcessAdd;"));
693 assert!(
694 !out.contains("process:toggle"),
695 "raw `:` name must not leak"
696 );
697 }
698
699 #[test]
700 fn channel_without_envelope_emits_no_enum() {
701 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(None)));
703 assert!(!out.contains("pub enum"), "no envelope ⇒ no enum");
704 }
705
706 #[test]
707 fn envelope_channel_emits_internally_tagged_enum() {
708 let out = RustEmitter::new().emit(&protocol_schema(sidebar_channel(Some("t"))));
709 assert!(out.contains("#[serde(tag = \"t\")]"), "internally tagged");
710 assert!(
711 out.contains("pub enum IpcEnvelope {"),
712 "enum named <Channel>Envelope"
713 );
714 assert!(out.contains(" #[serde(rename = \"process:toggle\")]"));
716 assert!(out.contains(" ProcessToggle(ProcessToggle),"));
717 assert!(out.contains(" #[serde(rename = \"process:add\")]"));
719 assert!(out.contains(" ProcessAdd,\n"));
720 assert!(
721 !out.contains("ProcessAdd(ProcessAdd)"),
722 "fieldless request must not become a newtype variant"
723 );
724 }
725
726 fn push_channel(envelope: Option<&str>) -> ir::Channel {
730 ir::Channel {
731 name: "push".to_string(),
732 from: ir::ChannelFrom::Server,
733 lifetime: ir::ChannelLifetime::Persistent,
734 backend: ir::ChannelBackend::Stream,
735 channel_id: None,
736 envelope: envelope.map(str::to_string),
737 requests: vec![],
738 events: vec![
739 ir::Event {
740 name: "term:ensure_lane".to_string(),
741 fields: vec![
742 field("lane", ir::Ty::Primitive(ir::Prim::String), true),
743 field("session", ir::Ty::Primitive(ir::Prim::Int), true),
744 ],
745 },
746 ir::Event {
747 name: "term:clear".to_string(),
748 fields: vec![],
749 },
750 ],
751 }
752 }
753
754 #[test]
755 fn events_only_channel_emits_event_envelope() {
756 let out = RustEmitter::new().emit(&protocol_schema(push_channel(Some("t"))));
757 assert!(out.contains("#[serde(tag = \"t\")]"), "internally tagged");
758 assert!(
759 out.contains("pub enum PushEventEnvelope {"),
760 "enum named <Channel>EventEnvelope"
761 );
762 assert!(out.contains(" #[serde(rename = \"term:ensure_lane\")]"));
764 assert!(out.contains(" TermEnsureLane(TermEnsureLane),"));
765 assert!(out.contains(" TermClear,\n"));
767 assert!(
768 !out.contains("TermClear(TermClear)"),
769 "fieldless event must not become a newtype variant"
770 );
771 assert!(
773 !out.contains("pub enum PushEnvelope {"),
774 "no requests ⇒ no request envelope"
775 );
776 }
777
778 #[test]
779 fn channel_with_both_directions_emits_two_envelopes() {
780 let mut channel = sidebar_channel(Some("t"));
784 channel.events = vec![ir::Event {
785 name: "topic:event".to_string(),
786 fields: vec![field("body", ir::Ty::Primitive(ir::Prim::String), true)],
787 }];
788 let out = RustEmitter::new().emit(&protocol_schema(channel));
789 assert!(out.contains("pub enum IpcEnvelope {"), "requests envelope");
790 assert!(
791 out.contains("pub enum IpcEventEnvelope {"),
792 "events envelope"
793 );
794 let req_enum = out.split("pub enum IpcEnvelope {").nth(1).unwrap();
796 let req_body = req_enum.split("}\n").next().unwrap();
797 assert!(
798 !req_body.contains("TopicEvent"),
799 "event leaked into the request envelope"
800 );
801 }
802
803 #[test]
804 fn events_without_envelope_tag_emit_no_enum() {
805 let out = RustEmitter::new().emit(&protocol_schema(push_channel(None)));
807 assert!(!out.contains("pub enum"), "no envelope tag ⇒ no enum");
808 assert!(
809 out.contains("pub struct TermEnsureLane"),
810 "structs still emit"
811 );
812 }
813
814 #[test]
815 fn envelope_variant_without_colon_name_needs_no_rename() {
816 let mut channel = sidebar_channel(Some("t"));
818 channel.requests = vec![ir::Request {
819 name: "Ping".to_string(),
820 fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
821 returns: None,
822 }];
823 let out = RustEmitter::new().emit(&protocol_schema(channel));
824 assert!(out.contains(" Ping(Ping),"));
825 let variant_line = out.find(" Ping(Ping),").unwrap();
827 let preceding = &out[..variant_line];
828 assert!(
829 !preceding.trim_end().ends_with("rename = \"Ping\")]"),
830 "an already-PascalCase name needs no rename"
831 );
832 }
833
834 #[test]
839 fn record_becomes_struct_with_id_field() {
840 let schema = ir::Schema {
841 records: vec![ir::Record {
842 name: "Atlas".to_string(),
843 description: None,
844 id_strategy: ir::IdStrategy::Uuidv7,
845 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
846 }],
847 ..Default::default()
848 };
849 let out = RustEmitter::new().emit(&schema);
850 assert!(out.contains("pub struct Atlas {"));
851 assert!(out.contains("pub id: String,"), "record gets an id field");
852 assert!(out.contains("pub name: String,"));
853 }
854
855 #[test]
856 fn relation_becomes_edge_struct_with_in_out() {
857 let schema = ir::Schema {
858 relations: vec![ir::Relation {
859 name: "derivedFrom".to_string(),
860 description: None,
861 from: "Memory".to_string(),
862 to: "Memory".to_string(),
863 unique: true,
864 fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
865 }],
866 ..Default::default()
867 };
868 let out = RustEmitter::new().emit(&schema);
869 assert!(out.contains("pub struct DerivedFrom {"));
870 assert!(out.contains("pub id: String,"));
871 assert!(out.contains("pub r#in: String,"));
873 assert!(out.contains("pub out: String,"));
874 assert!(out.contains("pub reason: Option<String>,"));
875 }
876
877 #[test]
878 fn link_field_becomes_string() {
879 let schema = ir::Schema {
880 records: vec![ir::Record {
881 name: "Atlas".to_string(),
882 description: None,
883 id_strategy: ir::IdStrategy::Uuidv7,
884 fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
885 }],
886 ..Default::default()
887 };
888 let out = RustEmitter::new().emit(&schema);
889 assert!(out.contains("pub parent: Option<String>,"));
890 }
891
892 #[test]
893 fn literal_union_degrades_to_string() {
894 let schema = ir::Schema {
895 records: vec![ir::Record {
896 name: "Doc".to_string(),
897 description: None,
898 id_strategy: ir::IdStrategy::Uuidv7,
899 fields: vec![field(
900 "visibility",
901 ir::Ty::Union(vec![
902 ir::Ty::Literal("public".to_string()),
903 ir::Ty::Literal("private".to_string()),
904 ]),
905 true,
906 )],
907 }],
908 ..Default::default()
909 };
910 let out = RustEmitter::new().emit(&schema);
911 assert!(out.contains("pub visibility: String,"));
912 }
913
914 #[test]
915 fn mixed_union_degrades_to_json_value() {
916 let schema = ir::Schema {
917 types: vec![ir::TypeDef::Struct {
918 name: "T".to_string(),
919 description: None,
920 fields: vec![field(
921 "v",
922 ir::Ty::Union(vec![
923 ir::Ty::Primitive(ir::Prim::String),
924 ir::Ty::Primitive(ir::Prim::Int),
925 ]),
926 true,
927 )],
928 }],
929 ..Default::default()
930 };
931 let out = RustEmitter::new().emit(&schema);
932 assert!(out.contains("pub v: serde_json::Value,"));
933 }
934
935 #[test]
940 fn struct_and_field_descriptions_become_doc_comments() {
941 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
942 content.description = Some("Memory content text".to_string());
943 let schema = ir::Schema {
944 types: vec![ir::TypeDef::Struct {
945 name: "Memory".to_string(),
946 description: Some("User memory".to_string()),
947 fields: vec![content],
948 }],
949 ..Default::default()
950 };
951 let out = RustEmitter::new().emit(&schema);
952 assert!(out.contains("/// User memory\n"), "struct doc comment");
953 assert!(
954 out.contains(" /// Memory content text\n"),
955 "field doc comment"
956 );
957 }
958
959 #[test]
960 fn enum_description_becomes_doc_comment() {
961 let schema = ir::Schema {
962 types: vec![ir::TypeDef::Enum {
963 name: "Role".to_string(),
964 description: Some("An access role".to_string()),
965 variants: vec!["admin".to_string()],
966 }],
967 ..Default::default()
968 };
969 let out = RustEmitter::new().emit(&schema);
970 assert!(out.contains("/// An access role\n"));
971 }
972
973 #[test]
974 fn constraints_do_not_appear_in_rust_output() {
975 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
978 f.constraints = ir::Constraints {
979 min: Some(0),
980 max: Some(1),
981 pattern: Some("x".to_string()),
982 ..Default::default()
983 };
984 let schema = ir::Schema {
985 types: vec![ir::TypeDef::Struct {
986 name: "T".to_string(),
987 description: None,
988 fields: vec![f],
989 }],
990 ..Default::default()
991 };
992 let out = RustEmitter::new().emit(&schema);
993 assert!(out.contains("pub confidence: f64,"));
994 assert!(!out.contains("minimum"), "no constraint metadata leaks");
995 }
996}