Skip to main content

kdl_codegen/emit/
zod.rs

1//! Zod emitter — renders [`ir::Schema`] into Zod schema source (TypeScript).
2//!
3//! Zod schemas are runtime validators. The generated `export const` values
4//! mirror the data dialect's `struct` / `enum`, the entity dialect's
5//! `record` / `relation`, and the protocol dialect's request / response /
6//! event payloads.
7//!
8//! ## Tier 1 type mapping
9//!
10//! - `link<Record>` → `z.string()` (the linked record's id).
11//! - `'literal'` → `z.literal("value")`.
12//! - `A | B` → `z.union([...])`; a union of string literals collapses to a
13//!   `z.enum([...])` (Zod's idiomatic closed-string-set validator).
14//! - `record` → a `z.object({...})` with a leading `id: z.string()`.
15//! - `relation` → a `z.object({...})` with `id` / `in` / `out: z.string()`.
16//!
17//! ## Ordering
18//!
19//! Unlike the TypeScript emitter (whose `interface` declarations are types and
20//! thus order-independent), a Zod schema is a **value** — `z.object({ role:
21//! Role })` needs `Role` defined first. The emitter therefore writes all
22//! `enum` schemas before any `object` schema. Struct-to-struct references rely
23//! on source order (a forward reference between two structs is uncommon and
24//! out of Phase 1 scope).
25
26use crate::Emitter;
27use crate::ir;
28
29use super::case::to_pascal_case;
30
31/// The Zod code generation target.
32#[derive(Debug, Default, Clone, Copy)]
33pub struct ZodEmitter;
34
35impl ZodEmitter {
36    /// Create a new [`ZodEmitter`].
37    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        // enums first — a Zod schema is a value and cannot be forward-referenced.
49        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        // then structs.
61        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        // entity dialect — records and relations.
74        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        // protocol dialect — request / response / event payload schemas.
92        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                // discriminated-union envelope over the channel's requests,
107                // emitted after the per-request objects it references.
108                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
121/// Fixed header block.
122const HEADER: &str = "\
123// Auto-generated Zod schemas
124// DO NOT EDIT MANUALLY
125import { z } from \"zod\";
126";
127
128/// Render a JavaScript string literal (double-quoted, with `"` and `\`
129/// escaped) for use inside a generated Zod call such as `.describe(...)`.
130fn js_string(text: &str) -> String {
131    let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
132    format!("\"{escaped}\"")
133}
134
135/// Append a `.describe("...")` call when the type definition carries a
136/// description.
137fn describe_suffix(description: Option<&str>) -> String {
138    match description {
139        Some(text) => format!(".describe({})", js_string(text)),
140        None => String::new(),
141    }
142}
143
144/// Render an `enum` as a `z.enum([...])`.
145fn 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
155/// Render a `struct` / payload as a `z.object({...})`.
156fn 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
169/// Render the channel's envelope as a `z.discriminatedUnion`.
170///
171/// Each member extends a per-request `z.object` with the discriminator
172/// (`tag`) literal, so a runtime value carrying `{ "<tag>": "<wire>" }`
173/// validates against exactly one request payload. A fieldless request's
174/// object is `z.object({})`, which `.extend` still accepts.
175fn 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
192/// Render the constraint suffix for a Zod schema: `.min(N)` / `.max(N)` for a
193/// numeric range, `.min(N)` / `.max(N)` for a string / array length bound, and
194/// `.regex(/.../)` for a pattern. Numeric range and length bound never both
195/// apply to one field (a field is either numeric or string / array), so the
196/// shared `.min` / `.max` call sites do not collide.
197fn 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
211/// Render a single object field line, applying any `.describe(...)` and
212/// constraint suffixes before the `.optional()` wrapper.
213fn 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
225/// The synthetic `id: z.string()` member shared by records and relations.
226fn 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
238/// A record's object members: a leading `id`, then its declared fields.
239fn 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
246/// A relation's edge-object members: `id` / `in` / `out`, then its declared
247/// edge-property fields.
248fn 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
266/// Map an [`ir::Ty`] to its Zod schema expression.
267fn 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        // a named type references another generated schema by identifier.
272        ir::Ty::Named(name) => to_pascal_case(name),
273        // a link is validated as the target record's id — a string.
274        ir::Ty::Link(_) => "z.string()".to_string(),
275        // a string literal → `z.literal(...)`.
276        ir::Ty::Literal(value) => format!("z.literal(\"{value}\")"),
277        ir::Ty::Union(members) => {
278            // A union of string literals → the idiomatic `z.enum([...])`.
279            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                // members collapsing to one schema need no `z.union` wrapper.
291                if parts.len() == 1 {
292                    parts.into_iter().next().unwrap()
293                } else {
294                    format!("z.union([{}])", parts.join(", "))
295                }
296            }
297        }
298    }
299}
300
301/// If every union member is a [`ir::Ty::Literal`], return their values.
302fn 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
312/// Map an [`ir::Prim`] to its Zod schema expression.
313fn 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        // `User` references `Role`; the schema lists the struct *first*.
416        // The emitter must still place the enum before the object so the
417        // generated Zod value resolves.
418        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    // -------------------------------------------------------------------------
480    // protocol dialect — envelope discriminated union
481    // -------------------------------------------------------------------------
482
483    /// The sidebar-IPC spike channel: a `:`-bearing request name, a fieldless
484    /// request, and an optional `envelope` tag.
485    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        // `render_object` PascalCases the schema name, so a `:`-bearing
521        // request name yields a valid `export const` identifier.
522        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        // the union must follow the per-request objects it references.
543        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    // -------------------------------------------------------------------------
552    // Tier 1 — record / relation / link / literal / union
553    // -------------------------------------------------------------------------
554
555    #[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    // -------------------------------------------------------------------------
651    // Tier 2 — description -> .describe(), constraints -> .min/.max/.regex
652    // -------------------------------------------------------------------------
653
654    #[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}