Skip to main content

kdl_codegen/emit/
typescript.rs

1//! TypeScript emitter — renders [`ir::Schema`] into TypeScript source text.
2//!
3//! Ported from club-unison's `codegen/typescript.rs`. Output format is kept
4//! faithful so Phase 1 Step 6 can diff this against club-unison's generator
5//! for regression detection.
6//!
7//! ## What it emits
8//!
9//! - A fixed import / type-alias header (`Timestamp`, `UUID`, `LanguageCode`).
10//! - data dialect: every [`ir::TypeDef`] — `interface` for structs, string
11//!   `enum` for enums.
12//! - entity dialect: every [`ir::Record`] as an `interface` carrying an
13//!   `id: string` member; every [`ir::Relation`] as an edge `interface`
14//!   carrying `id` / `in` / `out: string` plus its edge properties.
15//! - protocol dialect: for every [`ir::Channel`], per club-unison's
16//!   `generate_channel`:
17//!   - one `interface` per event payload, request payload and `returns` message,
18//!   - a `<Channel>ChannelEventTypes` type map,
19//!   - a `<Channel>ChannelRequestTypes` type map,
20//!   - a `<Channel>ChannelMeta` `const` carrying channel metadata.
21//!
22//! ## Differences from club-unison (IR-driven port)
23//!
24//! - No inline `_inline_*` message skipping and no `service` legacy handling.
25//! - Named type references emit the bare PascalCase identifier; the special
26//!   `timestamp` / `uuid` / `language_code` aliases of club-unison are still
27//!   honoured for compatibility with the fixed header.
28//!
29//! ## Tier 2 — description / constraints
30//!
31//! - A `description` on an `interface` / `enum` or a field becomes a
32//!   `/** ... */` JSDoc comment.
33//! - Field `constraints` are **not** emitted — TypeScript's type system
34//!   cannot express them, and `@minimum` / `@pattern` JSDoc hacks are
35//!   deliberately avoided.
36
37use crate::Emitter;
38use crate::ir;
39
40use super::case::to_pascal_case;
41
42/// The TypeScript code generation target.
43#[derive(Debug, Default, Clone, Copy)]
44pub struct TypeScriptEmitter;
45
46impl TypeScriptEmitter {
47    /// Create a new [`TypeScriptEmitter`].
48    pub fn new() -> Self {
49        Self
50    }
51}
52
53impl Emitter for TypeScriptEmitter {
54    fn emit(&self, schema: &ir::Schema) -> String {
55        let mut code = String::new();
56        code.push_str(HEADER);
57        code.push('\n');
58
59        // data dialect — standalone type definitions.
60        for ty in &schema.types {
61            match ty {
62                ir::TypeDef::Struct {
63                    name,
64                    description,
65                    fields,
66                } => {
67                    code.push_str(&render_interface(name, description.as_deref(), fields));
68                }
69                ir::TypeDef::Enum {
70                    name,
71                    description,
72                    variants,
73                } => {
74                    code.push_str(&render_enum(name, description.as_deref(), variants));
75                }
76            }
77            code.push_str("\n\n");
78        }
79
80        // entity dialect — records and relations. Interface names are
81        // PascalCased so a camelCase relation name (`derivedFrom`) is idiomatic.
82        for record in &schema.records {
83            code.push_str(&render_interface(
84                &to_pascal_case(&record.name),
85                record.description.as_deref(),
86                &record_members(record),
87            ));
88            code.push_str("\n\n");
89        }
90        for relation in &schema.relations {
91            code.push_str(&render_interface(
92                &to_pascal_case(&relation.name),
93                relation.description.as_deref(),
94                &relation_members(relation),
95            ));
96            code.push_str("\n\n");
97        }
98
99        // protocol dialect — channel interfaces + metadata.
100        if let Some(protocol) = &schema.protocol {
101            if let Some(namespace) = &protocol.namespace {
102                code.push_str(&format!("// Namespace: {namespace}\n"));
103                code.push_str(&format!("// Version: {}\n\n", protocol.version));
104            }
105            for channel in &protocol.channels {
106                code.push_str(&render_channel(channel));
107                code.push_str("\n\n");
108            }
109        }
110
111        code
112    }
113}
114
115/// Fixed header block, matching club-unison's `generate_imports`.
116const HEADER: &str = "\
117// Auto-generated TypeScript definitions
118// DO NOT EDIT MANUALLY
119
120export type Timestamp = string; // ISO-8601 format
121export type UUID = string;
122export type LanguageCode = string; // ISO 639-1 format
123";
124
125/// Render a `/** ... */` JSDoc block at the given indentation from an optional
126/// description, including a trailing newline. A multi-line description is
127/// rendered as a `*`-prefixed block; a single line stays on one line.
128fn render_doc(description: Option<&str>, indent: &str) -> String {
129    match description {
130        None => String::new(),
131        Some(text) => {
132            let mut lines = text.lines();
133            match (lines.next(), text.contains('\n')) {
134                (Some(first), false) => format!("{indent}/** {first} */\n"),
135                (Some(first), true) => {
136                    let mut out = format!("{indent}/**\n{indent} * {first}\n");
137                    for line in lines {
138                        out.push_str(&format!("{indent} * {line}\n"));
139                    }
140                    out.push_str(&format!("{indent} */\n"));
141                    out
142                }
143                (None, _) => String::new(),
144            }
145        }
146    }
147}
148
149/// Render a plain `interface` from a name and field list.
150fn render_interface(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
151    let doc = render_doc(description, "");
152    let body: Vec<String> = fields.iter().map(render_field).collect();
153    format!("{doc}export interface {name} {{\n{}\n}}", body.join("\n"))
154}
155
156/// Render a string-valued `enum`.
157fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
158    let doc = render_doc(description, "");
159    let body: Vec<String> = variants
160        .iter()
161        .map(|v| format!("  {} = '{}',", to_pascal_case(v), v))
162        .collect();
163    format!("{doc}export enum {name} {{\n{}\n}}", body.join("\n"))
164}
165
166/// Render a single interface field line, prefixed by its JSDoc when the field
167/// carries a description.
168fn render_field(field: &ir::Field) -> String {
169    let optional = if field.required { "" } else { "?" };
170    let doc = render_doc(field.description.as_deref(), "  ");
171    format!(
172        "{doc}  {}{}: {};",
173        field.name,
174        optional,
175        ty_to_ts(&field.ty)
176    )
177}
178
179/// The synthetic `id: string` field shared by records and relations.
180fn id_member() -> ir::Field {
181    ir::Field {
182        name: "id".to_string(),
183        ty: ir::Ty::Primitive(ir::Prim::String),
184        required: true,
185        flexible: false,
186        default: None,
187        description: None,
188        constraints: ir::Constraints::default(),
189    }
190}
191
192/// A record's interface members: a leading `id`, then its declared fields.
193fn record_members(record: &ir::Record) -> Vec<ir::Field> {
194    let mut members = Vec::with_capacity(record.fields.len() + 1);
195    members.push(id_member());
196    members.extend(record.fields.iter().cloned());
197    members
198}
199
200/// A relation's edge-interface members: `id` / `in` / `out`, then its
201/// declared edge-property fields.
202fn relation_members(relation: &ir::Relation) -> Vec<ir::Field> {
203    let endpoint = |name: &str| ir::Field {
204        name: name.to_string(),
205        ty: ir::Ty::Primitive(ir::Prim::String),
206        required: true,
207        flexible: false,
208        default: None,
209        description: None,
210        constraints: ir::Constraints::default(),
211    };
212    let mut members = Vec::with_capacity(relation.fields.len() + 3);
213    members.push(id_member());
214    members.push(endpoint("in"));
215    members.push(endpoint("out"));
216    members.extend(relation.fields.iter().cloned());
217    members
218}
219
220/// Map an [`ir::Ty`] to its TypeScript type expression.
221fn ty_to_ts(ty: &ir::Ty) -> String {
222    match ty {
223        ir::Ty::Primitive(p) => prim_to_ts(*p).to_string(),
224        ir::Ty::Array(inner) => format!("{}[]", ty_to_ts(inner)),
225        ir::Ty::Named(name) => named_to_ts(name),
226        // a link is stored as the target record's id — a string.
227        ir::Ty::Link(_) => "string".to_string(),
228        // a string literal type maps 1:1 to a TS literal type.
229        ir::Ty::Literal(value) => format!("'{value}'"),
230        // a union maps to a TS union; members that map to the same TS type
231        // are de-duplicated (`link<X> | string` both become `string`).
232        ir::Ty::Union(members) => {
233            let mut parts: Vec<String> = Vec::new();
234            for m in members {
235                let t = ty_to_ts(m);
236                if !parts.contains(&t) {
237                    parts.push(t);
238                }
239            }
240            parts.join(" | ")
241        }
242    }
243}
244
245/// Map an [`ir::Prim`] to its TypeScript type.
246fn prim_to_ts(p: ir::Prim) -> &'static str {
247    match p {
248        ir::Prim::String => "string",
249        ir::Prim::Int | ir::Prim::Float => "number",
250        ir::Prim::Bool => "boolean",
251        ir::Prim::Datetime => "Timestamp",
252        ir::Prim::Json => "any",
253    }
254}
255
256/// Resolve a named type reference, honouring club-unison's special aliases.
257fn named_to_ts(name: &str) -> String {
258    match name {
259        "timestamp" => "Timestamp".to_string(),
260        "uuid" => "UUID".to_string(),
261        "language_code" => "LanguageCode".to_string(),
262        _ => to_pascal_case(name),
263    }
264}
265
266/// Render a payload `interface` with a leading JSDoc comment of the given kind.
267///
268/// `name` is the wire name as written in the schema; the JSDoc keeps it
269/// verbatim while the `interface` identifier is PascalCased so a wire-style
270/// name (`process:toggle`) yields a valid TypeScript identifier
271/// (`ProcessToggle`).
272fn render_payload_interface(kind: &str, name: &str, fields: &[ir::Field]) -> String {
273    let ident = to_pascal_case(name);
274    if fields.is_empty() {
275        format!("/** {kind} \"{name}\" — empty payload */\nexport interface {ident} {{}}")
276    } else {
277        let body: Vec<String> = fields.iter().map(render_field).collect();
278        format!(
279            "/** {kind} \"{name}\" */\nexport interface {ident} {{\n{}\n}}",
280            body.join("\n")
281        )
282    }
283}
284
285/// The TypeScript identifier for a request's response: the sentinel `"void"`
286/// stays literal, any other (wire) name is PascalCased to match its generated
287/// `interface`.
288fn response_ident(resp: &str) -> String {
289    if resp == "void" {
290        "void".to_string()
291    } else {
292        to_pascal_case(resp)
293    }
294}
295
296/// Render the full block for one channel: payload interfaces, the event /
297/// request type maps, the `ChannelMeta` const, and — when the channel
298/// declares `envelope="<tag>"` — a discriminated-union envelope type. Ported
299/// from club-unison's `generate_channel`.
300fn render_channel(channel: &ir::Channel) -> String {
301    let mut code = String::new();
302
303    let backend_str = match channel.backend {
304        ir::ChannelBackend::Stream => "stream",
305        ir::ChannelBackend::Datagram => "datagram",
306    };
307
308    // Section header.
309    let channel_id_note = match channel.channel_id {
310        Some(id) => format!(", channel_id={id}"),
311        None => String::new(),
312    };
313    code.push_str(&format!(
314        "// ════════════════════════════════════════════════\n\
315         // Channel: {name} (backend={backend_str}{channel_id_note})\n\
316         // ════════════════════════════════════════════════\n\n",
317        name = channel.name,
318    ));
319
320    // Event interfaces.
321    let mut event_names: Vec<String> = Vec::new();
322    for evt in &channel.events {
323        code.push_str(&render_payload_interface("Event", &evt.name, &evt.fields));
324        code.push_str("\n\n");
325        event_names.push(evt.name.clone());
326    }
327
328    // Request / response interfaces. Each entry is (request name, response name).
329    let mut request_mappings: Vec<(String, String)> = Vec::new();
330    for req in &channel.requests {
331        code.push_str(&render_payload_interface("Request", &req.name, &req.fields));
332        code.push_str("\n\n");
333
334        let response_name = match &req.returns {
335            Some(returns) => {
336                code.push_str(&render_payload_interface(
337                    "Response",
338                    &returns.name,
339                    &returns.fields,
340                ));
341                code.push_str("\n\n");
342                returns.name.clone()
343            }
344            None => "void".to_string(),
345        };
346        request_mappings.push((req.name.clone(), response_name));
347    }
348
349    let pascal = to_pascal_case(&channel.name);
350
351    // Event type map.
352    let event_types_name = format!("{pascal}ChannelEventTypes");
353    code.push_str(&format!(
354        "/** Event name → 生成 interface の map for \"{}\" (= type-narrowing 用) */\n",
355        channel.name
356    ));
357    if event_names.is_empty() {
358        code.push_str(&format!(
359            "export type {event_types_name} = Record<string, never>;\n\n"
360        ));
361    } else {
362        // `type` (not `interface`): the map must be assignable to
363        // `Record<string, unknown>` for the SDK's `ChannelMeta.__types` —
364        // an `interface` has no implicit index signature and would fail.
365        code.push_str(&format!("export type {event_types_name} = {{\n"));
366        for n in &event_names {
367            let ident = to_pascal_case(n);
368            code.push_str(&format!("  {ident}: {ident};\n"));
369        }
370        code.push_str("};\n\n");
371    }
372
373    // Request type map.
374    let request_types_name = format!("{pascal}ChannelRequestTypes");
375    code.push_str(&format!(
376        "/** Request name → {{ request, response }} 生成 interface の map for \"{}\" */\n",
377        channel.name
378    ));
379    if request_mappings.is_empty() {
380        code.push_str(&format!(
381            "export type {request_types_name} = Record<string, never>;\n\n"
382        ));
383    } else {
384        // `type` (not `interface`) — see the event-map note above.
385        code.push_str(&format!("export type {request_types_name} = {{\n"));
386        for (req_name, resp_type) in &request_mappings {
387            let req_ident = to_pascal_case(req_name);
388            let resp_ident = response_ident(resp_type);
389            code.push_str(&format!(
390                "  {req_ident}: {{ request: {req_ident}; response: {resp_ident} }};\n"
391            ));
392        }
393        code.push_str("};\n\n");
394    }
395
396    // Channel metadata const.
397    let meta_name = format!("{pascal}ChannelMeta");
398    code.push_str(&format!(
399        "/** Channel metadata for \"{}\" (= Phase 2 runtime SDK 用 type-narrowing 入力) */\n",
400        channel.name
401    ));
402    code.push_str(&format!("export const {meta_name} = {{\n"));
403    code.push_str(&format!("  name: {:?} as const,\n", channel.name));
404    code.push_str(&format!("  backend: {backend_str:?} as const,\n"));
405    if let Some(cid) = channel.channel_id {
406        code.push_str(&format!("  channelId: {cid} as const,\n"));
407    }
408    let from_str = match channel.from {
409        ir::ChannelFrom::Client => "client",
410        ir::ChannelFrom::Server => "server",
411        ir::ChannelFrom::Either => "either",
412    };
413    code.push_str(&format!("  from: {from_str:?} as const,\n"));
414    let lifetime_str = match channel.lifetime {
415        ir::ChannelLifetime::Transient => "transient",
416        ir::ChannelLifetime::Persistent => "persistent",
417    };
418    code.push_str(&format!("  lifetime: {lifetime_str:?} as const,\n"));
419
420    // events list.
421    if event_names.is_empty() {
422        code.push_str("  events: [] as const,\n");
423    } else {
424        code.push_str("  events: [");
425        for (i, n) in event_names.iter().enumerate() {
426            if i > 0 {
427                code.push_str(", ");
428            }
429            code.push_str(&format!("{n:?}"));
430        }
431        code.push_str("] as const,\n");
432    }
433
434    // requests mapping.
435    if request_mappings.is_empty() {
436        code.push_str("  requests: {} as const,\n");
437    } else {
438        code.push_str("  requests: {\n");
439        for (req_name, resp_type) in &request_mappings {
440            let req_ident = to_pascal_case(req_name);
441            code.push_str(&format!(
442                "    {req_ident}: {{ request: {req_name:?} as const, response: {resp_type:?} as const }},\n"
443            ));
444        }
445        code.push_str("  } as const,\n");
446    }
447
448    // Phantom type carrier.
449    code.push_str(&format!(
450        "  __types: undefined as unknown as {{ events: {event_types_name}; requests: {request_types_name} }},\n"
451    ));
452    code.push_str("} as const;\n");
453
454    // Discriminated-union envelopes (opt-in via `envelope="<tag>"`): one over
455    // the channel's requests, one over its events. Each arm intersects the tag
456    // literal with the payload `interface`; a fieldless payload's interface is
457    // `{}`, so the arm collapses to the tag literal alone.
458    //
459    // Events get their own union because a channel can carry both directions
460    // (a `from="client"` channel whose server pushes events back), and the two
461    // sets are dispatched by different peers — one union would force each side
462    // to handle arms it can never receive.
463    if let Some(tag) = &channel.envelope {
464        let request_names: Vec<&str> = channel.requests.iter().map(|r| r.name.as_str()).collect();
465        if !request_names.is_empty() {
466            code.push_str(&render_envelope_union(
467                &channel.name,
468                tag,
469                &format!("{pascal}Envelope"),
470                &request_names,
471            ));
472        }
473        let event_names: Vec<&str> = channel.events.iter().map(|e| e.name.as_str()).collect();
474        if !event_names.is_empty() {
475            code.push_str(&render_envelope_union(
476                &channel.name,
477                tag,
478                &format!("{pascal}EventEnvelope"),
479                &event_names,
480            ));
481        }
482    }
483
484    code
485}
486
487/// Render one discriminated-union envelope over `members` (wire names in source
488/// order), discriminated on `tag`.
489fn render_envelope_union(
490    channel_name: &str,
491    tag: &str,
492    union_name: &str,
493    members: &[&str],
494) -> String {
495    let mut code = format!(
496        "\n/** Envelope union for channel \"{channel_name}\" — discriminated on {tag:?}. */\n"
497    );
498    code.push_str(&format!("export type {union_name} =\n"));
499    let arms: Vec<String> = members
500        .iter()
501        .map(|name| format!("  | ({{ {tag}: {name:?} }} & {})", to_pascal_case(name)))
502        .collect();
503    code.push_str(&arms.join("\n"));
504    code.push_str(";\n");
505    code
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
513        ir::Field {
514            name: name.to_string(),
515            ty,
516            required,
517            flexible: false,
518            default: None,
519            description: None,
520            constraints: ir::Constraints::default(),
521        }
522    }
523
524    #[test]
525    fn emits_header() {
526        let out = TypeScriptEmitter::new().emit(&ir::Schema::default());
527        assert!(out.contains("// DO NOT EDIT MANUALLY"));
528        assert!(out.contains("export type Timestamp = string;"));
529        assert!(out.contains("export type UUID = string;"));
530    }
531
532    #[test]
533    fn emits_interface_with_optional_field() {
534        let schema = ir::Schema {
535            types: vec![ir::TypeDef::Struct {
536                name: "User".to_string(),
537                description: None,
538                fields: vec![
539                    field("name", ir::Ty::Primitive(ir::Prim::String), true),
540                    field("nick", ir::Ty::Primitive(ir::Prim::String), false),
541                ],
542            }],
543            protocol: None,
544            ..Default::default()
545        };
546        let out = TypeScriptEmitter::new().emit(&schema);
547        assert!(out.contains("export interface User {"));
548        assert!(out.contains("  name: string;"));
549        assert!(out.contains("  nick?: string;"));
550    }
551
552    #[test]
553    fn emits_enum() {
554        let schema = ir::Schema {
555            types: vec![ir::TypeDef::Enum {
556                name: "Role".to_string(),
557                description: None,
558                variants: vec!["admin".to_string(), "guest_user".to_string()],
559            }],
560            protocol: None,
561            ..Default::default()
562        };
563        let out = TypeScriptEmitter::new().emit(&schema);
564        assert!(out.contains("export enum Role {"));
565        assert!(out.contains("  Admin = 'admin',"));
566        assert!(out.contains("  GuestUser = 'guest_user',"));
567    }
568
569    #[test]
570    fn maps_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("b", ir::Ty::Primitive(ir::Prim::Bool), true),
578                    field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
579                    field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
580                    field(
581                        "tags",
582                        ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
583                        true,
584                    ),
585                    field("owner", ir::Ty::Named("user_account".to_string()), true),
586                ],
587            }],
588            protocol: None,
589            ..Default::default()
590        };
591        let out = TypeScriptEmitter::new().emit(&schema);
592        assert!(out.contains("  n: number;"));
593        assert!(out.contains("  b: boolean;"));
594        assert!(out.contains("  at: Timestamp;"));
595        assert!(out.contains("  blob: any;"));
596        assert!(out.contains("  tags: string[];"));
597        assert!(out.contains("  owner: UserAccount;"));
598    }
599
600    #[test]
601    fn emits_channel_interfaces_and_meta() {
602        let schema = ir::Schema {
603            types: vec![],
604            records: vec![],
605            relations: vec![],
606            protocol: Some(ir::Protocol {
607                name: "ping-pong".to_string(),
608                version: "2.0.0".to_string(),
609                namespace: Some("demo".to_string()),
610                description: None,
611                channels: vec![ir::Channel {
612                    name: "ping-pong".to_string(),
613                    from: ir::ChannelFrom::Client,
614                    lifetime: ir::ChannelLifetime::Persistent,
615                    backend: ir::ChannelBackend::Stream,
616                    channel_id: None,
617                    envelope: None,
618                    requests: vec![ir::Request {
619                        name: "Ping".to_string(),
620                        fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
621                        returns: Some(ir::Message {
622                            name: "Pong".to_string(),
623                            fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
624                        }),
625                    }],
626                    events: vec![ir::Event {
627                        name: "Tick".to_string(),
628                        fields: vec![],
629                    }],
630                }],
631            }),
632        };
633        let out = TypeScriptEmitter::new().emit(&schema);
634        assert!(out.contains("// Namespace: demo"));
635        assert!(out.contains("// Channel: ping-pong (backend=stream)"));
636        assert!(out.contains("/** Request \"Ping\" */"));
637        assert!(out.contains("export interface Ping {"));
638        assert!(out.contains("/** Response \"Pong\" */"));
639        assert!(out.contains("/** Event \"Tick\" — empty payload */"));
640        assert!(out.contains("export interface Tick {}"));
641        assert!(out.contains("export type PingPongChannelEventTypes = {"));
642        assert!(out.contains("export type PingPongChannelRequestTypes = {"));
643        assert!(out.contains("  Ping: { request: Ping; response: Pong };"));
644        assert!(out.contains("export const PingPongChannelMeta = {"));
645        assert!(out.contains("  name: \"ping-pong\" as const,"));
646        assert!(out.contains("  backend: \"stream\" as const,"));
647        assert!(out.contains("  from: \"client\" as const,"));
648        assert!(out.contains("  lifetime: \"persistent\" as const,"));
649        assert!(out.contains("  events: [\"Tick\"] as const,"));
650    }
651
652    #[test]
653    fn datagram_channel_meta_carries_channel_id() {
654        let schema = ir::Schema {
655            types: vec![],
656            records: vec![],
657            relations: vec![],
658            protocol: Some(ir::Protocol {
659                name: "telemetry".to_string(),
660                version: "1.0.0".to_string(),
661                namespace: None,
662                description: None,
663                channels: vec![ir::Channel {
664                    name: "metrics".to_string(),
665                    from: ir::ChannelFrom::Server,
666                    lifetime: ir::ChannelLifetime::Persistent,
667                    backend: ir::ChannelBackend::Datagram,
668                    channel_id: Some(7),
669                    envelope: None,
670                    requests: vec![],
671                    events: vec![ir::Event {
672                        name: "Sample".to_string(),
673                        fields: vec![field("v", ir::Ty::Primitive(ir::Prim::Float), true)],
674                    }],
675                }],
676            }),
677        };
678        let out = TypeScriptEmitter::new().emit(&schema);
679        assert!(out.contains("// Channel: metrics (backend=datagram, channel_id=7)"));
680        assert!(out.contains("  channelId: 7 as const,"));
681        assert!(out.contains("  requests: {} as const,"));
682        assert!(out.contains("export type MetricsChannelRequestTypes = Record<string, never>;"));
683    }
684
685    // -------------------------------------------------------------------------
686    // protocol dialect — envelope union + identifier sanitize
687    // -------------------------------------------------------------------------
688
689    /// The sidebar-IPC spike channel: a `:`-bearing request name, a fieldless
690    /// request, and an optional `envelope` tag.
691    fn sidebar_schema(envelope: Option<&str>) -> ir::Schema {
692        ir::Schema {
693            protocol: Some(ir::Protocol {
694                name: "sidebar".to_string(),
695                version: "1.0.0".to_string(),
696                namespace: None,
697                description: None,
698                channels: vec![ir::Channel {
699                    name: "ipc".to_string(),
700                    from: ir::ChannelFrom::Client,
701                    lifetime: ir::ChannelLifetime::Transient,
702                    backend: ir::ChannelBackend::Stream,
703                    channel_id: None,
704                    envelope: envelope.map(str::to_string),
705                    requests: vec![
706                        ir::Request {
707                            name: "process:toggle".to_string(),
708                            fields: vec![field("path", ir::Ty::Primitive(ir::Prim::String), true)],
709                            returns: None,
710                        },
711                        ir::Request {
712                            name: "process:add".to_string(),
713                            fields: vec![],
714                            returns: None,
715                        },
716                    ],
717                    events: vec![],
718                }],
719            }),
720            ..Default::default()
721        }
722    }
723
724    #[test]
725    fn channel_request_names_are_sanitized_to_valid_identifiers() {
726        // A `:`-bearing request name must not leak into `interface process:toggle`.
727        let out = TypeScriptEmitter::new().emit(&sidebar_schema(None));
728        assert!(out.contains("export interface ProcessToggle {"));
729        assert!(out.contains("export interface ProcessAdd {}"));
730        assert!(
731            !out.contains("interface process:toggle"),
732            "raw `:` name must not leak into an identifier"
733        );
734        // the JSDoc keeps the wire name verbatim.
735        assert!(out.contains("/** Request \"process:toggle\" */"));
736    }
737
738    #[test]
739    fn channel_without_envelope_emits_no_union() {
740        let out = TypeScriptEmitter::new().emit(&sidebar_schema(None));
741        assert!(
742            !out.contains("export type IpcEnvelope"),
743            "no envelope ⇒ no union"
744        );
745    }
746
747    #[test]
748    fn envelope_channel_emits_discriminated_union() {
749        let out = TypeScriptEmitter::new().emit(&sidebar_schema(Some("t")));
750        assert!(
751            out.contains("export type IpcEnvelope ="),
752            "union type emitted"
753        );
754        assert!(out.contains("  | ({ t: \"process:toggle\" } & ProcessToggle)"));
755        assert!(out.contains("  | ({ t: \"process:add\" } & ProcessAdd)"));
756    }
757
758    /// A `from="server"` channel carrying only events — the push-only shape.
759    fn push_schema(envelope: Option<&str>) -> ir::Schema {
760        ir::Schema {
761            protocol: Some(ir::Protocol {
762                name: "push".to_string(),
763                version: "1.0.0".to_string(),
764                namespace: None,
765                description: None,
766                channels: vec![ir::Channel {
767                    name: "push".to_string(),
768                    from: ir::ChannelFrom::Server,
769                    lifetime: ir::ChannelLifetime::Persistent,
770                    backend: ir::ChannelBackend::Stream,
771                    channel_id: None,
772                    envelope: envelope.map(str::to_string),
773                    requests: vec![],
774                    events: vec![ir::Event {
775                        name: "term:ensure_lane".to_string(),
776                        fields: vec![field("lane", ir::Ty::Primitive(ir::Prim::String), true)],
777                    }],
778                }],
779            }),
780            ..Default::default()
781        }
782    }
783
784    #[test]
785    fn events_only_channel_emits_event_union() {
786        let out = TypeScriptEmitter::new().emit(&push_schema(Some("t")));
787        assert!(
788            out.contains("export type PushEventEnvelope ="),
789            "union named <Channel>EventEnvelope"
790        );
791        assert!(out.contains("  | ({ t: \"term:ensure_lane\" } & TermEnsureLane)"));
792        assert!(
793            !out.contains("export type PushEnvelope ="),
794            "no requests ⇒ no request union"
795        );
796    }
797
798    #[test]
799    fn channel_with_both_directions_emits_two_unions() {
800        let mut schema = sidebar_schema(Some("t"));
801        schema.protocol.as_mut().unwrap().channels[0].events = vec![ir::Event {
802            name: "topic:event".to_string(),
803            fields: vec![field("body", ir::Ty::Primitive(ir::Prim::String), true)],
804        }];
805        let out = TypeScriptEmitter::new().emit(&schema);
806        assert!(out.contains("export type IpcEnvelope ="), "requests union");
807        assert!(
808            out.contains("export type IpcEventEnvelope ="),
809            "events union"
810        );
811        // the event must not leak into the request union.
812        let req_union = out.split("export type IpcEnvelope =").nth(1).unwrap();
813        let req_body = req_union.split(";\n").next().unwrap();
814        assert!(
815            !req_body.contains("TopicEvent"),
816            "event leaked into the request union"
817        );
818    }
819
820    #[test]
821    fn events_without_envelope_tag_emit_no_union() {
822        // Backward compatibility: envelope generation stays opt-in for events too.
823        let out = TypeScriptEmitter::new().emit(&push_schema(None));
824        assert!(!out.contains("EventEnvelope"), "no envelope tag ⇒ no union");
825        assert!(
826            out.contains("export interface TermEnsureLane {"),
827            "interfaces still emit"
828        );
829    }
830
831    // -------------------------------------------------------------------------
832    // Tier 1 — record / relation / link / literal / union
833    // -------------------------------------------------------------------------
834
835    #[test]
836    fn record_becomes_interface_with_id() {
837        let schema = ir::Schema {
838            records: vec![ir::Record {
839                name: "Atlas".to_string(),
840                description: None,
841                id_strategy: ir::IdStrategy::Uuidv7,
842                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
843            }],
844            ..Default::default()
845        };
846        let out = TypeScriptEmitter::new().emit(&schema);
847        assert!(out.contains("export interface Atlas {"));
848        assert!(out.contains("  id: string;"));
849        assert!(out.contains("  name: string;"));
850    }
851
852    #[test]
853    fn relation_interface_is_pascal_cased_with_in_out() {
854        let schema = ir::Schema {
855            relations: vec![ir::Relation {
856                name: "derivedFrom".to_string(),
857                description: None,
858                from: "Memory".to_string(),
859                to: "Memory".to_string(),
860                unique: true,
861                fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
862            }],
863            ..Default::default()
864        };
865        let out = TypeScriptEmitter::new().emit(&schema);
866        assert!(out.contains("export interface DerivedFrom {"));
867        assert!(out.contains("  id: string;"));
868        assert!(out.contains("  in: string;"));
869        assert!(out.contains("  out: string;"));
870        assert!(out.contains("  reason?: string;"));
871    }
872
873    #[test]
874    fn link_literal_and_union_map_to_ts_types() {
875        let schema = ir::Schema {
876            records: vec![ir::Record {
877                name: "Doc".to_string(),
878                description: None,
879                id_strategy: ir::IdStrategy::Uuidv7,
880                fields: vec![
881                    field("parent", ir::Ty::Link("Doc".to_string()), false),
882                    field(
883                        "visibility",
884                        ir::Ty::Union(vec![
885                            ir::Ty::Literal("public".to_string()),
886                            ir::Ty::Literal("private".to_string()),
887                        ]),
888                        true,
889                    ),
890                ],
891            }],
892            ..Default::default()
893        };
894        let out = TypeScriptEmitter::new().emit(&schema);
895        assert!(out.contains("  parent?: string;"), "link → string");
896        assert!(
897            out.contains("  visibility: 'public' | 'private';"),
898            "literal union → TS union of literals"
899        );
900    }
901
902    // -------------------------------------------------------------------------
903    // Tier 2 — description -> JSDoc (constraints are not emitted)
904    // -------------------------------------------------------------------------
905
906    #[test]
907    fn interface_and_field_descriptions_become_jsdoc() {
908        let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
909        content.description = Some("Memory content text".to_string());
910        let schema = ir::Schema {
911            types: vec![ir::TypeDef::Struct {
912                name: "Memory".to_string(),
913                description: Some("User memory".to_string()),
914                fields: vec![content],
915            }],
916            ..Default::default()
917        };
918        let out = TypeScriptEmitter::new().emit(&schema);
919        assert!(out.contains("/** User memory */\n"), "interface JSDoc");
920        assert!(
921            out.contains("  /** Memory content text */\n"),
922            "field JSDoc"
923        );
924    }
925
926    #[test]
927    fn enum_description_becomes_jsdoc() {
928        let schema = ir::Schema {
929            types: vec![ir::TypeDef::Enum {
930                name: "Role".to_string(),
931                description: Some("An access role".to_string()),
932                variants: vec!["admin".to_string()],
933            }],
934            ..Default::default()
935        };
936        let out = TypeScriptEmitter::new().emit(&schema);
937        assert!(out.contains("/** An access role */\n"));
938    }
939
940    #[test]
941    fn constraints_do_not_appear_in_typescript_output() {
942        // TypeScript's type system cannot express min/max/pattern.
943        let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
944        f.constraints = ir::Constraints {
945            min: Some(0),
946            max: Some(1),
947            ..Default::default()
948        };
949        let schema = ir::Schema {
950            types: vec![ir::TypeDef::Struct {
951                name: "T".to_string(),
952                description: None,
953                fields: vec![f],
954            }],
955            ..Default::default()
956        };
957        let out = TypeScriptEmitter::new().emit(&schema);
958        assert!(out.contains("  confidence: number;"));
959        assert!(!out.contains("@minimum"), "no constraint metadata leaks");
960    }
961}