1use crate::Emitter;
27use crate::ir;
28
29use super::case::to_pascal_case;
30
31#[derive(Debug, Default, Clone, Copy)]
33pub struct ZodEmitter;
34
35impl ZodEmitter {
36 pub fn new() -> Self {
38 Self
39 }
40}
41
42impl Emitter for ZodEmitter {
43 fn emit(&self, schema: &ir::Schema) -> String {
44 let mut code = String::new();
45 code.push_str(HEADER);
46 code.push('\n');
47
48 for ty in &schema.types {
50 if let ir::TypeDef::Enum {
51 name,
52 description,
53 variants,
54 } = ty
55 {
56 code.push_str(&render_enum(name, description.as_deref(), variants));
57 code.push_str("\n\n");
58 }
59 }
60 for ty in &schema.types {
62 if let ir::TypeDef::Struct {
63 name,
64 description,
65 fields,
66 } = ty
67 {
68 code.push_str(&render_object(name, description.as_deref(), fields));
69 code.push_str("\n\n");
70 }
71 }
72
73 for record in &schema.records {
75 code.push_str(&render_object(
76 &record.name,
77 record.description.as_deref(),
78 &record_members(record),
79 ));
80 code.push_str("\n\n");
81 }
82 for relation in &schema.relations {
83 code.push_str(&render_object(
84 &relation.name,
85 relation.description.as_deref(),
86 &relation_members(relation),
87 ));
88 code.push_str("\n\n");
89 }
90
91 if let Some(protocol) = &schema.protocol {
93 for channel in &protocol.channels {
94 for req in &channel.requests {
95 code.push_str(&render_object(&req.name, None, &req.fields));
96 code.push_str("\n\n");
97 if let Some(returns) = &req.returns {
98 code.push_str(&render_object(&returns.name, None, &returns.fields));
99 code.push_str("\n\n");
100 }
101 }
102 for evt in &channel.events {
103 code.push_str(&render_object(&evt.name, None, &evt.fields));
104 code.push_str("\n\n");
105 }
106 if let Some(tag) = &channel.envelope
109 && !channel.requests.is_empty()
110 {
111 code.push_str(&render_envelope(channel, tag));
112 code.push_str("\n\n");
113 }
114 }
115 }
116
117 code
118 }
119}
120
121const HEADER: &str = "\
123// Auto-generated Zod schemas
124// DO NOT EDIT MANUALLY
125import { z } from \"zod\";
126";
127
128fn js_string(text: &str) -> String {
131 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
132 format!("\"{escaped}\"")
133}
134
135fn describe_suffix(description: Option<&str>) -> String {
138 match description {
139 Some(text) => format!(".describe({})", js_string(text)),
140 None => String::new(),
141 }
142}
143
144fn render_enum(name: &str, description: Option<&str>, variants: &[String]) -> String {
146 let vs: Vec<String> = variants.iter().map(|v| format!("\"{v}\"")).collect();
147 format!(
148 "export const {} = z.enum([{}]){};",
149 to_pascal_case(name),
150 vs.join(", "),
151 describe_suffix(description),
152 )
153}
154
155fn render_object(name: &str, description: Option<&str>, fields: &[ir::Field]) -> String {
157 let pascal = to_pascal_case(name);
158 let describe = describe_suffix(description);
159 if fields.is_empty() {
160 return format!("export const {pascal} = z.object({{}}){describe};");
161 }
162 let body: Vec<String> = fields.iter().map(render_field).collect();
163 format!(
164 "export const {pascal} = z.object({{\n{}\n}}){describe};",
165 body.join("\n")
166 )
167}
168
169fn render_envelope(channel: &ir::Channel, tag: &str) -> String {
176 let name = format!("{}Envelope", to_pascal_case(&channel.name));
177 let mut out = format!(
178 "export const {name} = z.discriminatedUnion({}, [\n",
179 js_string(tag)
180 );
181 for req in &channel.requests {
182 out.push_str(&format!(
183 " {}.extend({{ {tag}: z.literal({}) }}),\n",
184 to_pascal_case(&req.name),
185 js_string(&req.name),
186 ));
187 }
188 out.push_str("]);");
189 out
190}
191
192fn constraint_suffix(c: &ir::Constraints) -> String {
198 let mut out = String::new();
199 if let Some(min) = c.min.or(c.min_length.map(|n| n as i64)) {
200 out.push_str(&format!(".min({min})"));
201 }
202 if let Some(max) = c.max.or(c.max_length.map(|n| n as i64)) {
203 out.push_str(&format!(".max({max})"));
204 }
205 if let Some(pattern) = &c.pattern {
206 out.push_str(&format!(".regex(/{pattern}/)"));
207 }
208 out
209}
210
211fn render_field(field: &ir::Field) -> String {
214 let mut schema = ty_to_zod(&field.ty);
215 schema.push_str(&constraint_suffix(&field.constraints));
216 if let Some(desc) = &field.description {
217 schema.push_str(&format!(".describe({})", js_string(desc)));
218 }
219 if !field.required {
220 schema.push_str(".optional()");
221 }
222 format!(" {}: {},", field.name, schema)
223}
224
225fn id_member() -> ir::Field {
227 ir::Field {
228 name: "id".to_string(),
229 ty: ir::Ty::Primitive(ir::Prim::String),
230 required: true,
231 flexible: false,
232 default: None,
233 description: None,
234 constraints: ir::Constraints::default(),
235 }
236}
237
238fn record_members(record: &ir::Record) -> Vec<ir::Field> {
240 let mut members = Vec::with_capacity(record.fields.len() + 1);
241 members.push(id_member());
242 members.extend(record.fields.iter().cloned());
243 members
244}
245
246fn relation_members(relation: &ir::Relation) -> Vec<ir::Field> {
249 let endpoint = |name: &str| ir::Field {
250 name: name.to_string(),
251 ty: ir::Ty::Primitive(ir::Prim::String),
252 required: true,
253 flexible: false,
254 default: None,
255 description: None,
256 constraints: ir::Constraints::default(),
257 };
258 let mut members = Vec::with_capacity(relation.fields.len() + 3);
259 members.push(id_member());
260 members.push(endpoint("in"));
261 members.push(endpoint("out"));
262 members.extend(relation.fields.iter().cloned());
263 members
264}
265
266fn ty_to_zod(ty: &ir::Ty) -> String {
268 match ty {
269 ir::Ty::Primitive(p) => prim_to_zod(*p).to_string(),
270 ir::Ty::Array(inner) => format!("z.array({})", ty_to_zod(inner)),
271 ir::Ty::Named(name) => to_pascal_case(name),
273 ir::Ty::Link(_) => "z.string()".to_string(),
275 ir::Ty::Literal(value) => format!("z.literal(\"{value}\")"),
277 ir::Ty::Union(members) => {
278 if let Some(values) = literal_union_values(members) {
280 let vs: Vec<String> = values.iter().map(|v| format!("\"{v}\"")).collect();
281 format!("z.enum([{}])", vs.join(", "))
282 } else {
283 let mut parts: Vec<String> = Vec::new();
284 for m in members {
285 let p = ty_to_zod(m);
286 if !parts.contains(&p) {
287 parts.push(p);
288 }
289 }
290 if parts.len() == 1 {
292 parts.into_iter().next().unwrap()
293 } else {
294 format!("z.union([{}])", parts.join(", "))
295 }
296 }
297 }
298 }
299}
300
301fn literal_union_values(members: &[ir::Ty]) -> Option<Vec<String>> {
303 members
304 .iter()
305 .map(|m| match m {
306 ir::Ty::Literal(v) => Some(v.clone()),
307 _ => None,
308 })
309 .collect()
310}
311
312fn prim_to_zod(p: ir::Prim) -> &'static str {
314 match p {
315 ir::Prim::String => "z.string()",
316 ir::Prim::Int => "z.number().int()",
317 ir::Prim::Float => "z.number()",
318 ir::Prim::Bool => "z.boolean()",
319 ir::Prim::Datetime => "z.string().datetime()",
320 ir::Prim::Json => "z.unknown()",
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
329 ir::Field {
330 name: name.to_string(),
331 ty,
332 required,
333 flexible: false,
334 default: None,
335 description: None,
336 constraints: ir::Constraints::default(),
337 }
338 }
339
340 #[test]
341 fn emits_header() {
342 let out = ZodEmitter::new().emit(&ir::Schema::default());
343 assert!(out.contains("import { z } from \"zod\";"));
344 assert!(out.contains("// DO NOT EDIT MANUALLY"));
345 }
346
347 #[test]
348 fn emits_enum_as_z_enum() {
349 let schema = ir::Schema {
350 types: vec![ir::TypeDef::Enum {
351 name: "Role".to_string(),
352 description: None,
353 variants: vec!["admin".to_string(), "member".to_string()],
354 }],
355 protocol: None,
356 ..Default::default()
357 };
358 let out = ZodEmitter::new().emit(&schema);
359 assert!(out.contains("export const Role = z.enum([\"admin\", \"member\"]);"));
360 }
361
362 #[test]
363 fn emits_object_with_optional_field() {
364 let schema = ir::Schema {
365 types: vec![ir::TypeDef::Struct {
366 name: "User".to_string(),
367 description: None,
368 fields: vec![
369 field("name", ir::Ty::Primitive(ir::Prim::String), true),
370 field("nick", ir::Ty::Primitive(ir::Prim::String), false),
371 ],
372 }],
373 protocol: None,
374 ..Default::default()
375 };
376 let out = ZodEmitter::new().emit(&schema);
377 assert!(out.contains("export const User = z.object({"));
378 assert!(out.contains(" name: z.string(),"));
379 assert!(out.contains(" nick: z.string().optional(),"));
380 }
381
382 #[test]
383 fn maps_primitive_and_compound_types() {
384 let schema = ir::Schema {
385 types: vec![ir::TypeDef::Struct {
386 name: "T".to_string(),
387 description: None,
388 fields: vec![
389 field("n", ir::Ty::Primitive(ir::Prim::Int), true),
390 field("f", ir::Ty::Primitive(ir::Prim::Float), true),
391 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
392 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
393 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
394 field(
395 "tags",
396 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
397 true,
398 ),
399 ],
400 }],
401 protocol: None,
402 ..Default::default()
403 };
404 let out = ZodEmitter::new().emit(&schema);
405 assert!(out.contains(" n: z.number().int(),"));
406 assert!(out.contains(" f: z.number(),"));
407 assert!(out.contains(" b: z.boolean(),"));
408 assert!(out.contains(" at: z.string().datetime(),"));
409 assert!(out.contains(" blob: z.unknown(),"));
410 assert!(out.contains(" tags: z.array(z.string()),"));
411 }
412
413 #[test]
414 fn enum_is_emitted_before_referencing_struct() {
415 let schema = ir::Schema {
419 types: vec![
420 ir::TypeDef::Struct {
421 name: "User".to_string(),
422 description: None,
423 fields: vec![field("role", ir::Ty::Named("Role".to_string()), true)],
424 },
425 ir::TypeDef::Enum {
426 name: "Role".to_string(),
427 description: None,
428 variants: vec!["admin".to_string()],
429 },
430 ],
431 protocol: None,
432 ..Default::default()
433 };
434 let out = ZodEmitter::new().emit(&schema);
435 let enum_pos = out.find("export const Role").expect("enum emitted");
436 let struct_pos = out.find("export const User").expect("struct emitted");
437 assert!(
438 enum_pos < struct_pos,
439 "enum must precede the struct using it"
440 );
441 assert!(out.contains(" role: Role,"));
442 }
443
444 #[test]
445 fn emits_protocol_payload_schemas() {
446 let schema = ir::Schema {
447 types: vec![],
448 records: vec![],
449 relations: vec![],
450 protocol: Some(ir::Protocol {
451 name: "chat".to_string(),
452 version: "1.0.0".to_string(),
453 namespace: None,
454 description: None,
455 channels: vec![ir::Channel {
456 name: "messaging".to_string(),
457 from: ir::ChannelFrom::Client,
458 lifetime: ir::ChannelLifetime::Persistent,
459 backend: ir::ChannelBackend::Stream,
460 channel_id: None,
461 envelope: None,
462 requests: vec![ir::Request {
463 name: "Send".to_string(),
464 fields: vec![field("body", ir::Ty::Primitive(ir::Prim::String), true)],
465 returns: Some(ir::Message {
466 name: "Ack".to_string(),
467 fields: vec![field("id", ir::Ty::Primitive(ir::Prim::String), true)],
468 }),
469 }],
470 events: vec![],
471 }],
472 }),
473 };
474 let out = ZodEmitter::new().emit(&schema);
475 assert!(out.contains("export const Send = z.object({"));
476 assert!(out.contains("export const Ack = z.object({"));
477 }
478
479 fn sidebar_schema(envelope: Option<&str>) -> ir::Schema {
486 ir::Schema {
487 protocol: Some(ir::Protocol {
488 name: "sidebar".to_string(),
489 version: "1.0.0".to_string(),
490 namespace: None,
491 description: None,
492 channels: vec![ir::Channel {
493 name: "ipc".to_string(),
494 from: ir::ChannelFrom::Client,
495 lifetime: ir::ChannelLifetime::Transient,
496 backend: ir::ChannelBackend::Stream,
497 channel_id: None,
498 envelope: envelope.map(str::to_string),
499 requests: vec![
500 ir::Request {
501 name: "process:toggle".to_string(),
502 fields: vec![field("path", ir::Ty::Primitive(ir::Prim::String), true)],
503 returns: None,
504 },
505 ir::Request {
506 name: "process:add".to_string(),
507 fields: vec![],
508 returns: None,
509 },
510 ],
511 events: vec![],
512 }],
513 }),
514 ..Default::default()
515 }
516 }
517
518 #[test]
519 fn channel_request_schema_names_are_sanitized() {
520 let out = ZodEmitter::new().emit(&sidebar_schema(None));
523 assert!(out.contains("export const ProcessToggle = z.object({"));
524 assert!(out.contains("export const ProcessAdd = z.object({})"));
525 }
526
527 #[test]
528 fn channel_without_envelope_emits_no_discriminated_union() {
529 let out = ZodEmitter::new().emit(&sidebar_schema(None));
530 assert!(
531 !out.contains("z.discriminatedUnion"),
532 "no envelope ⇒ no union"
533 );
534 }
535
536 #[test]
537 fn envelope_channel_emits_discriminated_union() {
538 let out = ZodEmitter::new().emit(&sidebar_schema(Some("t")));
539 assert!(out.contains("export const IpcEnvelope = z.discriminatedUnion(\"t\", ["));
540 assert!(out.contains(" ProcessToggle.extend({ t: z.literal(\"process:toggle\") }),"));
541 assert!(out.contains(" ProcessAdd.extend({ t: z.literal(\"process:add\") }),"));
542 let obj = out.find("export const ProcessToggle").unwrap();
544 let union = out.find("export const IpcEnvelope").unwrap();
545 assert!(
546 obj < union,
547 "request objects precede the discriminated union"
548 );
549 }
550
551 #[test]
556 fn record_becomes_object_with_id() {
557 let schema = ir::Schema {
558 records: vec![ir::Record {
559 name: "Atlas".to_string(),
560 description: None,
561 id_strategy: ir::IdStrategy::Uuidv7,
562 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
563 }],
564 ..Default::default()
565 };
566 let out = ZodEmitter::new().emit(&schema);
567 assert!(out.contains("export const Atlas = z.object({"));
568 assert!(out.contains(" id: z.string(),"));
569 assert!(out.contains(" name: z.string(),"));
570 }
571
572 #[test]
573 fn relation_object_is_pascal_cased_with_in_out() {
574 let schema = ir::Schema {
575 relations: vec![ir::Relation {
576 name: "derivedFrom".to_string(),
577 description: None,
578 from: "Memory".to_string(),
579 to: "Memory".to_string(),
580 unique: false,
581 fields: vec![field("reason", ir::Ty::Primitive(ir::Prim::String), false)],
582 }],
583 ..Default::default()
584 };
585 let out = ZodEmitter::new().emit(&schema);
586 assert!(out.contains("export const DerivedFrom = z.object({"));
587 assert!(out.contains(" id: z.string(),"));
588 assert!(out.contains(" in: z.string(),"));
589 assert!(out.contains(" out: z.string(),"));
590 assert!(out.contains(" reason: z.string().optional(),"));
591 }
592
593 #[test]
594 fn link_becomes_z_string() {
595 let schema = ir::Schema {
596 records: vec![ir::Record {
597 name: "Atlas".to_string(),
598 description: None,
599 id_strategy: ir::IdStrategy::Uuidv7,
600 fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), true)],
601 }],
602 ..Default::default()
603 };
604 let out = ZodEmitter::new().emit(&schema);
605 assert!(out.contains(" parent: z.string(),"));
606 }
607
608 #[test]
609 fn literal_union_collapses_to_z_enum() {
610 let schema = ir::Schema {
611 types: vec![ir::TypeDef::Struct {
612 name: "T".to_string(),
613 description: None,
614 fields: vec![field(
615 "visibility",
616 ir::Ty::Union(vec![
617 ir::Ty::Literal("public".to_string()),
618 ir::Ty::Literal("private".to_string()),
619 ]),
620 true,
621 )],
622 }],
623 ..Default::default()
624 };
625 let out = ZodEmitter::new().emit(&schema);
626 assert!(out.contains(" visibility: z.enum([\"public\", \"private\"]),"));
627 }
628
629 #[test]
630 fn mixed_union_becomes_z_union() {
631 let schema = ir::Schema {
632 types: vec![ir::TypeDef::Struct {
633 name: "T".to_string(),
634 description: None,
635 fields: vec![field(
636 "v",
637 ir::Ty::Union(vec![
638 ir::Ty::Primitive(ir::Prim::String),
639 ir::Ty::Primitive(ir::Prim::Int),
640 ]),
641 true,
642 )],
643 }],
644 ..Default::default()
645 };
646 let out = ZodEmitter::new().emit(&schema);
647 assert!(out.contains(" v: z.union([z.string(), z.number().int()]),"));
648 }
649
650 #[test]
655 fn object_and_field_descriptions_become_describe_calls() {
656 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
657 content.description = Some("Memory content text".to_string());
658 let schema = ir::Schema {
659 types: vec![ir::TypeDef::Struct {
660 name: "Memory".to_string(),
661 description: Some("User memory".to_string()),
662 fields: vec![content],
663 }],
664 ..Default::default()
665 };
666 let out = ZodEmitter::new().emit(&schema);
667 assert!(
668 out.contains("}).describe(\"User memory\");"),
669 "object .describe()"
670 );
671 assert!(
672 out.contains("z.string().describe(\"Memory content text\")"),
673 "field .describe()"
674 );
675 }
676
677 #[test]
678 fn enum_description_becomes_describe_call() {
679 let schema = ir::Schema {
680 types: vec![ir::TypeDef::Enum {
681 name: "Role".to_string(),
682 description: Some("An access role".to_string()),
683 variants: vec!["admin".to_string()],
684 }],
685 ..Default::default()
686 };
687 let out = ZodEmitter::new().emit(&schema);
688 assert!(out.contains("z.enum([\"admin\"]).describe(\"An access role\");"));
689 }
690
691 #[test]
692 fn numeric_constraints_become_min_max() {
693 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
694 f.constraints = ir::Constraints {
695 min: Some(0),
696 max: Some(1),
697 ..Default::default()
698 };
699 let schema = ir::Schema {
700 types: vec![ir::TypeDef::Struct {
701 name: "T".to_string(),
702 description: None,
703 fields: vec![f],
704 }],
705 ..Default::default()
706 };
707 let out = ZodEmitter::new().emit(&schema);
708 assert!(out.contains(" confidence: z.number().min(0).max(1),"));
709 }
710
711 #[test]
712 fn string_length_and_pattern_constraints_are_emitted() {
713 let mut f = field("name", ir::Ty::Primitive(ir::Prim::String), true);
714 f.constraints = ir::Constraints {
715 min_length: Some(1),
716 max_length: Some(32),
717 pattern: Some("^[a-z]+$".to_string()),
718 ..Default::default()
719 };
720 let schema = ir::Schema {
721 types: vec![ir::TypeDef::Struct {
722 name: "T".to_string(),
723 description: None,
724 fields: vec![f],
725 }],
726 ..Default::default()
727 };
728 let out = ZodEmitter::new().emit(&schema);
729 assert!(
730 out.contains(" name: z.string().min(1).max(32).regex(/^[a-z]+$/),"),
731 "got: {out}"
732 );
733 }
734
735 #[test]
736 fn constraint_and_describe_precede_optional_wrapper() {
737 let mut f = field("nick", ir::Ty::Primitive(ir::Prim::String), false);
738 f.constraints = ir::Constraints {
739 max_length: Some(8),
740 ..Default::default()
741 };
742 f.description = Some("nickname".to_string());
743 let schema = ir::Schema {
744 types: vec![ir::TypeDef::Struct {
745 name: "T".to_string(),
746 description: None,
747 fields: vec![f],
748 }],
749 ..Default::default()
750 };
751 let out = ZodEmitter::new().emit(&schema);
752 assert!(
753 out.contains("z.string().max(8).describe(\"nickname\").optional()"),
754 ".optional() must come last; got: {out}"
755 );
756 }
757}