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>"`, discriminated-union enums
327/// bundling the channel's payloads are appended — one over its requests
328/// (`{Channel}Envelope`) and one over its events (`{Channel}EventEnvelope`).
329/// See [`render_envelope_enum`].
330fn 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        // Events get their own envelope: a channel can carry both directions
373        // (a `from="client"` channel whose server pushes events back), and the
374        // two sets are dispatched by different peers. Bundling them into one
375        // union would force each side to match arms it can never receive.
376        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
395/// Render an envelope `enum`: an internally `#[serde(tag = "...")]`
396/// discriminated union bundling one direction's payloads.
397///
398/// A member carrying fields becomes a newtype variant wrapping its payload
399/// struct (`ProcessToggle(ProcessToggle)`); a fieldless member becomes a unit
400/// variant (`ProcessAdd`). The unit form is required — serde rejects an
401/// internally tagged newtype variant that wraps a unit struct at runtime.
402///
403/// The variant identifier is the PascalCased member name; the original
404/// (possibly `:`-bearing) wire name is preserved with `#[serde(rename = ...)]`
405/// whenever sanitizing changed it.
406///
407/// `members` is `(wire name, payload fields)` in source order — requests and
408/// events share this shape, so both directions render through one path.
409/// `member_kind` names the direction in the doc comment (`"requests"` /
410/// `"events"`).
411fn 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        // `type` is a Rust keyword — it must be escaped as a raw identifier
494        // so the generated source compiles.
495        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    // -------------------------------------------------------------------------
642    // protocol dialect — envelope enum + identifier sanitize
643    // -------------------------------------------------------------------------
644
645    /// The sidebar-IPC spike channel: `:`-bearing request names, a fieldless
646    /// request, and an `envelope` tag.
647    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        // A `:`-bearing request name must not leak into `pub struct foo:bar`.
690        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        // Backward compatibility: an `envelope`-less channel emits only structs.
702        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        // a request with fields → newtype variant wrapping its struct.
715        assert!(out.contains("    #[serde(rename = \"process:toggle\")]"));
716        assert!(out.contains("    ProcessToggle(ProcessToggle),"));
717        // a fieldless request → unit variant (serde rejects newtype-of-unit).
718        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    /// A `from="server"` channel that carries only events — the push-only shape
727    /// (Rust → webview, pubsub fan-out). Before events were enveloped, such a
728    /// channel emitted payload structs but nothing to dispatch on.
729    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        // an event with fields → newtype variant wrapping its struct.
763        assert!(out.contains("    #[serde(rename = \"term:ensure_lane\")]"));
764        assert!(out.contains("    TermEnsureLane(TermEnsureLane),"));
765        // a fieldless event → unit variant (serde rejects newtype-of-unit).
766        assert!(out.contains("    TermClear,\n"));
767        assert!(
768            !out.contains("TermClear(TermClear)"),
769            "fieldless event must not become a newtype variant"
770        );
771        // request-side envelope is not emitted for a request-less channel.
772        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        // A `from="client"` channel whose server pushes events back (unison's
781        // pubsub shape). The two sets are dispatched by different peers, so each
782        // gets its own union rather than one mixed enum.
783        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        // the event must not leak into the request envelope.
795        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        // Backward compatibility: envelope generation stays opt-in for events too.
806        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        // A request whose name is already PascalCase carries no `#[serde(rename)]`.
817        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        // `Ping` == to_pascal_case("Ping") → no rename attribute precedes it.
826        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    // -------------------------------------------------------------------------
835    // Tier 1 — record / relation / link / union
836    // -------------------------------------------------------------------------
837
838    #[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        // `in` is a Rust keyword → escaped as a raw identifier.
872        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    // -------------------------------------------------------------------------
936    // Tier 2 — description → `///` doc comments (constraints are not emitted)
937    // -------------------------------------------------------------------------
938
939    #[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        // Rust's type system cannot express min/max/pattern — they must be
976        // dropped, not emitted as attributes or comments.
977        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}