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.
267fn render_payload_interface(kind: &str, name: &str, fields: &[ir::Field]) -> String {
268    if fields.is_empty() {
269        format!("/** {kind} \"{name}\" — empty payload */\nexport interface {name} {{}}")
270    } else {
271        let body: Vec<String> = fields.iter().map(render_field).collect();
272        format!(
273            "/** {kind} \"{name}\" */\nexport interface {name} {{\n{}\n}}",
274            body.join("\n")
275        )
276    }
277}
278
279/// Render the full block for one channel: payload interfaces, the event /
280/// request type maps, and the `ChannelMeta` const. Ported from
281/// club-unison's `generate_channel`.
282fn render_channel(channel: &ir::Channel) -> String {
283    let mut code = String::new();
284
285    let backend_str = match channel.backend {
286        ir::ChannelBackend::Stream => "stream",
287        ir::ChannelBackend::Datagram => "datagram",
288    };
289
290    // Section header.
291    let channel_id_note = match channel.channel_id {
292        Some(id) => format!(", channel_id={id}"),
293        None => String::new(),
294    };
295    code.push_str(&format!(
296        "// ════════════════════════════════════════════════\n\
297         // Channel: {name} (backend={backend_str}{channel_id_note})\n\
298         // ════════════════════════════════════════════════\n\n",
299        name = channel.name,
300    ));
301
302    // Event interfaces.
303    let mut event_names: Vec<String> = Vec::new();
304    for evt in &channel.events {
305        code.push_str(&render_payload_interface("Event", &evt.name, &evt.fields));
306        code.push_str("\n\n");
307        event_names.push(evt.name.clone());
308    }
309
310    // Request / response interfaces. Each entry is (request name, response name).
311    let mut request_mappings: Vec<(String, String)> = Vec::new();
312    for req in &channel.requests {
313        code.push_str(&render_payload_interface("Request", &req.name, &req.fields));
314        code.push_str("\n\n");
315
316        let response_name = match &req.returns {
317            Some(returns) => {
318                code.push_str(&render_payload_interface(
319                    "Response",
320                    &returns.name,
321                    &returns.fields,
322                ));
323                code.push_str("\n\n");
324                returns.name.clone()
325            }
326            None => "void".to_string(),
327        };
328        request_mappings.push((req.name.clone(), response_name));
329    }
330
331    let pascal = to_pascal_case(&channel.name);
332
333    // Event type map.
334    let event_types_name = format!("{pascal}ChannelEventTypes");
335    code.push_str(&format!(
336        "/** Event name → 生成 interface の map for \"{}\" (= type-narrowing 用) */\n",
337        channel.name
338    ));
339    if event_names.is_empty() {
340        code.push_str(&format!(
341            "export type {event_types_name} = Record<string, never>;\n\n"
342        ));
343    } else {
344        code.push_str(&format!("export interface {event_types_name} {{\n"));
345        for n in &event_names {
346            code.push_str(&format!("  {n}: {n};\n"));
347        }
348        code.push_str("}\n\n");
349    }
350
351    // Request type map.
352    let request_types_name = format!("{pascal}ChannelRequestTypes");
353    code.push_str(&format!(
354        "/** Request name → {{ request, response }} 生成 interface の map for \"{}\" */\n",
355        channel.name
356    ));
357    if request_mappings.is_empty() {
358        code.push_str(&format!(
359            "export type {request_types_name} = Record<string, never>;\n\n"
360        ));
361    } else {
362        code.push_str(&format!("export interface {request_types_name} {{\n"));
363        for (req_name, resp_type) in &request_mappings {
364            code.push_str(&format!(
365                "  {req_name}: {{ request: {req_name}; response: {resp_type} }};\n"
366            ));
367        }
368        code.push_str("}\n\n");
369    }
370
371    // Channel metadata const.
372    let meta_name = format!("{pascal}ChannelMeta");
373    code.push_str(&format!(
374        "/** Channel metadata for \"{}\" (= Phase 2 runtime SDK 用 type-narrowing 入力) */\n",
375        channel.name
376    ));
377    code.push_str(&format!("export const {meta_name} = {{\n"));
378    code.push_str(&format!("  name: {:?} as const,\n", channel.name));
379    code.push_str(&format!("  backend: {backend_str:?} as const,\n"));
380    if let Some(cid) = channel.channel_id {
381        code.push_str(&format!("  channelId: {cid} as const,\n"));
382    }
383    let from_str = match channel.from {
384        ir::ChannelFrom::Client => "client",
385        ir::ChannelFrom::Server => "server",
386        ir::ChannelFrom::Either => "either",
387    };
388    code.push_str(&format!("  from: {from_str:?} as const,\n"));
389    let lifetime_str = match channel.lifetime {
390        ir::ChannelLifetime::Transient => "transient",
391        ir::ChannelLifetime::Persistent => "persistent",
392    };
393    code.push_str(&format!("  lifetime: {lifetime_str:?} as const,\n"));
394
395    // events list.
396    if event_names.is_empty() {
397        code.push_str("  events: [] as const,\n");
398    } else {
399        code.push_str("  events: [");
400        for (i, n) in event_names.iter().enumerate() {
401            if i > 0 {
402                code.push_str(", ");
403            }
404            code.push_str(&format!("{n:?}"));
405        }
406        code.push_str("] as const,\n");
407    }
408
409    // requests mapping.
410    if request_mappings.is_empty() {
411        code.push_str("  requests: {} as const,\n");
412    } else {
413        code.push_str("  requests: {\n");
414        for (req_name, resp_type) in &request_mappings {
415            code.push_str(&format!(
416                "    {req_name}: {{ request: {req_name:?} as const, response: {resp_type:?} as const }},\n"
417            ));
418        }
419        code.push_str("  } as const,\n");
420    }
421
422    // Phantom type carrier.
423    code.push_str(&format!(
424        "  __types: undefined as unknown as {{ events: {event_types_name}; requests: {request_types_name} }},\n"
425    ));
426    code.push_str("} as const;\n");
427
428    code
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
436        ir::Field {
437            name: name.to_string(),
438            ty,
439            required,
440            flexible: false,
441            default: None,
442            description: None,
443            constraints: ir::Constraints::default(),
444        }
445    }
446
447    #[test]
448    fn emits_header() {
449        let out = TypeScriptEmitter::new().emit(&ir::Schema::default());
450        assert!(out.contains("// DO NOT EDIT MANUALLY"));
451        assert!(out.contains("export type Timestamp = string;"));
452        assert!(out.contains("export type UUID = string;"));
453    }
454
455    #[test]
456    fn emits_interface_with_optional_field() {
457        let schema = ir::Schema {
458            types: vec![ir::TypeDef::Struct {
459                name: "User".to_string(),
460                description: None,
461                fields: vec![
462                    field("name", ir::Ty::Primitive(ir::Prim::String), true),
463                    field("nick", ir::Ty::Primitive(ir::Prim::String), false),
464                ],
465            }],
466            protocol: None,
467            ..Default::default()
468        };
469        let out = TypeScriptEmitter::new().emit(&schema);
470        assert!(out.contains("export interface User {"));
471        assert!(out.contains("  name: string;"));
472        assert!(out.contains("  nick?: string;"));
473    }
474
475    #[test]
476    fn emits_enum() {
477        let schema = ir::Schema {
478            types: vec![ir::TypeDef::Enum {
479                name: "Role".to_string(),
480                description: None,
481                variants: vec!["admin".to_string(), "guest_user".to_string()],
482            }],
483            protocol: None,
484            ..Default::default()
485        };
486        let out = TypeScriptEmitter::new().emit(&schema);
487        assert!(out.contains("export enum Role {"));
488        assert!(out.contains("  Admin = 'admin',"));
489        assert!(out.contains("  GuestUser = 'guest_user',"));
490    }
491
492    #[test]
493    fn maps_types() {
494        let schema = ir::Schema {
495            types: vec![ir::TypeDef::Struct {
496                name: "T".to_string(),
497                description: None,
498                fields: vec![
499                    field("n", ir::Ty::Primitive(ir::Prim::Int), true),
500                    field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
501                    field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
502                    field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
503                    field(
504                        "tags",
505                        ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
506                        true,
507                    ),
508                    field("owner", ir::Ty::Named("user_account".to_string()), true),
509                ],
510            }],
511            protocol: None,
512            ..Default::default()
513        };
514        let out = TypeScriptEmitter::new().emit(&schema);
515        assert!(out.contains("  n: number;"));
516        assert!(out.contains("  b: boolean;"));
517        assert!(out.contains("  at: Timestamp;"));
518        assert!(out.contains("  blob: any;"));
519        assert!(out.contains("  tags: string[];"));
520        assert!(out.contains("  owner: UserAccount;"));
521    }
522
523    #[test]
524    fn emits_channel_interfaces_and_meta() {
525        let schema = ir::Schema {
526            types: vec![],
527            records: vec![],
528            relations: vec![],
529            protocol: Some(ir::Protocol {
530                name: "ping-pong".to_string(),
531                version: "2.0.0".to_string(),
532                namespace: Some("demo".to_string()),
533                description: None,
534                channels: vec![ir::Channel {
535                    name: "ping-pong".to_string(),
536                    from: ir::ChannelFrom::Client,
537                    lifetime: ir::ChannelLifetime::Persistent,
538                    backend: ir::ChannelBackend::Stream,
539                    channel_id: None,
540                    requests: vec![ir::Request {
541                        name: "Ping".to_string(),
542                        fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
543                        returns: Some(ir::Message {
544                            name: "Pong".to_string(),
545                            fields: vec![field("seq", ir::Ty::Primitive(ir::Prim::Int), true)],
546                        }),
547                    }],
548                    events: vec![ir::Event {
549                        name: "Tick".to_string(),
550                        fields: vec![],
551                    }],
552                }],
553            }),
554        };
555        let out = TypeScriptEmitter::new().emit(&schema);
556        assert!(out.contains("// Namespace: demo"));
557        assert!(out.contains("// Channel: ping-pong (backend=stream)"));
558        assert!(out.contains("/** Request \"Ping\" */"));
559        assert!(out.contains("export interface Ping {"));
560        assert!(out.contains("/** Response \"Pong\" */"));
561        assert!(out.contains("/** Event \"Tick\" — empty payload */"));
562        assert!(out.contains("export interface Tick {}"));
563        assert!(out.contains("export interface PingPongChannelEventTypes {"));
564        assert!(out.contains("export interface PingPongChannelRequestTypes {"));
565        assert!(out.contains("  Ping: { request: Ping; response: Pong };"));
566        assert!(out.contains("export const PingPongChannelMeta = {"));
567        assert!(out.contains("  name: \"ping-pong\" as const,"));
568        assert!(out.contains("  backend: \"stream\" as const,"));
569        assert!(out.contains("  from: \"client\" as const,"));
570        assert!(out.contains("  lifetime: \"persistent\" as const,"));
571        assert!(out.contains("  events: [\"Tick\"] as const,"));
572    }
573
574    #[test]
575    fn datagram_channel_meta_carries_channel_id() {
576        let schema = ir::Schema {
577            types: vec![],
578            records: vec![],
579            relations: vec![],
580            protocol: Some(ir::Protocol {
581                name: "telemetry".to_string(),
582                version: "1.0.0".to_string(),
583                namespace: None,
584                description: None,
585                channels: vec![ir::Channel {
586                    name: "metrics".to_string(),
587                    from: ir::ChannelFrom::Server,
588                    lifetime: ir::ChannelLifetime::Persistent,
589                    backend: ir::ChannelBackend::Datagram,
590                    channel_id: Some(7),
591                    requests: vec![],
592                    events: vec![ir::Event {
593                        name: "Sample".to_string(),
594                        fields: vec![field("v", ir::Ty::Primitive(ir::Prim::Float), true)],
595                    }],
596                }],
597            }),
598        };
599        let out = TypeScriptEmitter::new().emit(&schema);
600        assert!(out.contains("// Channel: metrics (backend=datagram, channel_id=7)"));
601        assert!(out.contains("  channelId: 7 as const,"));
602        assert!(out.contains("  requests: {} as const,"));
603        assert!(out.contains("export type MetricsChannelRequestTypes = Record<string, never>;"));
604    }
605
606    // -------------------------------------------------------------------------
607    // Tier 1 — record / relation / link / literal / union
608    // -------------------------------------------------------------------------
609
610    #[test]
611    fn record_becomes_interface_with_id() {
612        let schema = ir::Schema {
613            records: vec![ir::Record {
614                name: "Atlas".to_string(),
615                description: None,
616                id_strategy: ir::IdStrategy::Uuidv7,
617                fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
618            }],
619            ..Default::default()
620        };
621        let out = TypeScriptEmitter::new().emit(&schema);
622        assert!(out.contains("export interface Atlas {"));
623        assert!(out.contains("  id: string;"));
624        assert!(out.contains("  name: string;"));
625    }
626
627    #[test]
628    fn relation_interface_is_pascal_cased_with_in_out() {
629        let schema = ir::Schema {
630            relations: vec![ir::Relation {
631                name: "derivedFrom".to_string(),
632                description: None,
633                from: "Memory".to_string(),
634                to: "Memory".to_string(),
635                unique: true,
636                fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
637            }],
638            ..Default::default()
639        };
640        let out = TypeScriptEmitter::new().emit(&schema);
641        assert!(out.contains("export interface DerivedFrom {"));
642        assert!(out.contains("  id: string;"));
643        assert!(out.contains("  in: string;"));
644        assert!(out.contains("  out: string;"));
645        assert!(out.contains("  reason?: string;"));
646    }
647
648    #[test]
649    fn link_literal_and_union_map_to_ts_types() {
650        let schema = ir::Schema {
651            records: vec![ir::Record {
652                name: "Doc".to_string(),
653                description: None,
654                id_strategy: ir::IdStrategy::Uuidv7,
655                fields: vec![
656                    field("parent", ir::Ty::Link("Doc".to_string()), false),
657                    field(
658                        "visibility",
659                        ir::Ty::Union(vec![
660                            ir::Ty::Literal("public".to_string()),
661                            ir::Ty::Literal("private".to_string()),
662                        ]),
663                        true,
664                    ),
665                ],
666            }],
667            ..Default::default()
668        };
669        let out = TypeScriptEmitter::new().emit(&schema);
670        assert!(out.contains("  parent?: string;"), "link → string");
671        assert!(
672            out.contains("  visibility: 'public' | 'private';"),
673            "literal union → TS union of literals"
674        );
675    }
676
677    // -------------------------------------------------------------------------
678    // Tier 2 — description -> JSDoc (constraints are not emitted)
679    // -------------------------------------------------------------------------
680
681    #[test]
682    fn interface_and_field_descriptions_become_jsdoc() {
683        let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
684        content.description = Some("Memory content text".to_string());
685        let schema = ir::Schema {
686            types: vec![ir::TypeDef::Struct {
687                name: "Memory".to_string(),
688                description: Some("User memory".to_string()),
689                fields: vec![content],
690            }],
691            ..Default::default()
692        };
693        let out = TypeScriptEmitter::new().emit(&schema);
694        assert!(out.contains("/** User memory */\n"), "interface JSDoc");
695        assert!(
696            out.contains("  /** Memory content text */\n"),
697            "field JSDoc"
698        );
699    }
700
701    #[test]
702    fn enum_description_becomes_jsdoc() {
703        let schema = ir::Schema {
704            types: vec![ir::TypeDef::Enum {
705                name: "Role".to_string(),
706                description: Some("An access role".to_string()),
707                variants: vec!["admin".to_string()],
708            }],
709            ..Default::default()
710        };
711        let out = TypeScriptEmitter::new().emit(&schema);
712        assert!(out.contains("/** An access role */\n"));
713    }
714
715    #[test]
716    fn constraints_do_not_appear_in_typescript_output() {
717        // TypeScript's type system cannot express min/max/pattern.
718        let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
719        f.constraints = ir::Constraints {
720            min: Some(0),
721            max: Some(1),
722            ..Default::default()
723        };
724        let schema = ir::Schema {
725            types: vec![ir::TypeDef::Struct {
726                name: "T".to_string(),
727                description: None,
728                fields: vec![f],
729            }],
730            ..Default::default()
731        };
732        let out = TypeScriptEmitter::new().emit(&schema);
733        assert!(out.contains("  confidence: number;"));
734        assert!(!out.contains("@minimum"), "no constraint metadata leaks");
735    }
736}