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 envelope over the channel's requests (opt-in via
455    // `envelope="<tag>"`). Each arm intersects the tag literal with the
456    // request's payload `interface`; a fieldless request's interface is `{}`,
457    // so the arm collapses to the tag literal alone.
458    if let Some(tag) = &channel.envelope
459        && !channel.requests.is_empty()
460    {
461        let envelope_name = format!("{pascal}Envelope");
462        code.push_str(&format!(
463            "\n/** Envelope union for channel \"{}\" — discriminated on {tag:?}. */\n",
464            channel.name
465        ));
466        code.push_str(&format!("export type {envelope_name} =\n"));
467        let arms: Vec<String> = channel
468            .requests
469            .iter()
470            .map(|req| {
471                format!(
472                    "  | ({{ {tag}: {:?} }} & {})",
473                    req.name,
474                    to_pascal_case(&req.name)
475                )
476            })
477            .collect();
478        code.push_str(&arms.join("\n"));
479        code.push_str(";\n");
480    }
481
482    code
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
490        ir::Field {
491            name: name.to_string(),
492            ty,
493            required,
494            flexible: false,
495            default: None,
496            description: None,
497            constraints: ir::Constraints::default(),
498        }
499    }
500
501    #[test]
502    fn emits_header() {
503        let out = TypeScriptEmitter::new().emit(&ir::Schema::default());
504        assert!(out.contains("// DO NOT EDIT MANUALLY"));
505        assert!(out.contains("export type Timestamp = string;"));
506        assert!(out.contains("export type UUID = string;"));
507    }
508
509    #[test]
510    fn emits_interface_with_optional_field() {
511        let schema = ir::Schema {
512            types: vec![ir::TypeDef::Struct {
513                name: "User".to_string(),
514                description: None,
515                fields: vec![
516                    field("name", ir::Ty::Primitive(ir::Prim::String), true),
517                    field("nick", ir::Ty::Primitive(ir::Prim::String), false),
518                ],
519            }],
520            protocol: None,
521            ..Default::default()
522        };
523        let out = TypeScriptEmitter::new().emit(&schema);
524        assert!(out.contains("export interface User {"));
525        assert!(out.contains("  name: string;"));
526        assert!(out.contains("  nick?: string;"));
527    }
528
529    #[test]
530    fn emits_enum() {
531        let schema = ir::Schema {
532            types: vec![ir::TypeDef::Enum {
533                name: "Role".to_string(),
534                description: None,
535                variants: vec!["admin".to_string(), "guest_user".to_string()],
536            }],
537            protocol: None,
538            ..Default::default()
539        };
540        let out = TypeScriptEmitter::new().emit(&schema);
541        assert!(out.contains("export enum Role {"));
542        assert!(out.contains("  Admin = 'admin',"));
543        assert!(out.contains("  GuestUser = 'guest_user',"));
544    }
545
546    #[test]
547    fn maps_types() {
548        let schema = ir::Schema {
549            types: vec![ir::TypeDef::Struct {
550                name: "T".to_string(),
551                description: None,
552                fields: vec![
553                    field("n", ir::Ty::Primitive(ir::Prim::Int), true),
554                    field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
555                    field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
556                    field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
557                    field(
558                        "tags",
559                        ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
560                        true,
561                    ),
562                    field("owner", ir::Ty::Named("user_account".to_string()), true),
563                ],
564            }],
565            protocol: None,
566            ..Default::default()
567        };
568        let out = TypeScriptEmitter::new().emit(&schema);
569        assert!(out.contains("  n: number;"));
570        assert!(out.contains("  b: boolean;"));
571        assert!(out.contains("  at: Timestamp;"));
572        assert!(out.contains("  blob: any;"));
573        assert!(out.contains("  tags: string[];"));
574        assert!(out.contains("  owner: UserAccount;"));
575    }
576
577    #[test]
578    fn emits_channel_interfaces_and_meta() {
579        let schema = ir::Schema {
580            types: vec![],
581            records: vec![],
582            relations: vec![],
583            protocol: Some(ir::Protocol {
584                name: "ping-pong".to_string(),
585                version: "2.0.0".to_string(),
586                namespace: Some("demo".to_string()),
587                description: None,
588                channels: vec![ir::Channel {
589                    name: "ping-pong".to_string(),
590                    from: ir::ChannelFrom::Client,
591                    lifetime: ir::ChannelLifetime::Persistent,
592                    backend: ir::ChannelBackend::Stream,
593                    channel_id: None,
594                    envelope: None,
595                    requests: vec![ir::Request {
596                        name: "Ping".to_string(),
597                        fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
598                        returns: Some(ir::Message {
599                            name: "Pong".to_string(),
600                            fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
601                        }),
602                    }],
603                    events: vec![ir::Event {
604                        name: "Tick".to_string(),
605                        fields: vec![],
606                    }],
607                }],
608            }),
609        };
610        let out = TypeScriptEmitter::new().emit(&schema);
611        assert!(out.contains("// Namespace: demo"));
612        assert!(out.contains("// Channel: ping-pong (backend=stream)"));
613        assert!(out.contains("/** Request \"Ping\" */"));
614        assert!(out.contains("export interface Ping {"));
615        assert!(out.contains("/** Response \"Pong\" */"));
616        assert!(out.contains("/** Event \"Tick\" — empty payload */"));
617        assert!(out.contains("export interface Tick {}"));
618        assert!(out.contains("export type PingPongChannelEventTypes = {"));
619        assert!(out.contains("export type PingPongChannelRequestTypes = {"));
620        assert!(out.contains("  Ping: { request: Ping; response: Pong };"));
621        assert!(out.contains("export const PingPongChannelMeta = {"));
622        assert!(out.contains("  name: \"ping-pong\" as const,"));
623        assert!(out.contains("  backend: \"stream\" as const,"));
624        assert!(out.contains("  from: \"client\" as const,"));
625        assert!(out.contains("  lifetime: \"persistent\" as const,"));
626        assert!(out.contains("  events: [\"Tick\"] as const,"));
627    }
628
629    #[test]
630    fn datagram_channel_meta_carries_channel_id() {
631        let schema = ir::Schema {
632            types: vec![],
633            records: vec![],
634            relations: vec![],
635            protocol: Some(ir::Protocol {
636                name: "telemetry".to_string(),
637                version: "1.0.0".to_string(),
638                namespace: None,
639                description: None,
640                channels: vec![ir::Channel {
641                    name: "metrics".to_string(),
642                    from: ir::ChannelFrom::Server,
643                    lifetime: ir::ChannelLifetime::Persistent,
644                    backend: ir::ChannelBackend::Datagram,
645                    channel_id: Some(7),
646                    envelope: None,
647                    requests: vec![],
648                    events: vec![ir::Event {
649                        name: "Sample".to_string(),
650                        fields: vec![field("v", ir::Ty::Primitive(ir::Prim::Float), true)],
651                    }],
652                }],
653            }),
654        };
655        let out = TypeScriptEmitter::new().emit(&schema);
656        assert!(out.contains("// Channel: metrics (backend=datagram, channel_id=7)"));
657        assert!(out.contains("  channelId: 7 as const,"));
658        assert!(out.contains("  requests: {} as const,"));
659        assert!(out.contains("export type MetricsChannelRequestTypes = Record<string, never>;"));
660    }
661
662    // -------------------------------------------------------------------------
663    // protocol dialect — envelope union + identifier sanitize
664    // -------------------------------------------------------------------------
665
666    /// The sidebar-IPC spike channel: a `:`-bearing request name, a fieldless
667    /// request, and an optional `envelope` tag.
668    fn sidebar_schema(envelope: Option<&str>) -> ir::Schema {
669        ir::Schema {
670            protocol: Some(ir::Protocol {
671                name: "sidebar".to_string(),
672                version: "1.0.0".to_string(),
673                namespace: None,
674                description: None,
675                channels: vec![ir::Channel {
676                    name: "ipc".to_string(),
677                    from: ir::ChannelFrom::Client,
678                    lifetime: ir::ChannelLifetime::Transient,
679                    backend: ir::ChannelBackend::Stream,
680                    channel_id: None,
681                    envelope: envelope.map(str::to_string),
682                    requests: vec![
683                        ir::Request {
684                            name: "process:toggle".to_string(),
685                            fields: vec![field("path", ir::Ty::Primitive(ir::Prim::String), true)],
686                            returns: None,
687                        },
688                        ir::Request {
689                            name: "process:add".to_string(),
690                            fields: vec![],
691                            returns: None,
692                        },
693                    ],
694                    events: vec![],
695                }],
696            }),
697            ..Default::default()
698        }
699    }
700
701    #[test]
702    fn channel_request_names_are_sanitized_to_valid_identifiers() {
703        // A `:`-bearing request name must not leak into `interface process:toggle`.
704        let out = TypeScriptEmitter::new().emit(&sidebar_schema(None));
705        assert!(out.contains("export interface ProcessToggle {"));
706        assert!(out.contains("export interface ProcessAdd {}"));
707        assert!(
708            !out.contains("interface process:toggle"),
709            "raw `:` name must not leak into an identifier"
710        );
711        // the JSDoc keeps the wire name verbatim.
712        assert!(out.contains("/** Request \"process:toggle\" */"));
713    }
714
715    #[test]
716    fn channel_without_envelope_emits_no_union() {
717        let out = TypeScriptEmitter::new().emit(&sidebar_schema(None));
718        assert!(
719            !out.contains("export type IpcEnvelope"),
720            "no envelope ⇒ no union"
721        );
722    }
723
724    #[test]
725    fn envelope_channel_emits_discriminated_union() {
726        let out = TypeScriptEmitter::new().emit(&sidebar_schema(Some("t")));
727        assert!(
728            out.contains("export type IpcEnvelope ="),
729            "union type emitted"
730        );
731        assert!(out.contains("  | ({ t: \"process:toggle\" } & ProcessToggle)"));
732        assert!(out.contains("  | ({ t: \"process:add\" } & ProcessAdd)"));
733    }
734
735    // -------------------------------------------------------------------------
736    // Tier 1 — record / relation / link / literal / union
737    // -------------------------------------------------------------------------
738
739    #[test]
740    fn record_becomes_interface_with_id() {
741        let schema = ir::Schema {
742            records: vec![ir::Record {
743                name: "Atlas".to_string(),
744                description: None,
745                id_strategy: ir::IdStrategy::Uuidv7,
746                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
747            }],
748            ..Default::default()
749        };
750        let out = TypeScriptEmitter::new().emit(&schema);
751        assert!(out.contains("export interface Atlas {"));
752        assert!(out.contains("  id: string;"));
753        assert!(out.contains("  name: string;"));
754    }
755
756    #[test]
757    fn relation_interface_is_pascal_cased_with_in_out() {
758        let schema = ir::Schema {
759            relations: vec![ir::Relation {
760                name: "derivedFrom".to_string(),
761                description: None,
762                from: "Memory".to_string(),
763                to: "Memory".to_string(),
764                unique: true,
765                fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
766            }],
767            ..Default::default()
768        };
769        let out = TypeScriptEmitter::new().emit(&schema);
770        assert!(out.contains("export interface DerivedFrom {"));
771        assert!(out.contains("  id: string;"));
772        assert!(out.contains("  in: string;"));
773        assert!(out.contains("  out: string;"));
774        assert!(out.contains("  reason?: string;"));
775    }
776
777    #[test]
778    fn link_literal_and_union_map_to_ts_types() {
779        let schema = ir::Schema {
780            records: vec![ir::Record {
781                name: "Doc".to_string(),
782                description: None,
783                id_strategy: ir::IdStrategy::Uuidv7,
784                fields: vec![
785                    field("parent", ir::Ty::Link("Doc".to_string()), false),
786                    field(
787                        "visibility",
788                        ir::Ty::Union(vec![
789                            ir::Ty::Literal("public".to_string()),
790                            ir::Ty::Literal("private".to_string()),
791                        ]),
792                        true,
793                    ),
794                ],
795            }],
796            ..Default::default()
797        };
798        let out = TypeScriptEmitter::new().emit(&schema);
799        assert!(out.contains("  parent?: string;"), "link → string");
800        assert!(
801            out.contains("  visibility: 'public' | 'private';"),
802            "literal union → TS union of literals"
803        );
804    }
805
806    // -------------------------------------------------------------------------
807    // Tier 2 — description -> JSDoc (constraints are not emitted)
808    // -------------------------------------------------------------------------
809
810    #[test]
811    fn interface_and_field_descriptions_become_jsdoc() {
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 = TypeScriptEmitter::new().emit(&schema);
823        assert!(out.contains("/** User memory */\n"), "interface JSDoc");
824        assert!(
825            out.contains("  /** Memory content text */\n"),
826            "field JSDoc"
827        );
828    }
829
830    #[test]
831    fn enum_description_becomes_jsdoc() {
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 = TypeScriptEmitter::new().emit(&schema);
841        assert!(out.contains("/** An access role */\n"));
842    }
843
844    #[test]
845    fn constraints_do_not_appear_in_typescript_output() {
846        // TypeScript's type system cannot express min/max/pattern.
847        let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
848        f.constraints = ir::Constraints {
849            min: Some(0),
850            max: Some(1),
851            ..Default::default()
852        };
853        let schema = ir::Schema {
854            types: vec![ir::TypeDef::Struct {
855                name: "T".to_string(),
856                description: None,
857                fields: vec![f],
858            }],
859            ..Default::default()
860        };
861        let out = TypeScriptEmitter::new().emit(&schema);
862        assert!(out.contains("  confidence: number;"));
863        assert!(!out.contains("@minimum"), "no constraint metadata leaks");
864    }
865}