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
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 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 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 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 #[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 #[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 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}