1use crate::Emitter;
38use crate::ir;
39
40use super::case::to_pascal_case;
41
42#[derive(Debug, Default, Clone, Copy)]
44pub struct TypeScriptEmitter;
45
46impl TypeScriptEmitter {
47 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 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 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 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
115const 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
125fn 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
149fn 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
156fn 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
166fn 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
179fn 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
192fn 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
200fn 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
220fn 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 ir::Ty::Link(_) => "string".to_string(),
228 ir::Ty::Literal(value) => format!("'{value}'"),
230 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
245fn 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
256fn 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
266fn 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
285fn response_ident(resp: &str) -> String {
289 if resp == "void" {
290 "void".to_string()
291 } else {
292 to_pascal_case(resp)
293 }
294}
295
296fn 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 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 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 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 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 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 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 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 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 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 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 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 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
487fn 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 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 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 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 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 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 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 #[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 #[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 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}