Skip to main content

kdl_codegen/emit/
rust.rs

1//! Rust emitter — renders [`ir::Schema`] into Rust source text.
2//!
3//! Ported from club-unison's `codegen/rust.rs`. The original used
4//! `proc_macro2` + `quote` to build a `TokenStream` and a hand-rolled
5//! `format_code` pass; this port writes pre-formatted Rust text directly so
6//! `club-kdl-codegen` stays dependency-free during Phase 1.
7//!
8//! ## What it emits
9//!
10//! - data dialect: every [`ir::TypeDef`] — `struct` (with fields) and `enum`
11//!   (string-valued variants).
12//! - entity dialect: every [`ir::Record`] as a `struct` carrying an `id`
13//!   field; every [`ir::Relation`] as an edge `struct` carrying `id` / `in` /
14//!   `out` fields plus its edge properties.
15//! - protocol dialect: for every [`ir::Channel`], a `struct` per request
16//!   payload, per `returns` message, and per event payload.
17//!
18//! ## Tier 1 type mapping
19//!
20//! - `link<Record>` → `String` (the linked record's id).
21//! - `'literal'` and unions of literals → a generated string-valued `enum`
22//!   is *not* produced (no schema name is available at the field site);
23//!   instead the field type degrades to `String`. A union of non-literal
24//!   types also degrades to `serde_json::Value` — Rust has no anonymous sum
25//!   type, and inventing names per field is out of Tier 1 scope.
26//!
27//! Each generated `struct` / `enum` carries `#[derive(...)]` attributes and
28//! `serde` annotations matching club-unison's generator. Optional fields
29//! become `Option<T>` with `#[serde(skip_serializing_if = "Option::is_none")]`.
30//!
31//! ## Differences from club-unison (IR-driven port)
32//!
33//! - The IR has no inline `_inline_*` messages and no `service` / `method` /
34//!   `stream` / `send` / `recv` legacy constructs — so the corresponding
35//!   branches are dropped.
36//! - The IR's [`ir::Prim::Datetime`] maps to `chrono::DateTime<Utc>`; named
37//!   type references emit the bare identifier (no `TypeRegistry` indirection).
38//!
39//! ## Tier 2 — description / constraints
40//!
41//! - A `description` on a `struct` / `enum` / `record` / `relation` or a
42//!   field becomes a `///` doc comment.
43//! - Field `constraints` (`min` / `max` / `min_length` / `max_length` /
44//!   `pattern`) are **not** emitted — Rust's type system cannot express them,
45//!   and JSDoc-style `@minimum` hacks are deliberately avoided.
46
47use crate::Emitter;
48use crate::ir;
49
50use super::case::{to_pascal_case, to_snake_case};
51
52/// The Rust code generation target.
53#[derive(Debug, Default, Clone, Copy)]
54pub struct RustEmitter;
55
56impl RustEmitter {
57    /// Create a new [`RustEmitter`].
58    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        // data dialect — standalone type definitions.
69        for ty in &schema.types {
70            out.push('\n');
71            out.push_str(&render_typedef(ty));
72        }
73
74        // entity dialect — records and relations.
75        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        // protocol dialect — channel payload structs.
85        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
95/// Header import block, matching club-unison's `generate_imports`.
96const IMPORTS: &str = "\
97use serde::{Deserialize, Serialize};
98use anyhow::Result;
99use chrono::{DateTime, Utc};
100use uuid::Uuid;
101use std::collections::HashMap;
102";
103
104/// Render one standalone [`ir::TypeDef`].
105fn 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
120/// Render a `///` doc comment block at the given indentation from an optional
121/// description. Each line of a multi-line description gets its own `///`.
122fn 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
132/// Render a `struct` from a name and field list. A fieldless struct becomes a
133/// unit struct (`pub struct Name;`), matching club-unison.
134fn 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
151/// Render an `enum` of string-valued variants.
152fn 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
166/// Render a single struct field with its `serde` attributes.
167fn render_field(field: &ir::Field) -> String {
168    let mut out = String::new();
169
170    // `///` doc comment from the field description.
171    out.push_str(&render_doc(field.description.as_deref(), "    "));
172
173    // `#[serde(rename = "...")]` when the source name is not snake_case.
174    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
194/// Render a field name as a valid Rust identifier. A name that collides with
195/// a Rust keyword is escaped as a raw identifier (`r#type`) so the generated
196/// source compiles. `serde` strips the `r#` prefix, so the wire name is
197/// unaffected.
198///
199/// `crate` / `self` / `Self` / `super` cannot be raw identifiers; they are
200/// left as-is (a KDL schema field is extremely unlikely to use them).
201fn 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
216/// Map an [`ir::Ty`] to its Rust type expression.
217fn 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        // a link stores the target record's id — a plain string.
223        ir::Ty::Link(_) => "String".to_string(),
224        // a literal degrades to String (no per-field type name available).
225        ir::Ty::Literal(_) => "String".to_string(),
226        // a union of string literals stays a String. Otherwise, if every
227        // member maps to the same Rust type, collapse to it (`link<X> |
228        // string` → `String`); a genuinely heterogeneous union has no Rust
229        // anonymous-sum representation, so it degrades to a JSON value.
230        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
251/// Map an [`ir::Prim`] to its Rust type.
252fn 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
263/// Render one [`ir::Record`] as a `struct`. The record's `id` becomes a
264/// leading `String` field; the remaining fields follow in source order. The
265/// struct name is PascalCased so a camelCase record name still compiles.
266fn 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
277/// Render one [`ir::Relation`] as an edge `struct` carrying `id` / `in` /
278/// `out` (the edge endpoints, as record ids) plus its edge-property fields.
279fn 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
308/// The synthetic `id: String` field shared by records and relations.
309fn 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
321/// Render every payload struct for one channel: request payloads, `returns`
322/// messages, and event payloads. Payload names are PascalCased so a wire-style
323/// schema name (`process:toggle`) becomes a valid Rust identifier
324/// (`ProcessToggle`).
325///
326/// When the channel declares `envelope="<tag>"`, a discriminated-union enum
327/// bundling every request payload is appended (see [`render_envelope_enum`]).
328fn 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
363/// Render the channel's envelope `enum`: an internally `#[serde(tag = "...")]`
364/// discriminated union bundling every request payload.
365///
366/// A request carrying fields becomes a newtype variant wrapping its payload
367/// struct (`ProcessToggle(ProcessToggle)`); a fieldless request becomes a unit
368/// variant (`ProcessAdd`). The unit form is required — serde rejects an
369/// internally tagged newtype variant that wraps a unit struct at runtime.
370///
371/// The variant identifier is the PascalCased request name; the original
372/// (possibly `:`-bearing) wire name is preserved with `#[serde(rename = ...)]`
373/// whenever sanitizing changed it.
374fn 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        // `type` is a Rust keyword — it must be escaped as a raw identifier
453        // so the generated source compiles.
454        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    // -------------------------------------------------------------------------
601    // protocol dialect — envelope enum + identifier sanitize
602    // -------------------------------------------------------------------------
603
604    /// The sidebar-IPC spike channel: `:`-bearing request names, a fieldless
605    /// request, and an `envelope` tag.
606    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        // A `:`-bearing request name must not leak into `pub struct foo:bar`.
649        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        // Backward compatibility: an `envelope`-less channel emits only structs.
661        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        // a request with fields → newtype variant wrapping its struct.
674        assert!(out.contains("    #[serde(rename = \"process:toggle\")]"));
675        assert!(out.contains("    ProcessToggle(ProcessToggle),"));
676        // a fieldless request → unit variant (serde rejects newtype-of-unit).
677        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        // A request whose name is already PascalCase carries no `#[serde(rename)]`.
688        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        // `Ping` == to_pascal_case("Ping") → no rename attribute precedes it.
697        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    // -------------------------------------------------------------------------
706    // Tier 1 — record / relation / link / union
707    // -------------------------------------------------------------------------
708
709    #[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        // `in` is a Rust keyword → escaped as a raw identifier.
743        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    // -------------------------------------------------------------------------
807    // Tier 2 — description → `///` doc comments (constraints are not emitted)
808    // -------------------------------------------------------------------------
809
810    #[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        // Rust's type system cannot express min/max/pattern — they must be
847        // dropped, not emitted as attributes or comments.
848        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}