1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Validator {
52 Valibot,
53 Zod,
54 None,
55}
56
57#[derive(Debug, Clone)]
59pub struct CodegenOptions {
60 pub validator: Validator,
61 pub bigint_strategy: BigIntStrategy,
62 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#[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
88pub 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
116fn 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 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 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 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 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 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 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
405fn 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 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
451fn 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
473fn 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 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
552fn 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 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
571fn 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 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 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 let parts: Vec<String> = (0..*len).map(|_| elem_expr.clone()).collect();
620 format!("{prefix}.tuple([{}])", parts.join(", "))
621 }
622 }
623}
624
625fn render_field_schema(f: &Field, validator: Validator, tm_opts: &type_map::Options) -> String {
633 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
665fn 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 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
725fn 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)] fn 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 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 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
830fn is_string_keyed(key: &TypeRef) -> bool {
833 matches!(
834 key,
835 TypeRef::Primitive(Primitive::String | Primitive::DateTime | Primitive::Uuid,)
836 )
837}
838
839fn render_number(n: f64) -> String {
842 #[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 format!("{n}")
852 }
853}
854
855fn 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
873fn 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 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 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
938fn 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 return format!("{name}Schema");
955 }
956 render_schema(t, validator, tm_opts)
957}
958
959fn 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
979fn 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
987fn 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
1006fn 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#[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 assert!(
1107 !s.contains("Proc_ping_Error"),
1108 "zero-error procedure must not emit error alias:\n{s}"
1109 );
1110 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 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 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 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 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 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 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 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 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 assert!(
1511 s.contains(
1512 "export type Proc_ticker = ProcedureDef<number, string, never, \"subscription\">;"
1513 ),
1514 "subscription proc alias wrong:\n{s}"
1515 );
1516 assert!(
1518 s.contains("\"ticker\": \"subscription\","),
1519 "procedureKinds entry must record subscription kind:\n{s}"
1520 );
1521 assert!(
1524 s.contains(
1525 "/** Subscription procedure — call via `.subscribe(input)`, returns AsyncIterable. */"
1526 ),
1527 "subscription JSDoc hint missing:\n{s}"
1528 );
1529 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 fn opts_zod() -> CodegenOptions {
1545 CodegenOptions {
1546 validator: Validator::Zod,
1547 ..CodegenOptions::default()
1548 }
1549 }
1550
1551 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 assert!(
1595 s.contains("export interface User {"),
1596 "interface should still be emitted:\n{s}"
1597 );
1598 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 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 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 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 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 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 assert!(
1871 s.contains("export interface User {"),
1872 "interface still emitted:\n{s}"
1873 );
1874 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 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 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}