Skip to main content

taut_rpc_cli/
codegen.rs

1//! TypeScript codegen for `cargo taut gen` (Phase 1 + 2 error narrowing,
2//! Phase 4 validator schemas).
3//!
4//! Pure logic: takes an [`taut_rpc::ir::Ir`] document and renders a single
5//! `api.gen.ts` source string. The CLI's `gen` subcommand owns the I/O and
6//! validator-flag parsing; this module owns the string-building.
7//!
8//! See `SPEC.md` §3 (type mapping), §3.3/§4.1 (error envelope), §6 (client
9//! API), and §7 (validation bridge). The shape of the generated client
10//! mirrors the runtime declarations in `npm/taut-rpc/src/index.ts` — every
11//! emitted procedure is a `ProcedureDef<I, O, E, Kind>`, and the final
12//! `Procedures` type map is what `createClient<Procedures>` consumes.
13//!
14//! Phase 2 additions:
15//! - For each procedure with a non-empty error set we also emit a
16//!   `Proc_<name>_Error` type alias right before its `ProcedureDef`. Callers
17//!   import this alias to drive `e.code` narrowing without touching `unknown`.
18//! - We emit a runtime `procedureKinds` const (`as const satisfies …`) so
19//!   `createClient` can dispatch query vs. mutation vs. subscription without
20//!   the caller having to thread the map manually. `createApi` defaults to
21//!   it but still lets users override via `opts.kinds`.
22//!
23//! Phase 4 additions:
24//! - For each `TypeDef` we additionally emit a `<Name>Schema` const using the
25//!   selected validator (`valibot` is the default, `zod` is opt-in). The
26//!   pre-existing TS interface is preserved verbatim so callers who only
27//!   want types can keep ignoring the schema.
28//! - For each procedure we emit `Proc_<name>_inputSchema` and
29//!   `Proc_<name>_outputSchema` constants — either an alias to an existing
30//!   `<Name>Schema` (for `Named` types) or an inline expression (for
31//!   primitives / composites that don't have a `TypeDef` of their own).
32//! - We emit a `procedureSchemas` runtime map mirroring `procedureKinds` so
33//!   `createClient` can locate a procedure's schemas by name.
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::fmt::Write as _;
37
38use taut_rpc::ir::{
39    Constraint, EnumDef, Field, Ir, Primitive, Procedure, TypeDef, TypeRef, TypeShape, Variant,
40    VariantPayload,
41};
42use taut_rpc::type_map::{self, BigIntStrategy};
43
44/// Validator runtime to target in the generated client.
45///
46/// Mirrors the CLI flag of the same name (see `commands/gen.rs`). Phase 4 wires
47/// `Valibot` and `Zod` through to real schema emission; `None` skips schema
48/// emission entirely (no `<Name>Schema` consts, no `procedureSchemas` map, no
49/// runtime import added).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Validator {
52    Valibot,
53    Zod,
54    None,
55}
56
57/// Per-invocation knobs for [`render_ts`].
58#[derive(Debug, Clone)]
59pub struct CodegenOptions {
60    pub validator: Validator,
61    pub bigint_strategy: BigIntStrategy,
62    /// Mirror of [`taut_rpc::type_map::Options::honor_undefined`].
63    /// Controls whether `field: T | undefined` is emitted for fields the
64    /// IR marks `undefined: true`.
65    pub honor_undefined: bool,
66}
67
68impl Default for CodegenOptions {
69    fn default() -> Self {
70        Self {
71            validator: Validator::Valibot,
72            bigint_strategy: BigIntStrategy::Native,
73            honor_undefined: true,
74        }
75    }
76}
77
78/// Render an [`Ir`] into a single `api.gen.ts` source string.
79///
80/// This is the panicking-on-internal-bug variant: duplicate type definitions
81/// with conflicting bodies will panic. Use [`render_ts_checked`] when callers
82/// want to recover from that case (e.g. surface a friendly CLI error).
83#[must_use]
84pub fn render_ts(ir: &Ir, opts: &CodegenOptions) -> String {
85    render_ts_checked(ir, opts).expect("render_ts: duplicate TypeDef names disagree")
86}
87
88/// Fallible variant of [`render_ts`].
89///
90/// Errors only on a single, well-defined condition: two `TypeDef`s share a
91/// name but have different bodies. Equal duplicates are silently deduped.
92pub fn render_ts_checked(ir: &Ir, opts: &CodegenOptions) -> Result<String, String> {
93    let mut out = String::new();
94    let tm_opts = type_map_options(opts);
95
96    write_header(&mut out, ir);
97    write_imports(&mut out, opts.validator);
98    write_types(&mut out, ir, &tm_opts)?;
99    write_schemas(&mut out, ir, opts.validator, &tm_opts);
100    write_procedures(&mut out, ir, &tm_opts);
101    write_procedures_map(&mut out, ir);
102    write_procedure_kinds(&mut out, ir);
103    write_procedure_schemas(&mut out, ir, opts.validator, &tm_opts);
104    write_create_api(&mut out);
105
106    Ok(out)
107}
108
109fn type_map_options(opts: &CodegenOptions) -> type_map::Options {
110    type_map::Options {
111        bigint: opts.bigint_strategy,
112        honor_undefined: opts.honor_undefined,
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Section emitters
118// ---------------------------------------------------------------------------
119
120fn write_header(out: &mut String, ir: &Ir) {
121    let v = ir.ir_version;
122    out.push_str("// DO NOT EDIT — generated by taut-rpc-cli.\n");
123    out.push_str("// Re-run `cargo taut gen` to refresh.\n");
124    let _ = writeln!(out, "// IR version: {v}");
125    out.push('\n');
126}
127
128fn write_imports(out: &mut String, validator: Validator) {
129    out.push_str("import type { ProcedureDef } from \"taut-rpc\";\n");
130    out.push_str("import { createClient, type ClientOptions, type ClientOf } from \"taut-rpc\";\n");
131    match validator {
132        Validator::Valibot => out.push_str("import * as v from \"valibot\";\n"),
133        Validator::Zod => out.push_str("import { z } from \"zod\";\n"),
134        Validator::None => {}
135    }
136    out.push('\n');
137}
138
139fn write_types(out: &mut String, ir: &Ir, tm_opts: &type_map::Options) -> Result<(), String> {
140    // Dedup-by-name. Equal duplicates collapse silently; conflicting ones
141    // are an error so we don't silently drop information.
142    let mut seen: BTreeMap<&str, &TypeDef> = BTreeMap::new();
143    let mut order: Vec<&TypeDef> = Vec::new();
144    for t in &ir.types {
145        match seen.get(t.name.as_str()) {
146            Some(prev) if *prev == t => {}
147            Some(_) => {
148                return Err(format!(
149                    "duplicate TypeDef `{}` with conflicting bodies",
150                    t.name
151                ));
152            }
153            None => {
154                seen.insert(t.name.as_str(), t);
155                order.push(t);
156            }
157        }
158    }
159
160    for t in order {
161        write_type_def(out, t, tm_opts);
162    }
163    Ok(())
164}
165
166fn write_type_def(out: &mut String, t: &TypeDef, tm_opts: &type_map::Options) {
167    if let Some(doc) = &t.doc {
168        write_doc_comment(out, doc, "");
169    }
170    match &t.shape {
171        TypeShape::Struct(fields) => write_struct(out, &t.name, fields, tm_opts),
172        TypeShape::Enum(e) => write_enum(out, &t.name, e, tm_opts),
173        TypeShape::Tuple(elems) => {
174            let name = &t.name;
175            let rendered = type_map::render_type(&TypeRef::Tuple(elems.clone()), tm_opts);
176            let _ = writeln!(out, "export type {name} = {rendered};\n");
177        }
178        TypeShape::Newtype(inner) | TypeShape::Alias(inner) => {
179            let name = &t.name;
180            let rendered = type_map::render_type(inner, tm_opts);
181            let _ = writeln!(out, "export type {name} = {rendered};\n");
182        }
183    }
184}
185
186fn write_struct(out: &mut String, name: &str, fields: &[Field], tm_opts: &type_map::Options) {
187    let _ = writeln!(out, "export interface {name} {{");
188    for f in fields {
189        write_field_line(out, f, tm_opts, "  ");
190    }
191    out.push_str("}\n\n");
192}
193
194fn write_field_line(out: &mut String, f: &Field, tm_opts: &type_map::Options, indent: &str) {
195    if let Some(doc) = &f.doc {
196        write_doc_comment(out, doc, indent);
197    }
198    let ty = type_map::render_type(&f.ty, tm_opts);
199    let want_undefined = f.undefined && tm_opts.honor_undefined;
200    let qmark = if f.optional { "?" } else { "" };
201    let ty_with_undef = if want_undefined {
202        format!("{ty} | undefined")
203    } else {
204        ty
205    };
206    let name = &f.name;
207    let _ = writeln!(out, "{indent}{name}{qmark}: {ty_with_undef};");
208}
209
210fn write_enum(out: &mut String, name: &str, e: &EnumDef, tm_opts: &type_map::Options) {
211    let _ = writeln!(out, "export type {name} =");
212    if e.variants.is_empty() {
213        // An empty enum is uninhabited; `never` is the closest TS analogue.
214        out.push_str("  never;\n\n");
215        return;
216    }
217    for (i, v) in e.variants.iter().enumerate() {
218        let last = i + 1 == e.variants.len();
219        let term = if last { ";" } else { "" };
220        write_variant(out, &e.tag, v, tm_opts, term);
221    }
222    out.push('\n');
223}
224
225fn write_variant(
226    out: &mut String,
227    tag: &str,
228    v: &Variant,
229    tm_opts: &type_map::Options,
230    terminator: &str,
231) {
232    match &v.payload {
233        VariantPayload::Unit => {
234            let _ = writeln!(
235                out,
236                "  | {{ {tag}: {variant} }}{terminator}",
237                variant = quoted(&v.name),
238            );
239        }
240        VariantPayload::Tuple(elems) => {
241            if elems.is_empty() {
242                let _ = writeln!(
243                    out,
244                    "  | {{ {tag}: {variant} }}{terminator}",
245                    variant = quoted(&v.name),
246                );
247            } else {
248                let inner: Vec<String> = elems
249                    .iter()
250                    .map(|t| type_map::render_type(t, tm_opts))
251                    .collect();
252                let _ = writeln!(
253                    out,
254                    "  | {{ {tag}: {variant}, payload: [{payload}] }}{terminator}",
255                    variant = quoted(&v.name),
256                    payload = inner.join(", "),
257                );
258            }
259        }
260        VariantPayload::Struct(fields) => {
261            if fields.is_empty() {
262                let _ = writeln!(
263                    out,
264                    "  | {{ {tag}: {variant} }}{terminator}",
265                    variant = quoted(&v.name),
266                );
267                return;
268            }
269            let _ = writeln!(out, "  | {{ {tag}: {variant},", variant = quoted(&v.name));
270            for (i, f) in fields.iter().enumerate() {
271                let last = i + 1 == fields.len();
272                let ty = type_map::render_type(&f.ty, tm_opts);
273                let want_undefined = f.undefined && tm_opts.honor_undefined;
274                let qmark = if f.optional { "?" } else { "" };
275                let ty_with_undef = if want_undefined {
276                    format!("{ty} | undefined")
277                } else {
278                    ty
279                };
280                let sep = if last { "" } else { "," };
281                let fname = &f.name;
282                let _ = writeln!(out, "      {fname}{qmark}: {ty_with_undef}{sep}");
283            }
284            let _ = writeln!(out, "    }}{terminator}");
285        }
286    }
287}
288
289fn write_procedures(out: &mut String, ir: &Ir, tm_opts: &type_map::Options) {
290    if ir.procedures.is_empty() {
291        return;
292    }
293    out.push_str("// ---- procedures ----\n\n");
294    for p in &ir.procedures {
295        write_procedure_alias(out, p, tm_opts);
296    }
297}
298
299fn write_procedure_alias(out: &mut String, p: &Procedure, tm_opts: &type_map::Options) {
300    if let Some(doc) = &p.doc {
301        write_doc_comment(out, doc, "");
302    }
303    let alias = procedure_alias_name(&p.name);
304    let input = type_map::render_type(&p.input, tm_opts);
305    let output = type_map::render_type(&p.output, tm_opts);
306    // Phase 3: surface the subscription dispatch hint in IntelliSense so
307    // callers see the `.subscribe(input)` shape on hover. The matching
308    // type-level dispatch lives in `ClientOf<P>` (see npm/taut-rpc/src/index.ts).
309    if matches!(p.kind, taut_rpc::ir::ProcKind::Subscription) {
310        out.push_str(
311            "/** Subscription procedure — call via `.subscribe(input)`, returns AsyncIterable. */\n",
312        );
313    }
314    let _ = writeln!(
315        out,
316        "// kind: {kind:?}, http: {method:?}, name: {name}",
317        kind = p.kind,
318        method = p.http_method,
319        name = p.name,
320    );
321
322    // Phase 2: emit a per-procedure error alias when the procedure has any
323    // declared errors, so callers can narrow on `.code` via the alias's
324    // discriminant. Procedures with no errors keep `never` as the third
325    // ProcedureDef type arg (same as Phase 1) and skip the alias entirely.
326    let error_arg = if p.errors.is_empty() {
327        "never".to_string()
328    } else {
329        let error_alias = procedure_error_alias_name(&p.name);
330        let union = render_error_union(&p.errors, tm_opts);
331        let _ = writeln!(
332            out,
333            "/** Wire-shape error union for procedure `{name}`. Narrow on `.code`. */",
334            name = p.name,
335        );
336        let _ = writeln!(out, "export type {error_alias} = {union};");
337        error_alias
338    };
339
340    let kind_lit = match p.kind {
341        taut_rpc::ir::ProcKind::Query => "\"query\"",
342        taut_rpc::ir::ProcKind::Mutation => "\"mutation\"",
343        taut_rpc::ir::ProcKind::Subscription => "\"subscription\"",
344    };
345    let _ = writeln!(
346        out,
347        "export type {alias} = ProcedureDef<{input}, {output}, {error_arg}, {kind_lit}>;\n",
348    );
349}
350
351fn render_error_union(errors: &[TypeRef], tm_opts: &type_map::Options) -> String {
352    match errors.len() {
353        0 => "never".to_string(),
354        1 => type_map::render_type(&errors[0], tm_opts),
355        _ => errors
356            .iter()
357            .map(|e| type_map::render_type(e, tm_opts))
358            .collect::<Vec<_>>()
359            .join(" | "),
360    }
361}
362
363fn write_procedures_map(out: &mut String, ir: &Ir) {
364    // Use a type alias (not an interface) so the literal shape's known keys
365    // intersect cleanly with the runtime's `Record<string, ProcedureDef<...>>`
366    // index signature. An interface without an index signature would not
367    // satisfy the `extends Procedures` constraint on `ClientOf<P>`.
368    out.push_str("export type Procedures = {\n");
369    for p in &ir.procedures {
370        let alias = procedure_alias_name(&p.name);
371        let _ = writeln!(out, "  {key}: {alias};", key = quoted(&p.name));
372    }
373    out.push_str("};\n\n");
374}
375
376fn write_procedure_kinds(out: &mut String, ir: &Ir) {
377    // Runtime-visible counterpart to the `Procedures` type map: the runtime
378    // dispatches query vs. mutation vs. subscription from this table.
379    // `as const satisfies …` pins each value to its narrow literal kind so
380    // callers reading `procedureKinds.foo` see `"query"` (not `string`).
381    out.push_str("/** Procedure name -> kind, for runtime dispatch. */\n");
382    out.push_str("export const procedureKinds = {\n");
383    for p in &ir.procedures {
384        let kind_lit = match p.kind {
385            taut_rpc::ir::ProcKind::Query => "\"query\"",
386            taut_rpc::ir::ProcKind::Mutation => "\"mutation\"",
387            taut_rpc::ir::ProcKind::Subscription => "\"subscription\"",
388        };
389        let _ = writeln!(out, "  {key}: {kind_lit},", key = quoted(&p.name));
390    }
391    out.push_str(
392        "} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;\n\n",
393    );
394}
395
396fn write_create_api(out: &mut String) {
397    out.push_str("/** Construct a typed client for the procedures generated above. */\n");
398    out.push_str("export function createApi(opts: ClientOptions): ClientOf<Procedures> {\n");
399    out.push_str(
400        "  return createClient<Procedures>({ ...opts, kinds: opts.kinds ?? procedureKinds });\n",
401    );
402    out.push_str("}\n");
403}
404
405// ---------------------------------------------------------------------------
406// Phase 4: validator schema emission
407// ---------------------------------------------------------------------------
408
409/// Emit `<Name>Schema` consts for every `TypeDef`. Skipped entirely when the
410/// validator is `None`.
411fn write_schemas(out: &mut String, ir: &Ir, validator: Validator, tm_opts: &type_map::Options) {
412    if matches!(validator, Validator::None) || ir.types.is_empty() {
413        return;
414    }
415
416    // Re-run the same dedup pass `write_types` uses so that schemas appear in
417    // the same order as the interfaces they mirror, and duplicates collapse
418    // identically. Conflicts have already been surfaced upstream.
419    let mut seen: BTreeMap<&str, &TypeDef> = BTreeMap::new();
420    let mut order: Vec<&TypeDef> = Vec::new();
421    for t in &ir.types {
422        if seen.insert(t.name.as_str(), t).is_none() {
423            order.push(t);
424        }
425    }
426
427    out.push_str("// ---- validator schemas ----\n\n");
428    for t in order {
429        write_type_schema(out, t, validator, tm_opts);
430    }
431}
432
433fn write_type_schema(
434    out: &mut String,
435    t: &TypeDef,
436    validator: Validator,
437    tm_opts: &type_map::Options,
438) {
439    let name = &t.name;
440    let body = match &t.shape {
441        TypeShape::Struct(fields) => render_struct_schema(fields, validator, tm_opts),
442        TypeShape::Enum(e) => render_enum_schema(e, validator, tm_opts),
443        TypeShape::Tuple(elems) => render_tuple_schema(elems, validator, tm_opts),
444        TypeShape::Newtype(inner) | TypeShape::Alias(inner) => {
445            render_schema(inner, validator, tm_opts)
446        }
447    };
448    let _ = writeln!(out, "export const {name}Schema = {body};\n");
449}
450
451/// Build the `v.object({ ... })` / `z.object({ ... })` schema body for a
452/// struct's fields, applying any per-field constraints.
453fn render_struct_schema(
454    fields: &[Field],
455    validator: Validator,
456    tm_opts: &type_map::Options,
457) -> String {
458    let prefix = ns(validator);
459    if fields.is_empty() {
460        return format!("{prefix}.object({{}})");
461    }
462    let mut out = String::new();
463    out.push_str(prefix);
464    out.push_str(".object({\n");
465    for f in fields {
466        let expr = render_field_schema(f, validator, tm_opts);
467        let _ = writeln!(out, "  {name}: {expr},", name = f.name);
468    }
469    out.push_str("})");
470    out
471}
472
473/// Render the schema for an enum (discriminated union).
474///
475/// Each variant becomes a `v.object` / `z.object` carrying a literal `tag`
476/// field plus the variant's payload (if any). The variants are unioned via
477/// `v.variant(tag, [...])` for Valibot or `z.discriminatedUnion(tag, [...])`
478/// for Zod, matching the TS-side discriminant convention.
479fn render_enum_schema(e: &EnumDef, validator: Validator, tm_opts: &type_map::Options) -> String {
480    let prefix = ns(validator);
481    if e.variants.is_empty() {
482        // Uninhabited: emit a never-shaped schema. Both libraries accept
483        // a zero-element union via their respective primitives.
484        return match validator {
485            Validator::Valibot | Validator::Zod => format!("{prefix}.never()"),
486            Validator::None => unreachable!(),
487        };
488    }
489    let mut variant_exprs: Vec<String> = Vec::with_capacity(e.variants.len());
490    for v in &e.variants {
491        variant_exprs.push(render_variant_schema(&e.tag, v, validator, tm_opts));
492    }
493    match validator {
494        Validator::Valibot => {
495            let tag_lit = quoted(&e.tag);
496            let joined = variant_exprs.join(", ");
497            format!("{prefix}.variant({tag_lit}, [{joined}])")
498        }
499        Validator::Zod => {
500            let tag_lit = quoted(&e.tag);
501            let joined = variant_exprs.join(", ");
502            format!("{prefix}.discriminatedUnion({tag_lit}, [{joined}])")
503        }
504        Validator::None => unreachable!(),
505    }
506}
507
508fn render_variant_schema(
509    tag: &str,
510    v: &Variant,
511    validator: Validator,
512    tm_opts: &type_map::Options,
513) -> String {
514    let prefix = ns(validator);
515    let tag_field = match validator {
516        Validator::Valibot | Validator::Zod => format!("{prefix}.literal({})", quoted(&v.name)),
517        Validator::None => unreachable!(),
518    };
519    match &v.payload {
520        VariantPayload::Unit => {
521            format!("{prefix}.object({{ {tag}: {tag_field} }})")
522        }
523        VariantPayload::Tuple(elems) => {
524            if elems.is_empty() {
525                return format!("{prefix}.object({{ {tag}: {tag_field} }})");
526            }
527            let inner: Vec<String> = elems
528                .iter()
529                .map(|e| render_schema(e, validator, tm_opts))
530                .collect();
531            let payload = format!("{prefix}.tuple([{}])", inner.join(", "));
532            format!("{prefix}.object({{ {tag}: {tag_field}, payload: {payload} }})")
533        }
534        VariantPayload::Struct(fields) => {
535            if fields.is_empty() {
536                return format!("{prefix}.object({{ {tag}: {tag_field} }})");
537            }
538            let mut s = String::new();
539            s.push_str(prefix);
540            s.push_str(".object({ ");
541            let _ = write!(s, "{tag}: {tag_field}");
542            for f in fields {
543                let expr = render_field_schema(f, validator, tm_opts);
544                let _ = write!(s, ", {name}: {expr}", name = f.name);
545            }
546            s.push_str(" })");
547            s
548        }
549    }
550}
551
552/// Render a tuple-shaped `TypeDef`'s schema: `v.tuple([...])` / `z.tuple([...])`.
553fn render_tuple_schema(
554    elems: &[TypeRef],
555    validator: Validator,
556    tm_opts: &type_map::Options,
557) -> String {
558    let prefix = ns(validator);
559    if elems.is_empty() {
560        // The TS rendering for `()` is `void`. Match that here with `null()`
561        // (Valibot) / `null()` (Zod) — the chosen wire shape for `()`.
562        return format!("{prefix}.null()");
563    }
564    let inner: Vec<String> = elems
565        .iter()
566        .map(|t| render_schema(t, validator, tm_opts))
567        .collect();
568    format!("{prefix}.tuple([{}])", inner.join(", "))
569}
570
571/// Render the schema expression for a [`TypeRef`].
572fn render_schema(t: &TypeRef, validator: Validator, tm_opts: &type_map::Options) -> String {
573    match t {
574        TypeRef::Primitive(p) => render_primitive_schema(*p, validator, tm_opts),
575        TypeRef::Named(name) => format!("{name}Schema"),
576        TypeRef::Option(inner) => {
577            let inner_expr = render_schema(inner, validator, tm_opts);
578            let prefix = ns(validator);
579            // Both libraries accept "null or T". Use `nullable` (Valibot) or
580            // `nullable()` chained on Zod schemas to mirror SPEC §3.1's
581            // `T | null` mapping.
582            match validator {
583                Validator::Valibot => format!("{prefix}.nullable({inner_expr})"),
584                Validator::Zod => format!("{inner_expr}.nullable()"),
585                Validator::None => unreachable!(),
586            }
587        }
588        TypeRef::Vec(inner) => {
589            let inner_expr = render_schema(inner, validator, tm_opts);
590            let prefix = ns(validator);
591            format!("{prefix}.array({inner_expr})")
592        }
593        TypeRef::Map { key, value } => {
594            let v_expr = render_schema(value, validator, tm_opts);
595            let prefix = ns(validator);
596            // SPEC §3.1: string-keyed maps render as `Record<string, V>`. For
597            // non-string keys both libraries lack a great primitive — fall
598            // back to `array(tuple([k, v]))`.
599            if is_string_keyed(key) {
600                match validator {
601                    Validator::Valibot | Validator::Zod => {
602                        format!("{prefix}.record({prefix}.string(), {v_expr})")
603                    }
604                    Validator::None => unreachable!(),
605                }
606            } else {
607                let k_expr = render_schema(key, validator, tm_opts);
608                format!("{prefix}.array({prefix}.tuple([{k_expr}, {v_expr}]))")
609            }
610        }
611        TypeRef::Tuple(elems) => render_tuple_schema(elems, validator, tm_opts),
612        TypeRef::FixedArray { elem, len } => {
613            let elem_expr = render_schema(elem, validator, tm_opts);
614            let prefix = ns(validator);
615            // Mirror `type_map::render_fixed_array`: fold into an N-element
616            // tuple. We don't apply the 16-element cap here because schemas
617            // get reused at runtime — but to match the TS type's shape, we
618            // also stay tuple-shaped for any length.
619            let parts: Vec<String> = (0..*len).map(|_| elem_expr.clone()).collect();
620            format!("{prefix}.tuple([{}])", parts.join(", "))
621        }
622    }
623}
624
625/// Render the schema for a single struct field, applying any constraints.
626///
627/// Constraints are applied as a `v.pipe(<base>, <check1>, <check2>, ...)` for
628/// Valibot, or as chained `.method()` calls for Zod. If no constraints are
629/// present we emit just the base expression. `Option<T>` fields are wrapped
630/// in `nullable` _around_ the constrained inner expression so the constraint
631/// only applies when the value is present.
632fn render_field_schema(f: &Field, validator: Validator, tm_opts: &type_map::Options) -> String {
633    // Strip a top-level Option to apply constraints to the inner type, then
634    // re-wrap. This matches the SPEC §7 intent that constraints describe the
635    // shape of the present value.
636    let (inner_ty, is_option) = match &f.ty {
637        TypeRef::Option(inner) => (inner.as_ref(), true),
638        _ => (&f.ty, false),
639    };
640
641    let base = render_schema(inner_ty, validator, tm_opts);
642
643    let with_constraints = if f.constraints.is_empty() {
644        base
645    } else {
646        match validator {
647            Validator::Valibot => apply_valibot_constraints(&base, inner_ty, &f.constraints),
648            Validator::Zod => apply_zod_constraints(&base, inner_ty, &f.constraints),
649            Validator::None => unreachable!(),
650        }
651    };
652
653    if is_option {
654        let prefix = ns(validator);
655        match validator {
656            Validator::Valibot => format!("{prefix}.nullable({with_constraints})"),
657            Validator::Zod => format!("{with_constraints}.nullable()"),
658            Validator::None => unreachable!(),
659        }
660    } else {
661        with_constraints
662    }
663}
664
665/// Wrap `base` with `v.pipe(base, <check>, <check>, ...)` for the given
666/// constraints. If the list contains nothing renderable (e.g. only `Custom`
667/// tags) we just return `base` with the breadcrumb comments inline.
668fn apply_valibot_constraints(base: &str, inner_ty: &TypeRef, constraints: &[Constraint]) -> String {
669    let mut checks: Vec<String> = Vec::new();
670    let mut comments: Vec<String> = Vec::new();
671    for c in constraints {
672        match c {
673            Constraint::Min(n) => {
674                if is_string_typed(inner_ty) {
675                    // SPEC §7: min/max are numeric only. Length on strings is
676                    // expressed via `Constraint::Length`. If we see Min on a
677                    // string field we leave a comment rather than silently
678                    // pretending it's a length check.
679                    comments.push("/* min on string ignored — use length */".to_string());
680                } else {
681                    checks.push(format!("v.minValue({})", render_number(*n)));
682                }
683            }
684            Constraint::Max(n) => {
685                if is_string_typed(inner_ty) {
686                    comments.push("/* max on string ignored — use length */".to_string());
687                } else {
688                    checks.push(format!("v.maxValue({})", render_number(*n)));
689                }
690            }
691            Constraint::Length { min, max } => {
692                if let Some(n) = min {
693                    checks.push(format!("v.minLength({n})"));
694                }
695                if let Some(n) = max {
696                    checks.push(format!("v.maxLength({n})"));
697                }
698            }
699            Constraint::Pattern(re) => {
700                checks.push(format!("v.regex({})", regex_literal(re)));
701            }
702            Constraint::Email => checks.push("v.email()".to_string()),
703            Constraint::Url => checks.push("v.url()".to_string()),
704            Constraint::Custom(name) => {
705                comments.push(format!("/* custom:{name} — supply your own check */"));
706            }
707        }
708    }
709    if checks.is_empty() {
710        if comments.is_empty() {
711            base.to_string()
712        } else {
713            format!("{} {}", base, comments.join(" "))
714        }
715    } else {
716        let trail = if comments.is_empty() {
717            String::new()
718        } else {
719            format!(" {}", comments.join(" "))
720        };
721        format!("v.pipe({}, {}){}", base, checks.join(", "), trail)
722    }
723}
724
725/// Chain Zod constraints onto `base`. Zod expresses checks as
726/// `.min(n).max(n).email().url().regex(/.../)` rather than via a separate
727/// `pipe` combinator.
728fn apply_zod_constraints(base: &str, inner_ty: &TypeRef, constraints: &[Constraint]) -> String {
729    let mut chain = String::from(base);
730    let mut comments: Vec<String> = Vec::new();
731    for c in constraints {
732        match c {
733            Constraint::Min(n) => {
734                if is_string_typed(inner_ty) {
735                    comments.push("/* min on string ignored — use length */".to_string());
736                } else {
737                    let _ = write!(chain, ".min({})", render_number(*n));
738                }
739            }
740            Constraint::Max(n) => {
741                if is_string_typed(inner_ty) {
742                    comments.push("/* max on string ignored — use length */".to_string());
743                } else {
744                    let _ = write!(chain, ".max({})", render_number(*n));
745                }
746            }
747            Constraint::Length { min, max } => {
748                if let Some(n) = min {
749                    let _ = write!(chain, ".min({n})");
750                }
751                if let Some(n) = max {
752                    let _ = write!(chain, ".max({n})");
753                }
754            }
755            Constraint::Pattern(re) => {
756                let _ = write!(chain, ".regex({})", regex_literal(re));
757            }
758            Constraint::Email => chain.push_str(".email()"),
759            Constraint::Url => chain.push_str(".url()"),
760            Constraint::Custom(name) => {
761                comments.push(format!("/* custom:{name} — supply your own check */"));
762            }
763        }
764    }
765    if comments.is_empty() {
766        chain
767    } else {
768        format!("{chain} {}", comments.join(" "))
769    }
770}
771
772#[allow(clippy::match_same_arms)] // arms kept distinct so per-variant SPEC comments stay attached
773fn render_primitive_schema(
774    p: Primitive,
775    validator: Validator,
776    tm_opts: &type_map::Options,
777) -> String {
778    use Primitive::{
779        Bool, Bytes, DateTime, String, Unit, Uuid, F32, F64, I128, I16, I32, I64, I8, U128, U16,
780        U32, U64, U8,
781    };
782    let prefix = ns(validator);
783    // Native-bigint primitives (u64/i64/u128/i128) need JSON-compat coercion:
784    // the wire value comes back from `JSON.parse` as a `number` (or `string`
785    // when the runtime opted into stringly-typed bigints), but the generated
786    // TS type is `bigint`. Plain `v.bigint()` / `z.bigint()` would reject the
787    // parsed value at output validation time. Emit a small union+transform so
788    // the post-parse value is always a `bigint` that matches the TS type.
789    if matches!(p, U64 | I64 | U128 | I128) && tm_opts.bigint == BigIntStrategy::Native {
790        return match validator {
791            Validator::Valibot => "v.pipe(v.union([v.bigint(), v.number(), v.string()]), v.transform((x): bigint => typeof x === \"bigint\" ? x : BigInt(x as number | string)), v.bigint())".to_string(),
792            Validator::Zod => "z.union([z.bigint(), z.number(), z.string()]).transform(x => typeof x === \"bigint\" ? x : BigInt(x as any)).pipe(z.bigint())".to_string(),
793            Validator::None => unreachable!(),
794        };
795    }
796    let suffix = match p {
797        Bool => "boolean()",
798        U8 | U16 | U32 | I8 | I16 | I32 | F32 | F64 => "number()",
799        U64 | I64 | U128 | I128 => match tm_opts.bigint {
800            BigIntStrategy::Native => "bigint()",
801            BigIntStrategy::AsString => "string()",
802        },
803        String => "string()",
804        // SPEC §3.1: bytes go on the wire as base64.
805        Bytes => "string()",
806        Unit => "null()",
807        DateTime => "string()",
808        Uuid => "string()",
809    };
810    format!("{prefix}.{suffix}")
811}
812
813fn ns(validator: Validator) -> &'static str {
814    match validator {
815        Validator::Valibot => "v",
816        Validator::Zod => "z",
817        Validator::None => "",
818    }
819}
820
821fn is_string_typed(t: &TypeRef) -> bool {
822    matches!(
823        t,
824        TypeRef::Primitive(
825            Primitive::String | Primitive::Bytes | Primitive::DateTime | Primitive::Uuid,
826        )
827    )
828}
829
830/// A "string-keyed" map (mirrors `type_map`'s rule). `String`, `DateTime`, and
831/// `Uuid` all serialise as strings.
832fn is_string_keyed(key: &TypeRef) -> bool {
833    matches!(
834        key,
835        TypeRef::Primitive(Primitive::String | Primitive::DateTime | Primitive::Uuid,)
836    )
837}
838
839/// Render an `f64` constraint bound back into TS source. Whole numbers come
840/// out without a decimal point so that `Min(0.0)` reads as `0`, not `0.0`.
841fn render_number(n: f64) -> String {
842    // The `n.abs() < 1e16` guard keeps the value inside i64's safe integer
843    // range, so the cast can't truncate; the explicit `#[allow]` documents
844    // that the lossy-cast lint is intentional here.
845    #[allow(clippy::cast_possible_truncation)]
846    if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e16 {
847        format!("{}", n as i64)
848    } else {
849        // Default float rendering. We keep this minimal; Rust's default `{}`
850        // for f64 already drops trailing zeros after the decimal.
851        format!("{n}")
852    }
853}
854
855/// Render a Rust regex source as a TS regex literal. We escape `/` so the
856/// pattern doesn't accidentally close the literal early. Backslashes and
857/// other escapes are passed through verbatim (the regex engine on the JS
858/// side handles them).
859fn regex_literal(re: &str) -> String {
860    let mut out = String::with_capacity(re.len() + 2);
861    out.push('/');
862    for ch in re.chars() {
863        if ch == '/' {
864            out.push_str("\\/");
865        } else {
866            out.push(ch);
867        }
868    }
869    out.push('/');
870    out
871}
872
873/// Emit per-procedure schema constants and the `procedureSchemas` runtime
874/// map. Skipped entirely when the validator is `None`.
875fn write_procedure_schemas(
876    out: &mut String,
877    ir: &Ir,
878    validator: Validator,
879    tm_opts: &type_map::Options,
880) {
881    if matches!(validator, Validator::None) {
882        return;
883    }
884
885    // Collect every TypeDef name we know about so we can decide whether a
886    // procedure's input/output names can simply alias an existing
887    // `<Name>Schema` (yes for Named that's defined here; no for Named that's
888    // external — fall back to inline schema rendering).
889    let known: BTreeSet<&str> = ir.types.iter().map(|t| t.name.as_str()).collect();
890
891    out.push_str("// ---- procedure schemas ----\n\n");
892
893    // Wrap raw schemas in a uniform `{ parse(value) }` shape so the runtime can
894    // call them the same way regardless of the validator library. Valibot's
895    // top-level API is `v.parse(schema, value)` (no `.parse` method on the
896    // schema itself); Zod schemas already have `.parse(value)`. We normalise
897    // by emitting a thin adapter per procedure.
898    let (schema_ty, parse_call) = match validator {
899        Validator::Valibot => (
900            "v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>",
901            "v.parse(schema, value)",
902        ),
903        Validator::Zod => ("z.ZodTypeAny", "schema.parse(value)"),
904        Validator::None => unreachable!("guarded above"),
905    };
906    let _ = writeln!(
907        out,
908        "function __taut_wrap(schema: {schema_ty}): {{ parse(value: unknown): unknown }} {{",
909    );
910    let _ = writeln!(
911        out,
912        "  return {{ parse: (value: unknown) => {parse_call} }};"
913    );
914    out.push_str("}\n\n");
915
916    for p in &ir.procedures {
917        let alias = procedure_alias_name(&p.name);
918        let input_expr = procedure_io_schema(&p.input, validator, tm_opts, &known);
919        let output_expr = procedure_io_schema(&p.output, validator, tm_opts, &known);
920        let _ = writeln!(out, "export const {alias}_inputSchema = {input_expr};");
921        let _ = writeln!(out, "export const {alias}_outputSchema = {output_expr};");
922    }
923    out.push('\n');
924
925    out.push_str("/** Procedure name -> { input, output } schema, for runtime validation. */\n");
926    out.push_str("export const procedureSchemas = {\n");
927    for p in &ir.procedures {
928        let alias = procedure_alias_name(&p.name);
929        let _ = writeln!(
930            out,
931            "  {key}: {{ input: __taut_wrap({alias}_inputSchema), output: __taut_wrap({alias}_outputSchema) }},",
932            key = quoted(&p.name),
933        );
934    }
935    out.push_str("};\n\n");
936}
937
938/// Pick the right schema expression for a procedure's input or output type:
939/// alias an existing `TypeDef` schema by name when possible, otherwise fall
940/// back to an inline schema expression.
941fn procedure_io_schema(
942    t: &TypeRef,
943    validator: Validator,
944    tm_opts: &type_map::Options,
945    known: &BTreeSet<&str>,
946) -> String {
947    if let TypeRef::Named(name) = t {
948        if known.contains(name.as_str()) {
949            return format!("{name}Schema");
950        }
951        // Unknown Named — defer to whatever the user has in scope. We still
952        // emit `<Name>Schema` so a hand-supplied schema can satisfy the
953        // reference at TS compile time.
954        return format!("{name}Schema");
955    }
956    render_schema(t, validator, tm_opts)
957}
958
959// ---------------------------------------------------------------------------
960// Helpers
961// ---------------------------------------------------------------------------
962
963/// Turn a dotted procedure name like `"users.get"` into a TS identifier
964/// `"Proc_users_get"`. Non-identifier characters beyond `.` are also
965/// replaced, so `"users-list"` → `"Proc_users_list"`.
966fn procedure_alias_name(proc_name: &str) -> String {
967    let mut s = String::with_capacity(5 + proc_name.len());
968    s.push_str("Proc_");
969    for ch in proc_name.chars() {
970        if ch.is_ascii_alphanumeric() || ch == '_' {
971            s.push(ch);
972        } else {
973            s.push('_');
974        }
975    }
976    s
977}
978
979/// Companion to [`procedure_alias_name`] for the per-procedure error union
980/// alias. `"users.get"` → `"Proc_users_get_Error"`.
981fn procedure_error_alias_name(proc_name: &str) -> String {
982    let mut s = procedure_alias_name(proc_name);
983    s.push_str("_Error");
984    s
985}
986
987/// Quote a string as a TS string literal. We don't need full JSON escaping
988/// here because the inputs are Rust idents / dotted names by construction;
989/// still, we escape the obvious troublemakers defensively.
990fn quoted(s: &str) -> String {
991    let mut out = String::with_capacity(s.len() + 2);
992    out.push('"');
993    for ch in s.chars() {
994        match ch {
995            '"' => out.push_str("\\\""),
996            '\\' => out.push_str("\\\\"),
997            '\n' => out.push_str("\\n"),
998            '\r' => out.push_str("\\r"),
999            _ => out.push(ch),
1000        }
1001    }
1002    out.push('"');
1003    out
1004}
1005
1006/// Render a doc comment (`/** ... */`) at the given indent, splitting on
1007/// newlines so multi-line Rust doc comments survive the trip.
1008fn write_doc_comment(out: &mut String, doc: &str, indent: &str) {
1009    let lines: Vec<&str> = doc.lines().collect();
1010    if lines.len() == 1 {
1011        let body = lines[0].trim();
1012        let _ = writeln!(out, "{indent}/** {body} */");
1013        return;
1014    }
1015    let _ = writeln!(out, "{indent}/**");
1016    for line in lines {
1017        let body = line.trim_end();
1018        let _ = writeln!(out, "{indent} * {body}");
1019    }
1020    let _ = writeln!(out, "{indent} */");
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Tests
1025// ---------------------------------------------------------------------------
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use taut_rpc::ir::{
1031        Constraint, EnumDef, Field, HttpMethod, Ir, Primitive, ProcKind, Procedure, TypeDef,
1032        TypeRef, TypeShape, Variant, VariantPayload,
1033    };
1034
1035    fn opts() -> CodegenOptions {
1036        CodegenOptions::default()
1037    }
1038
1039    fn opts_no_validator() -> CodegenOptions {
1040        CodegenOptions {
1041            validator: Validator::None,
1042            ..CodegenOptions::default()
1043        }
1044    }
1045
1046    #[test]
1047    fn empty_ir_emits_header_imports_and_empty_procedures_map() {
1048        let ir = Ir::empty();
1049        let s = render_ts(&ir, &opts_no_validator());
1050        assert!(s.contains("DO NOT EDIT"), "header missing:\n{s}");
1051        assert!(
1052            s.contains("import type { ProcedureDef } from \"taut-rpc\";"),
1053            "type-only import missing:\n{s}"
1054        );
1055        assert!(
1056            s.contains(
1057                "import { createClient, type ClientOptions, type ClientOf } from \"taut-rpc\";"
1058            ),
1059            "value import missing:\n{s}"
1060        );
1061        assert!(
1062            s.contains("export type Procedures = {\n};"),
1063            "empty Procedures map missing:\n{s}"
1064        );
1065        assert!(
1066            s.contains("export const procedureKinds = {\n} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;"),
1067            "empty procedureKinds missing:\n{s}"
1068        );
1069        assert!(
1070            s.contains("export function createApi(opts: ClientOptions): ClientOf<Procedures>"),
1071            "createApi missing:\n{s}"
1072        );
1073        assert!(
1074            s.contains(
1075                "createClient<Procedures>({ ...opts, kinds: opts.kinds ?? procedureKinds });"
1076            ),
1077            "createApi should thread procedureKinds through to createClient:\n{s}"
1078        );
1079    }
1080
1081    #[test]
1082    fn one_query_string_to_u32_emits_proc_alias() {
1083        let ir = Ir {
1084            ir_version: Ir::CURRENT_VERSION,
1085            procedures: vec![Procedure {
1086                name: "ping".to_string(),
1087                kind: ProcKind::Query,
1088                input: TypeRef::Primitive(Primitive::String),
1089                output: TypeRef::Primitive(Primitive::U32),
1090                errors: vec![],
1091                http_method: HttpMethod::Post,
1092                doc: None,
1093            }],
1094            types: vec![],
1095        };
1096        let s = render_ts(&ir, &opts_no_validator());
1097        assert!(
1098            s.contains("export type Proc_ping = ProcedureDef<string, number, never, \"query\">;"),
1099            "proc alias wrong:\n{s}"
1100        );
1101        assert!(
1102            s.contains("\"ping\": Proc_ping;"),
1103            "Procedures key wrong:\n{s}"
1104        );
1105        // Zero-error procedure -> no `_Error` alias emitted.
1106        assert!(
1107            !s.contains("Proc_ping_Error"),
1108            "zero-error procedure must not emit error alias:\n{s}"
1109        );
1110        // procedureKinds runtime map should mention the procedure name -> kind.
1111        assert!(
1112            s.contains("\"ping\": \"query\","),
1113            "procedureKinds entry missing:\n{s}"
1114        );
1115    }
1116
1117    #[test]
1118    fn dotted_procedure_name_becomes_underscored_alias() {
1119        assert_eq!(procedure_alias_name("users.get"), "Proc_users_get");
1120        assert_eq!(procedure_alias_name("ping"), "Proc_ping");
1121        assert_eq!(procedure_alias_name("a.b.c"), "Proc_a_b_c");
1122    }
1123
1124    #[test]
1125    fn struct_typedef_emits_interface_with_all_three_field_modes() {
1126        let ir = Ir {
1127            ir_version: Ir::CURRENT_VERSION,
1128            procedures: vec![],
1129            types: vec![TypeDef {
1130                name: "User".to_string(),
1131                doc: None,
1132                shape: TypeShape::Struct(vec![
1133                    // plain
1134                    Field {
1135                        name: "id".to_string(),
1136                        ty: TypeRef::Primitive(Primitive::U32),
1137                        optional: false,
1138                        undefined: false,
1139                        doc: None,
1140                        constraints: vec![],
1141                    },
1142                    // optional
1143                    Field {
1144                        name: "nickname".to_string(),
1145                        ty: TypeRef::Primitive(Primitive::String),
1146                        optional: true,
1147                        undefined: false,
1148                        doc: None,
1149                        constraints: vec![],
1150                    },
1151                    // undefined-flavored
1152                    Field {
1153                        name: "tagline".to_string(),
1154                        ty: TypeRef::Primitive(Primitive::String),
1155                        optional: false,
1156                        undefined: true,
1157                        doc: None,
1158                        constraints: vec![],
1159                    },
1160                    // both
1161                    Field {
1162                        name: "avatar".to_string(),
1163                        ty: TypeRef::Primitive(Primitive::String),
1164                        optional: true,
1165                        undefined: true,
1166                        doc: None,
1167                        constraints: vec![],
1168                    },
1169                ]),
1170            }],
1171        };
1172        let s = render_ts(&ir, &opts_no_validator());
1173        assert!(s.contains("export interface User {"), "no interface:\n{s}");
1174        assert!(s.contains("id: number;"), "plain field wrong:\n{s}");
1175        assert!(s.contains("nickname?: string;"), "optional wrong:\n{s}");
1176        assert!(
1177            s.contains("tagline: string | undefined;"),
1178            "undefined wrong:\n{s}"
1179        );
1180        assert!(
1181            s.contains("avatar?: string | undefined;"),
1182            "both wrong:\n{s}"
1183        );
1184    }
1185
1186    #[test]
1187    fn enum_typedef_emits_discriminated_union() {
1188        let ir = Ir {
1189            ir_version: Ir::CURRENT_VERSION,
1190            procedures: vec![],
1191            types: vec![TypeDef {
1192                name: "Event".to_string(),
1193                doc: None,
1194                shape: TypeShape::Enum(EnumDef {
1195                    tag: "type".to_string(),
1196                    variants: vec![
1197                        Variant {
1198                            name: "Ping".to_string(),
1199                            payload: VariantPayload::Unit,
1200                        },
1201                        Variant {
1202                            name: "Message".to_string(),
1203                            payload: VariantPayload::Tuple(vec![TypeRef::Primitive(
1204                                Primitive::String,
1205                            )]),
1206                        },
1207                        Variant {
1208                            name: "Move".to_string(),
1209                            payload: VariantPayload::Struct(vec![
1210                                Field {
1211                                    name: "x".to_string(),
1212                                    ty: TypeRef::Primitive(Primitive::I32),
1213                                    optional: false,
1214                                    undefined: false,
1215                                    doc: None,
1216                                    constraints: vec![],
1217                                },
1218                                Field {
1219                                    name: "y".to_string(),
1220                                    ty: TypeRef::Primitive(Primitive::I32),
1221                                    optional: false,
1222                                    undefined: false,
1223                                    doc: None,
1224                                    constraints: vec![],
1225                                },
1226                            ]),
1227                        },
1228                    ],
1229                }),
1230            }],
1231        };
1232        let s = render_ts(&ir, &opts_no_validator());
1233        assert!(s.contains("export type Event ="), "header missing:\n{s}");
1234        assert!(s.contains("{ type: \"Ping\" }"), "unit variant wrong:\n{s}");
1235        assert!(
1236            s.contains("{ type: \"Message\", payload: [string] }"),
1237            "tuple variant wrong:\n{s}"
1238        );
1239        assert!(
1240            s.contains("{ type: \"Move\","),
1241            "struct variant header wrong:\n{s}"
1242        );
1243        assert!(s.contains("x: number"), "x field wrong:\n{s}");
1244        assert!(s.contains("y: number"), "y field wrong:\n{s}");
1245    }
1246
1247    #[test]
1248    fn newtype_and_alias_emit_type_aliases() {
1249        let ir = Ir {
1250            ir_version: Ir::CURRENT_VERSION,
1251            procedures: vec![],
1252            types: vec![
1253                TypeDef {
1254                    name: "UserId".to_string(),
1255                    doc: None,
1256                    shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U64)),
1257                },
1258                TypeDef {
1259                    name: "Maybe".to_string(),
1260                    doc: None,
1261                    shape: TypeShape::Alias(TypeRef::Option(Box::new(TypeRef::Primitive(
1262                        Primitive::String,
1263                    )))),
1264                },
1265                TypeDef {
1266                    name: "Pair".to_string(),
1267                    doc: None,
1268                    shape: TypeShape::Tuple(vec![
1269                        TypeRef::Primitive(Primitive::I32),
1270                        TypeRef::Primitive(Primitive::String),
1271                    ]),
1272                },
1273            ],
1274        };
1275        let s = render_ts(&ir, &opts_no_validator());
1276        assert!(
1277            s.contains("export type UserId = bigint;"),
1278            "newtype wrong:\n{s}"
1279        );
1280        assert!(
1281            s.contains("export type Maybe = string | null;"),
1282            "alias wrong:\n{s}"
1283        );
1284        assert!(
1285            s.contains("export type Pair = [number, string];"),
1286            "tuple wrong:\n{s}"
1287        );
1288    }
1289
1290    #[test]
1291    fn duplicate_typedefs_with_equal_bodies_dedup_silently() {
1292        let dup = TypeDef {
1293            name: "Same".to_string(),
1294            doc: None,
1295            shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U32)),
1296        };
1297        let ir = Ir {
1298            ir_version: Ir::CURRENT_VERSION,
1299            procedures: vec![],
1300            types: vec![dup.clone(), dup],
1301        };
1302        let s = render_ts_checked(&ir, &opts_no_validator()).expect("dedup ok");
1303        let count = s.matches("export type Same = number;").count();
1304        assert_eq!(count, 1, "should appear exactly once:\n{s}");
1305    }
1306
1307    #[test]
1308    fn duplicate_typedefs_with_conflicting_bodies_error() {
1309        let a = TypeDef {
1310            name: "Same".to_string(),
1311            doc: None,
1312            shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U32)),
1313        };
1314        let b = TypeDef {
1315            name: "Same".to_string(),
1316            doc: None,
1317            shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::String)),
1318        };
1319        let ir = Ir {
1320            ir_version: Ir::CURRENT_VERSION,
1321            procedures: vec![],
1322            types: vec![a, b],
1323        };
1324        let err = render_ts_checked(&ir, &opts_no_validator()).unwrap_err();
1325        assert!(err.contains("Same"), "err should mention name: {err}");
1326    }
1327
1328    #[test]
1329    fn multiple_errors_render_as_union() {
1330        let ir = Ir {
1331            ir_version: Ir::CURRENT_VERSION,
1332            procedures: vec![Procedure {
1333                name: "p".to_string(),
1334                kind: ProcKind::Mutation,
1335                input: TypeRef::Primitive(Primitive::Unit),
1336                output: TypeRef::Primitive(Primitive::Unit),
1337                errors: vec![
1338                    TypeRef::Named("NotFound".to_string()),
1339                    TypeRef::Named("Unauthorized".to_string()),
1340                ],
1341                http_method: HttpMethod::Post,
1342                doc: None,
1343            }],
1344            types: vec![],
1345        };
1346        let s = render_ts(&ir, &opts_no_validator());
1347        // Phase 2: the alias is emitted before the ProcedureDef and the
1348        // ProcedureDef's third type arg references the alias rather than
1349        // the inline union.
1350        assert!(
1351            s.contains("export type Proc_p_Error = NotFound | Unauthorized;"),
1352            "per-procedure error alias missing:\n{s}"
1353        );
1354        assert!(
1355            s.contains(
1356                "export type Proc_p = ProcedureDef<void, void, Proc_p_Error, \"mutation\">;"
1357            ),
1358            "ProcedureDef should reference Proc_p_Error alias:\n{s}"
1359        );
1360        // Sanity: the alias must appear *before* the ProcedureDef so TS
1361        // resolves the forward reference cleanly.
1362        let alias_idx = s.find("export type Proc_p_Error =").expect("alias present");
1363        let def_idx = s.find("export type Proc_p =").expect("def present");
1364        assert!(alias_idx < def_idx, "alias must precede ProcedureDef:\n{s}");
1365        // procedureKinds reflects the mutation kind for runtime dispatch.
1366        assert!(
1367            s.contains("\"p\": \"mutation\","),
1368            "procedureKinds entry missing:\n{s}"
1369        );
1370    }
1371
1372    #[test]
1373    fn single_error_emits_error_alias() {
1374        let ir = Ir {
1375            ir_version: Ir::CURRENT_VERSION,
1376            procedures: vec![Procedure {
1377                name: "add".to_string(),
1378                kind: ProcKind::Query,
1379                input: TypeRef::Primitive(Primitive::Unit),
1380                output: TypeRef::Primitive(Primitive::U32),
1381                errors: vec![TypeRef::Named("AddError".to_string())],
1382                http_method: HttpMethod::Post,
1383                doc: None,
1384            }],
1385            types: vec![],
1386        };
1387        let s = render_ts(&ir, &opts_no_validator());
1388        assert!(
1389            s.contains("export type Proc_add_Error = AddError;"),
1390            "single-error alias missing:\n{s}"
1391        );
1392        assert!(
1393            s.contains(
1394                "export type Proc_add = ProcedureDef<void, number, Proc_add_Error, \"query\">;"
1395            ),
1396            "ProcedureDef must reference single-error alias:\n{s}"
1397        );
1398        // Doc comment on the alias mentions narrowing.
1399        assert!(
1400            s.contains("Wire-shape error union for procedure `add`. Narrow on `.code`."),
1401            "alias doc comment missing or wrong:\n{s}"
1402        );
1403    }
1404
1405    #[test]
1406    fn dotted_procedure_name_emits_dotted_error_alias() {
1407        let ir = Ir {
1408            ir_version: Ir::CURRENT_VERSION,
1409            procedures: vec![Procedure {
1410                name: "users.get".to_string(),
1411                kind: ProcKind::Query,
1412                input: TypeRef::Primitive(Primitive::U32),
1413                output: TypeRef::Named("User".to_string()),
1414                errors: vec![TypeRef::Named("NotFound".to_string())],
1415                http_method: HttpMethod::Get,
1416                doc: None,
1417            }],
1418            types: vec![],
1419        };
1420        let s = render_ts(&ir, &opts_no_validator());
1421        assert!(
1422            s.contains("export type Proc_users_get_Error = NotFound;"),
1423            "dotted-name error alias missing:\n{s}"
1424        );
1425        assert!(
1426            s.contains(
1427                "export type Proc_users_get = ProcedureDef<number, User, Proc_users_get_Error, \"query\">;"
1428            ),
1429            "dotted-name ProcedureDef wrong:\n{s}"
1430        );
1431        assert_eq!(
1432            procedure_error_alias_name("users.get"),
1433            "Proc_users_get_Error"
1434        );
1435    }
1436
1437    #[test]
1438    fn procedure_kinds_const_emitted_with_satisfies_clause() {
1439        let ir = Ir {
1440            ir_version: Ir::CURRENT_VERSION,
1441            procedures: vec![
1442                Procedure {
1443                    name: "ping".to_string(),
1444                    kind: ProcKind::Query,
1445                    input: TypeRef::Primitive(Primitive::Unit),
1446                    output: TypeRef::Primitive(Primitive::String),
1447                    errors: vec![],
1448                    http_method: HttpMethod::Post,
1449                    doc: None,
1450                },
1451                Procedure {
1452                    name: "do_thing".to_string(),
1453                    kind: ProcKind::Mutation,
1454                    input: TypeRef::Primitive(Primitive::Unit),
1455                    output: TypeRef::Primitive(Primitive::Unit),
1456                    errors: vec![],
1457                    http_method: HttpMethod::Post,
1458                    doc: None,
1459                },
1460                Procedure {
1461                    name: "events".to_string(),
1462                    kind: ProcKind::Subscription,
1463                    input: TypeRef::Primitive(Primitive::Unit),
1464                    output: TypeRef::Primitive(Primitive::String),
1465                    errors: vec![],
1466                    http_method: HttpMethod::Get,
1467                    doc: None,
1468                },
1469            ],
1470            types: vec![],
1471        };
1472        let s = render_ts(&ir, &opts_no_validator());
1473        assert!(
1474            s.contains("export const procedureKinds = {"),
1475            "procedureKinds const missing:\n{s}"
1476        );
1477        assert!(s.contains("\"ping\": \"query\","), "ping entry:\n{s}");
1478        assert!(
1479            s.contains("\"do_thing\": \"mutation\","),
1480            "do_thing entry:\n{s}"
1481        );
1482        assert!(
1483            s.contains("\"events\": \"subscription\","),
1484            "events entry:\n{s}"
1485        );
1486        assert!(
1487            s.contains("} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;"),
1488            "as-const-satisfies tail missing:\n{s}"
1489        );
1490    }
1491
1492    #[test]
1493    fn subscription_procedure_emits_subscription_kind_in_alias_and_kinds_map() {
1494        let ir = Ir {
1495            ir_version: Ir::CURRENT_VERSION,
1496            procedures: vec![Procedure {
1497                name: "ticker".to_string(),
1498                kind: ProcKind::Subscription,
1499                input: TypeRef::Primitive(Primitive::U32),
1500                output: TypeRef::Primitive(Primitive::String),
1501                errors: vec![],
1502                http_method: HttpMethod::Get,
1503                doc: None,
1504            }],
1505            types: vec![],
1506        };
1507        let s = render_ts(&ir, &opts_no_validator());
1508        // (1) The ProcedureDef carries the `"subscription"` literal — this is
1509        // what `ClientOf<P>` keys off to dispatch to `.subscribe(input)`.
1510        assert!(
1511            s.contains(
1512                "export type Proc_ticker = ProcedureDef<number, string, never, \"subscription\">;"
1513            ),
1514            "subscription proc alias wrong:\n{s}"
1515        );
1516        // (2) The runtime kinds map records `"subscription"` (not `"query"`).
1517        assert!(
1518            s.contains("\"ticker\": \"subscription\","),
1519            "procedureKinds entry must record subscription kind:\n{s}"
1520        );
1521        // (3) The JSDoc hint is emitted above the alias so editors surface the
1522        //     `.subscribe(input)` shape on hover.
1523        assert!(
1524            s.contains(
1525                "/** Subscription procedure — call via `.subscribe(input)`, returns AsyncIterable. */"
1526            ),
1527            "subscription JSDoc hint missing:\n{s}"
1528        );
1529        // Sanity: the JSDoc must precede the `Proc_ticker` alias declaration.
1530        let jsdoc_idx = s
1531            .find("Subscription procedure — call via")
1532            .expect("jsdoc present");
1533        let alias_idx = s.find("export type Proc_ticker =").expect("alias present");
1534        assert!(
1535            jsdoc_idx < alias_idx,
1536            "JSDoc must precede the type alias:\n{s}"
1537        );
1538    }
1539
1540    // ---------------------------------------------------------------------
1541    // Phase 4: validator schema emission
1542    // ---------------------------------------------------------------------
1543
1544    fn opts_zod() -> CodegenOptions {
1545        CodegenOptions {
1546            validator: Validator::Zod,
1547            ..CodegenOptions::default()
1548        }
1549    }
1550
1551    /// Build a one-struct IR named `User` with the supplied fields.
1552    fn one_struct_ir(name: &str, fields: Vec<Field>) -> Ir {
1553        Ir {
1554            ir_version: Ir::CURRENT_VERSION,
1555            procedures: vec![],
1556            types: vec![TypeDef {
1557                name: name.to_string(),
1558                doc: None,
1559                shape: TypeShape::Struct(fields),
1560            }],
1561        }
1562    }
1563
1564    fn plain_field(name: &str, ty: TypeRef, constraints: Vec<Constraint>) -> Field {
1565        Field {
1566            name: name.to_string(),
1567            ty,
1568            optional: false,
1569            undefined: false,
1570            doc: None,
1571            constraints,
1572        }
1573    }
1574
1575    #[test]
1576    fn valibot_emits_schema_for_simple_struct() {
1577        let ir = one_struct_ir(
1578            "User",
1579            vec![
1580                plain_field("id", TypeRef::Primitive(Primitive::U64), vec![]),
1581                plain_field("name", TypeRef::Primitive(Primitive::String), vec![]),
1582            ],
1583        );
1584        let s = render_ts(&ir, &opts());
1585        assert!(
1586            s.contains("import * as v from \"valibot\";"),
1587            "valibot import missing:\n{s}"
1588        );
1589        assert!(
1590            s.contains("export const UserSchema = v.object({"),
1591            "UserSchema header missing:\n{s}"
1592        );
1593        // Phase 4 keeps the existing TS interface alongside the schema.
1594        assert!(
1595            s.contains("export interface User {"),
1596            "interface should still be emitted:\n{s}"
1597        );
1598        // u64 emits a coercion-style schema so a JSON-parsed number/string
1599        // round-trips into a bigint at output-validation time.
1600        assert!(
1601            s.contains(
1602                "id: v.pipe(v.union([v.bigint(), v.number(), v.string()]), v.transform((x): bigint => typeof x === \"bigint\" ? x : BigInt(x as number | string)), v.bigint())"
1603            ),
1604            "id field schema wrong (expected bigint coercion pipe):\n{s}"
1605        );
1606        assert!(
1607            s.contains("name: v.string()"),
1608            "name field schema wrong:\n{s}"
1609        );
1610    }
1611
1612    #[test]
1613    fn valibot_applies_email_constraint_to_string_field() {
1614        let ir = one_struct_ir(
1615            "User",
1616            vec![plain_field(
1617                "email",
1618                TypeRef::Primitive(Primitive::String),
1619                vec![Constraint::Email],
1620            )],
1621        );
1622        let s = render_ts(&ir, &opts());
1623        assert!(
1624            s.contains("email: v.pipe(v.string(), v.email())"),
1625            "email constraint missing:\n{s}"
1626        );
1627    }
1628
1629    #[test]
1630    fn valibot_applies_min_max_to_number_field() {
1631        let ir = one_struct_ir(
1632            "User",
1633            vec![plain_field(
1634                "score",
1635                TypeRef::Primitive(Primitive::U32),
1636                vec![Constraint::Min(0.0), Constraint::Max(100.0)],
1637            )],
1638        );
1639        let s = render_ts(&ir, &opts());
1640        assert!(
1641            s.contains("score: v.pipe(v.number(), v.minValue(0), v.maxValue(100))"),
1642            "min/max chain wrong:\n{s}"
1643        );
1644    }
1645
1646    #[test]
1647    fn valibot_applies_length_to_string_field() {
1648        let ir = one_struct_ir(
1649            "User",
1650            vec![plain_field(
1651                "name",
1652                TypeRef::Primitive(Primitive::String),
1653                vec![Constraint::Length {
1654                    min: Some(1),
1655                    max: Some(64),
1656                }],
1657            )],
1658        );
1659        let s = render_ts(&ir, &opts());
1660        assert!(
1661            s.contains("name: v.pipe(v.string(), v.minLength(1), v.maxLength(64))"),
1662            "length chain wrong:\n{s}"
1663        );
1664    }
1665
1666    #[test]
1667    fn valibot_pattern_renders_regex_literal() {
1668        let ir = one_struct_ir(
1669            "User",
1670            vec![plain_field(
1671                "slug",
1672                TypeRef::Primitive(Primitive::String),
1673                vec![Constraint::Pattern(r"^[a-z]+$".to_string())],
1674            )],
1675        );
1676        let s = render_ts(&ir, &opts());
1677        assert!(
1678            s.contains("slug: v.pipe(v.string(), v.regex(/^[a-z]+$/))"),
1679            "regex literal wrong:\n{s}"
1680        );
1681    }
1682
1683    #[test]
1684    fn valibot_custom_constraint_emits_breadcrumb_only() {
1685        let ir = one_struct_ir(
1686            "User",
1687            vec![plain_field(
1688                "secret",
1689                TypeRef::Primitive(Primitive::String),
1690                vec![Constraint::Custom("must_be_prime".to_string())],
1691            )],
1692        );
1693        let s = render_ts(&ir, &opts());
1694        assert!(
1695            s.contains("custom:must_be_prime"),
1696            "custom breadcrumb missing:\n{s}"
1697        );
1698        // No bogus check call should be generated for the custom predicate.
1699        assert!(
1700            !s.contains("v.must_be_prime"),
1701            "custom must not become a validator call:\n{s}"
1702        );
1703    }
1704
1705    #[test]
1706    fn valibot_emits_procedure_schemas_map() {
1707        let ir = Ir {
1708            ir_version: Ir::CURRENT_VERSION,
1709            procedures: vec![
1710                Procedure {
1711                    name: "create_user".to_string(),
1712                    kind: ProcKind::Mutation,
1713                    input: TypeRef::Named("CreateUserInput".to_string()),
1714                    output: TypeRef::Named("User".to_string()),
1715                    errors: vec![],
1716                    http_method: HttpMethod::Post,
1717                    doc: None,
1718                },
1719                Procedure {
1720                    name: "ping".to_string(),
1721                    kind: ProcKind::Query,
1722                    input: TypeRef::Primitive(Primitive::Unit),
1723                    output: TypeRef::Primitive(Primitive::String),
1724                    errors: vec![],
1725                    http_method: HttpMethod::Post,
1726                    doc: None,
1727                },
1728            ],
1729            types: vec![
1730                TypeDef {
1731                    name: "CreateUserInput".to_string(),
1732                    doc: None,
1733                    shape: TypeShape::Struct(vec![plain_field(
1734                        "name",
1735                        TypeRef::Primitive(Primitive::String),
1736                        vec![],
1737                    )]),
1738                },
1739                TypeDef {
1740                    name: "User".to_string(),
1741                    doc: None,
1742                    shape: TypeShape::Struct(vec![plain_field(
1743                        "id",
1744                        TypeRef::Primitive(Primitive::U64),
1745                        vec![],
1746                    )]),
1747                },
1748            ],
1749        };
1750        let s = render_ts(&ir, &opts());
1751        // Per-procedure schema constants alias the TypeDef schema for Named
1752        // types and inline the expression for primitives.
1753        assert!(
1754            s.contains("export const Proc_create_user_inputSchema = CreateUserInputSchema;"),
1755            "input alias missing:\n{s}"
1756        );
1757        assert!(
1758            s.contains("export const Proc_create_user_outputSchema = UserSchema;"),
1759            "output alias missing:\n{s}"
1760        );
1761        assert!(
1762            s.contains("export const Proc_ping_inputSchema = v.null();"),
1763            "primitive input schema wrong:\n{s}"
1764        );
1765        assert!(
1766            s.contains("export const Proc_ping_outputSchema = v.string();"),
1767            "primitive output schema wrong:\n{s}"
1768        );
1769        // Runtime map.
1770        assert!(
1771            s.contains("export const procedureSchemas = {"),
1772            "procedureSchemas header missing:\n{s}"
1773        );
1774        assert!(
1775            s.contains(
1776                "\"create_user\": { input: __taut_wrap(Proc_create_user_inputSchema), output: __taut_wrap(Proc_create_user_outputSchema) },"
1777            ),
1778            "create_user map entry wrong:\n{s}"
1779        );
1780        assert!(
1781            s.contains(
1782                "\"ping\": { input: __taut_wrap(Proc_ping_inputSchema), output: __taut_wrap(Proc_ping_outputSchema) },"
1783            ),
1784            "ping map entry wrong:\n{s}"
1785        );
1786    }
1787
1788    #[test]
1789    fn zod_emits_z_namespace_import() {
1790        let s = render_ts(&Ir::empty(), &opts_zod());
1791        assert!(
1792            s.contains("import { z } from \"zod\";"),
1793            "zod import missing:\n{s}"
1794        );
1795        // valibot import must NOT appear when zod is selected.
1796        assert!(
1797            !s.contains("import * as v from \"valibot\";"),
1798            "valibot import should be absent:\n{s}"
1799        );
1800    }
1801
1802    #[test]
1803    fn zod_applies_email_chain() {
1804        let ir = one_struct_ir(
1805            "User",
1806            vec![plain_field(
1807                "email",
1808                TypeRef::Primitive(Primitive::String),
1809                vec![Constraint::Email],
1810            )],
1811        );
1812        let s = render_ts(&ir, &opts_zod());
1813        assert!(
1814            s.contains("email: z.string().email()"),
1815            "zod email chain wrong:\n{s}"
1816        );
1817    }
1818
1819    #[test]
1820    fn zod_emits_bigint_coercion_for_u64() {
1821        let ir = one_struct_ir(
1822            "User",
1823            vec![plain_field(
1824                "id",
1825                TypeRef::Primitive(Primitive::U64),
1826                vec![],
1827            )],
1828        );
1829        let s = render_ts(&ir, &opts_zod());
1830        // Same coercion idea as Valibot: union of bigint/number/string,
1831        // transform into bigint, then pipe through z.bigint() so the final
1832        // post-parse type matches the emitted `bigint` TS type.
1833        assert!(
1834            s.contains(
1835                "id: z.union([z.bigint(), z.number(), z.string()]).transform(x => typeof x === \"bigint\" ? x : BigInt(x as any)).pipe(z.bigint())"
1836            ),
1837            "zod bigint coercion missing:\n{s}"
1838        );
1839    }
1840
1841    #[test]
1842    fn zod_applies_min_max_chain() {
1843        let ir = one_struct_ir(
1844            "User",
1845            vec![plain_field(
1846                "age",
1847                TypeRef::Primitive(Primitive::U8),
1848                vec![Constraint::Min(0.0), Constraint::Max(120.0)],
1849            )],
1850        );
1851        let s = render_ts(&ir, &opts_zod());
1852        assert!(
1853            s.contains("age: z.number().min(0).max(120)"),
1854            "zod min/max chain wrong:\n{s}"
1855        );
1856    }
1857
1858    #[test]
1859    fn none_validator_emits_no_schemas() {
1860        let ir = one_struct_ir(
1861            "User",
1862            vec![plain_field(
1863                "id",
1864                TypeRef::Primitive(Primitive::U32),
1865                vec![],
1866            )],
1867        );
1868        let s = render_ts(&ir, &opts_no_validator());
1869        // Existing types still emit.
1870        assert!(
1871            s.contains("export interface User {"),
1872            "interface still emitted:\n{s}"
1873        );
1874        // No schema emission of any kind.
1875        assert!(
1876            !s.contains("UserSchema"),
1877            "UserSchema must not appear:\n{s}"
1878        );
1879        assert!(
1880            !s.contains("procedureSchemas"),
1881            "procedureSchemas must not appear:\n{s}"
1882        );
1883        assert!(
1884            !s.contains("import * as v from \"valibot\";"),
1885            "valibot import must be absent:\n{s}"
1886        );
1887        assert!(
1888            !s.contains("import { z } from \"zod\";"),
1889            "zod import must be absent:\n{s}"
1890        );
1891    }
1892
1893    #[test]
1894    fn valibot_named_type_references_named_schema() {
1895        // Field of a Named type should reference `<Name>Schema`, not inline.
1896        let ir = Ir {
1897            ir_version: Ir::CURRENT_VERSION,
1898            procedures: vec![],
1899            types: vec![
1900                TypeDef {
1901                    name: "Address".to_string(),
1902                    doc: None,
1903                    shape: TypeShape::Struct(vec![plain_field(
1904                        "city",
1905                        TypeRef::Primitive(Primitive::String),
1906                        vec![],
1907                    )]),
1908                },
1909                TypeDef {
1910                    name: "User".to_string(),
1911                    doc: None,
1912                    shape: TypeShape::Struct(vec![plain_field(
1913                        "address",
1914                        TypeRef::Named("Address".to_string()),
1915                        vec![],
1916                    )]),
1917                },
1918            ],
1919        };
1920        let s = render_ts(&ir, &opts());
1921        assert!(
1922            s.contains("address: AddressSchema"),
1923            "Named ref should resolve to AddressSchema:\n{s}"
1924        );
1925    }
1926
1927    #[test]
1928    fn valibot_optional_field_wraps_constraints_in_nullable() {
1929        // Option<String> with email constraint should be `nullable(pipe(string, email))`.
1930        let ir = one_struct_ir(
1931            "User",
1932            vec![Field {
1933                name: "email".to_string(),
1934                ty: TypeRef::Option(Box::new(TypeRef::Primitive(Primitive::String))),
1935                optional: true,
1936                undefined: false,
1937                doc: None,
1938                constraints: vec![Constraint::Email],
1939            }],
1940        );
1941        let s = render_ts(&ir, &opts());
1942        assert!(
1943            s.contains("email: v.nullable(v.pipe(v.string(), v.email()))"),
1944            "nullable+pipe composition wrong:\n{s}"
1945        );
1946    }
1947
1948    #[test]
1949    fn valibot_vec_renders_array_schema() {
1950        let ir = one_struct_ir(
1951            "Page",
1952            vec![plain_field(
1953                "items",
1954                TypeRef::Vec(Box::new(TypeRef::Primitive(Primitive::String))),
1955                vec![],
1956            )],
1957        );
1958        let s = render_ts(&ir, &opts());
1959        assert!(
1960            s.contains("items: v.array(v.string())"),
1961            "array schema wrong:\n{s}"
1962        );
1963    }
1964
1965    #[test]
1966    fn valibot_map_renders_record_schema() {
1967        let ir = one_struct_ir(
1968            "Map",
1969            vec![plain_field(
1970                "counts",
1971                TypeRef::Map {
1972                    key: Box::new(TypeRef::Primitive(Primitive::String)),
1973                    value: Box::new(TypeRef::Primitive(Primitive::U32)),
1974                },
1975                vec![],
1976            )],
1977        );
1978        let s = render_ts(&ir, &opts());
1979        assert!(
1980            s.contains("counts: v.record(v.string(), v.number())"),
1981            "record schema wrong:\n{s}"
1982        );
1983    }
1984}