use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use taut_rpc::ir::{
Constraint, EnumDef, Field, Ir, Primitive, Procedure, TypeDef, TypeRef, TypeShape, Variant,
VariantPayload,
};
use taut_rpc::type_map::{self, BigIntStrategy};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Validator {
Valibot,
Zod,
None,
}
#[derive(Debug, Clone)]
pub struct CodegenOptions {
pub validator: Validator,
pub bigint_strategy: BigIntStrategy,
pub honor_undefined: bool,
}
impl Default for CodegenOptions {
fn default() -> Self {
Self {
validator: Validator::Valibot,
bigint_strategy: BigIntStrategy::Native,
honor_undefined: true,
}
}
}
#[must_use]
pub fn render_ts(ir: &Ir, opts: &CodegenOptions) -> String {
render_ts_checked(ir, opts).expect("render_ts: duplicate TypeDef names disagree")
}
pub fn render_ts_checked(ir: &Ir, opts: &CodegenOptions) -> Result<String, String> {
let mut out = String::new();
let tm_opts = type_map_options(opts);
write_header(&mut out, ir);
write_imports(&mut out, opts.validator);
write_types(&mut out, ir, &tm_opts)?;
write_schemas(&mut out, ir, opts.validator, &tm_opts);
write_procedures(&mut out, ir, &tm_opts);
write_procedures_map(&mut out, ir);
write_procedure_kinds(&mut out, ir);
write_procedure_schemas(&mut out, ir, opts.validator, &tm_opts);
write_create_api(&mut out);
Ok(out)
}
fn type_map_options(opts: &CodegenOptions) -> type_map::Options {
type_map::Options {
bigint: opts.bigint_strategy,
honor_undefined: opts.honor_undefined,
}
}
fn write_header(out: &mut String, ir: &Ir) {
let v = ir.ir_version;
out.push_str("// DO NOT EDIT — generated by taut-rpc-cli.\n");
out.push_str("// Re-run `cargo taut gen` to refresh.\n");
let _ = writeln!(out, "// IR version: {v}");
out.push('\n');
}
fn write_imports(out: &mut String, validator: Validator) {
out.push_str("import type { ProcedureDef } from \"taut-rpc\";\n");
out.push_str("import { createClient, type ClientOptions, type ClientOf } from \"taut-rpc\";\n");
match validator {
Validator::Valibot => out.push_str("import * as v from \"valibot\";\n"),
Validator::Zod => out.push_str("import { z } from \"zod\";\n"),
Validator::None => {}
}
out.push('\n');
}
fn write_types(out: &mut String, ir: &Ir, tm_opts: &type_map::Options) -> Result<(), String> {
let mut seen: BTreeMap<&str, &TypeDef> = BTreeMap::new();
let mut order: Vec<&TypeDef> = Vec::new();
for t in &ir.types {
match seen.get(t.name.as_str()) {
Some(prev) if *prev == t => {}
Some(_) => {
return Err(format!(
"duplicate TypeDef `{}` with conflicting bodies",
t.name
));
}
None => {
seen.insert(t.name.as_str(), t);
order.push(t);
}
}
}
for t in order {
write_type_def(out, t, tm_opts);
}
Ok(())
}
fn write_type_def(out: &mut String, t: &TypeDef, tm_opts: &type_map::Options) {
if let Some(doc) = &t.doc {
write_doc_comment(out, doc, "");
}
match &t.shape {
TypeShape::Struct(fields) => write_struct(out, &t.name, fields, tm_opts),
TypeShape::Enum(e) => write_enum(out, &t.name, e, tm_opts),
TypeShape::Tuple(elems) => {
let name = &t.name;
let rendered = type_map::render_type(&TypeRef::Tuple(elems.clone()), tm_opts);
let _ = writeln!(out, "export type {name} = {rendered};\n");
}
TypeShape::Newtype(inner) | TypeShape::Alias(inner) => {
let name = &t.name;
let rendered = type_map::render_type(inner, tm_opts);
let _ = writeln!(out, "export type {name} = {rendered};\n");
}
}
}
fn write_struct(out: &mut String, name: &str, fields: &[Field], tm_opts: &type_map::Options) {
let _ = writeln!(out, "export interface {name} {{");
for f in fields {
write_field_line(out, f, tm_opts, " ");
}
out.push_str("}\n\n");
}
fn write_field_line(out: &mut String, f: &Field, tm_opts: &type_map::Options, indent: &str) {
if let Some(doc) = &f.doc {
write_doc_comment(out, doc, indent);
}
let ty = type_map::render_type(&f.ty, tm_opts);
let want_undefined = f.undefined && tm_opts.honor_undefined;
let qmark = if f.optional { "?" } else { "" };
let ty_with_undef = if want_undefined {
format!("{ty} | undefined")
} else {
ty
};
let name = &f.name;
let _ = writeln!(out, "{indent}{name}{qmark}: {ty_with_undef};");
}
fn write_enum(out: &mut String, name: &str, e: &EnumDef, tm_opts: &type_map::Options) {
let _ = writeln!(out, "export type {name} =");
if e.variants.is_empty() {
out.push_str(" never;\n\n");
return;
}
for (i, v) in e.variants.iter().enumerate() {
let last = i + 1 == e.variants.len();
let term = if last { ";" } else { "" };
write_variant(out, &e.tag, v, tm_opts, term);
}
out.push('\n');
}
fn write_variant(
out: &mut String,
tag: &str,
v: &Variant,
tm_opts: &type_map::Options,
terminator: &str,
) {
match &v.payload {
VariantPayload::Unit => {
let _ = writeln!(
out,
" | {{ {tag}: {variant} }}{terminator}",
variant = quoted(&v.name),
);
}
VariantPayload::Tuple(elems) => {
if elems.is_empty() {
let _ = writeln!(
out,
" | {{ {tag}: {variant} }}{terminator}",
variant = quoted(&v.name),
);
} else {
let inner: Vec<String> = elems
.iter()
.map(|t| type_map::render_type(t, tm_opts))
.collect();
let _ = writeln!(
out,
" | {{ {tag}: {variant}, payload: [{payload}] }}{terminator}",
variant = quoted(&v.name),
payload = inner.join(", "),
);
}
}
VariantPayload::Struct(fields) => {
if fields.is_empty() {
let _ = writeln!(
out,
" | {{ {tag}: {variant} }}{terminator}",
variant = quoted(&v.name),
);
return;
}
let _ = writeln!(out, " | {{ {tag}: {variant},", variant = quoted(&v.name));
for (i, f) in fields.iter().enumerate() {
let last = i + 1 == fields.len();
let ty = type_map::render_type(&f.ty, tm_opts);
let want_undefined = f.undefined && tm_opts.honor_undefined;
let qmark = if f.optional { "?" } else { "" };
let ty_with_undef = if want_undefined {
format!("{ty} | undefined")
} else {
ty
};
let sep = if last { "" } else { "," };
let fname = &f.name;
let _ = writeln!(out, " {fname}{qmark}: {ty_with_undef}{sep}");
}
let _ = writeln!(out, " }}{terminator}");
}
}
}
fn write_procedures(out: &mut String, ir: &Ir, tm_opts: &type_map::Options) {
if ir.procedures.is_empty() {
return;
}
out.push_str("// ---- procedures ----\n\n");
for p in &ir.procedures {
write_procedure_alias(out, p, tm_opts);
}
}
fn write_procedure_alias(out: &mut String, p: &Procedure, tm_opts: &type_map::Options) {
if let Some(doc) = &p.doc {
write_doc_comment(out, doc, "");
}
let alias = procedure_alias_name(&p.name);
let input = type_map::render_type(&p.input, tm_opts);
let output = type_map::render_type(&p.output, tm_opts);
if matches!(p.kind, taut_rpc::ir::ProcKind::Subscription) {
out.push_str(
"/** Subscription procedure — call via `.subscribe(input)`, returns AsyncIterable. */\n",
);
}
let _ = writeln!(
out,
"// kind: {kind:?}, http: {method:?}, name: {name}",
kind = p.kind,
method = p.http_method,
name = p.name,
);
let error_arg = if p.errors.is_empty() {
"never".to_string()
} else {
let error_alias = procedure_error_alias_name(&p.name);
let union = render_error_union(&p.errors, tm_opts);
let _ = writeln!(
out,
"/** Wire-shape error union for procedure `{name}`. Narrow on `.code`. */",
name = p.name,
);
let _ = writeln!(out, "export type {error_alias} = {union};");
error_alias
};
let kind_lit = match p.kind {
taut_rpc::ir::ProcKind::Query => "\"query\"",
taut_rpc::ir::ProcKind::Mutation => "\"mutation\"",
taut_rpc::ir::ProcKind::Subscription => "\"subscription\"",
};
let _ = writeln!(
out,
"export type {alias} = ProcedureDef<{input}, {output}, {error_arg}, {kind_lit}>;\n",
);
}
fn render_error_union(errors: &[TypeRef], tm_opts: &type_map::Options) -> String {
match errors.len() {
0 => "never".to_string(),
1 => type_map::render_type(&errors[0], tm_opts),
_ => errors
.iter()
.map(|e| type_map::render_type(e, tm_opts))
.collect::<Vec<_>>()
.join(" | "),
}
}
fn write_procedures_map(out: &mut String, ir: &Ir) {
out.push_str("export type Procedures = {\n");
for p in &ir.procedures {
let alias = procedure_alias_name(&p.name);
let _ = writeln!(out, " {key}: {alias};", key = quoted(&p.name));
}
out.push_str("};\n\n");
}
fn write_procedure_kinds(out: &mut String, ir: &Ir) {
out.push_str("/** Procedure name -> kind, for runtime dispatch. */\n");
out.push_str("export const procedureKinds = {\n");
for p in &ir.procedures {
let kind_lit = match p.kind {
taut_rpc::ir::ProcKind::Query => "\"query\"",
taut_rpc::ir::ProcKind::Mutation => "\"mutation\"",
taut_rpc::ir::ProcKind::Subscription => "\"subscription\"",
};
let _ = writeln!(out, " {key}: {kind_lit},", key = quoted(&p.name));
}
out.push_str(
"} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;\n\n",
);
}
fn write_create_api(out: &mut String) {
out.push_str("/** Construct a typed client for the procedures generated above. */\n");
out.push_str("export function createApi(opts: ClientOptions): ClientOf<Procedures> {\n");
out.push_str(
" return createClient<Procedures>({ ...opts, kinds: opts.kinds ?? procedureKinds });\n",
);
out.push_str("}\n");
}
fn write_schemas(out: &mut String, ir: &Ir, validator: Validator, tm_opts: &type_map::Options) {
if matches!(validator, Validator::None) || ir.types.is_empty() {
return;
}
let mut seen: BTreeMap<&str, &TypeDef> = BTreeMap::new();
let mut order: Vec<&TypeDef> = Vec::new();
for t in &ir.types {
if seen.insert(t.name.as_str(), t).is_none() {
order.push(t);
}
}
out.push_str("// ---- validator schemas ----\n\n");
for t in order {
write_type_schema(out, t, validator, tm_opts);
}
}
fn write_type_schema(
out: &mut String,
t: &TypeDef,
validator: Validator,
tm_opts: &type_map::Options,
) {
let name = &t.name;
let body = match &t.shape {
TypeShape::Struct(fields) => render_struct_schema(fields, validator, tm_opts),
TypeShape::Enum(e) => render_enum_schema(e, validator, tm_opts),
TypeShape::Tuple(elems) => render_tuple_schema(elems, validator, tm_opts),
TypeShape::Newtype(inner) | TypeShape::Alias(inner) => {
render_schema(inner, validator, tm_opts)
}
};
let _ = writeln!(out, "export const {name}Schema = {body};\n");
}
fn render_struct_schema(
fields: &[Field],
validator: Validator,
tm_opts: &type_map::Options,
) -> String {
let prefix = ns(validator);
if fields.is_empty() {
return format!("{prefix}.object({{}})");
}
let mut out = String::new();
out.push_str(prefix);
out.push_str(".object({\n");
for f in fields {
let expr = render_field_schema(f, validator, tm_opts);
let _ = writeln!(out, " {name}: {expr},", name = f.name);
}
out.push_str("})");
out
}
fn render_enum_schema(e: &EnumDef, validator: Validator, tm_opts: &type_map::Options) -> String {
let prefix = ns(validator);
if e.variants.is_empty() {
return match validator {
Validator::Valibot | Validator::Zod => format!("{prefix}.never()"),
Validator::None => unreachable!(),
};
}
let mut variant_exprs: Vec<String> = Vec::with_capacity(e.variants.len());
for v in &e.variants {
variant_exprs.push(render_variant_schema(&e.tag, v, validator, tm_opts));
}
match validator {
Validator::Valibot => {
let tag_lit = quoted(&e.tag);
let joined = variant_exprs.join(", ");
format!("{prefix}.variant({tag_lit}, [{joined}])")
}
Validator::Zod => {
let tag_lit = quoted(&e.tag);
let joined = variant_exprs.join(", ");
format!("{prefix}.discriminatedUnion({tag_lit}, [{joined}])")
}
Validator::None => unreachable!(),
}
}
fn render_variant_schema(
tag: &str,
v: &Variant,
validator: Validator,
tm_opts: &type_map::Options,
) -> String {
let prefix = ns(validator);
let tag_field = match validator {
Validator::Valibot | Validator::Zod => format!("{prefix}.literal({})", quoted(&v.name)),
Validator::None => unreachable!(),
};
match &v.payload {
VariantPayload::Unit => {
format!("{prefix}.object({{ {tag}: {tag_field} }})")
}
VariantPayload::Tuple(elems) => {
if elems.is_empty() {
return format!("{prefix}.object({{ {tag}: {tag_field} }})");
}
let inner: Vec<String> = elems
.iter()
.map(|e| render_schema(e, validator, tm_opts))
.collect();
let payload = format!("{prefix}.tuple([{}])", inner.join(", "));
format!("{prefix}.object({{ {tag}: {tag_field}, payload: {payload} }})")
}
VariantPayload::Struct(fields) => {
if fields.is_empty() {
return format!("{prefix}.object({{ {tag}: {tag_field} }})");
}
let mut s = String::new();
s.push_str(prefix);
s.push_str(".object({ ");
let _ = write!(s, "{tag}: {tag_field}");
for f in fields {
let expr = render_field_schema(f, validator, tm_opts);
let _ = write!(s, ", {name}: {expr}", name = f.name);
}
s.push_str(" })");
s
}
}
}
fn render_tuple_schema(
elems: &[TypeRef],
validator: Validator,
tm_opts: &type_map::Options,
) -> String {
let prefix = ns(validator);
if elems.is_empty() {
return format!("{prefix}.null()");
}
let inner: Vec<String> = elems
.iter()
.map(|t| render_schema(t, validator, tm_opts))
.collect();
format!("{prefix}.tuple([{}])", inner.join(", "))
}
fn render_schema(t: &TypeRef, validator: Validator, tm_opts: &type_map::Options) -> String {
match t {
TypeRef::Primitive(p) => render_primitive_schema(*p, validator, tm_opts),
TypeRef::Named(name) => format!("{name}Schema"),
TypeRef::Option(inner) => {
let inner_expr = render_schema(inner, validator, tm_opts);
let prefix = ns(validator);
match validator {
Validator::Valibot => format!("{prefix}.nullable({inner_expr})"),
Validator::Zod => format!("{inner_expr}.nullable()"),
Validator::None => unreachable!(),
}
}
TypeRef::Vec(inner) => {
let inner_expr = render_schema(inner, validator, tm_opts);
let prefix = ns(validator);
format!("{prefix}.array({inner_expr})")
}
TypeRef::Map { key, value } => {
let v_expr = render_schema(value, validator, tm_opts);
let prefix = ns(validator);
if is_string_keyed(key) {
match validator {
Validator::Valibot | Validator::Zod => {
format!("{prefix}.record({prefix}.string(), {v_expr})")
}
Validator::None => unreachable!(),
}
} else {
let k_expr = render_schema(key, validator, tm_opts);
format!("{prefix}.array({prefix}.tuple([{k_expr}, {v_expr}]))")
}
}
TypeRef::Tuple(elems) => render_tuple_schema(elems, validator, tm_opts),
TypeRef::FixedArray { elem, len } => {
let elem_expr = render_schema(elem, validator, tm_opts);
let prefix = ns(validator);
let parts: Vec<String> = (0..*len).map(|_| elem_expr.clone()).collect();
format!("{prefix}.tuple([{}])", parts.join(", "))
}
}
}
fn render_field_schema(f: &Field, validator: Validator, tm_opts: &type_map::Options) -> String {
let (inner_ty, is_option) = match &f.ty {
TypeRef::Option(inner) => (inner.as_ref(), true),
_ => (&f.ty, false),
};
let base = render_schema(inner_ty, validator, tm_opts);
let with_constraints = if f.constraints.is_empty() {
base
} else {
match validator {
Validator::Valibot => apply_valibot_constraints(&base, inner_ty, &f.constraints),
Validator::Zod => apply_zod_constraints(&base, inner_ty, &f.constraints),
Validator::None => unreachable!(),
}
};
if is_option {
let prefix = ns(validator);
match validator {
Validator::Valibot => format!("{prefix}.nullable({with_constraints})"),
Validator::Zod => format!("{with_constraints}.nullable()"),
Validator::None => unreachable!(),
}
} else {
with_constraints
}
}
fn apply_valibot_constraints(base: &str, inner_ty: &TypeRef, constraints: &[Constraint]) -> String {
let mut checks: Vec<String> = Vec::new();
let mut comments: Vec<String> = Vec::new();
for c in constraints {
match c {
Constraint::Min(n) => {
if is_string_typed(inner_ty) {
comments.push("/* min on string ignored — use length */".to_string());
} else {
checks.push(format!("v.minValue({})", render_number(*n)));
}
}
Constraint::Max(n) => {
if is_string_typed(inner_ty) {
comments.push("/* max on string ignored — use length */".to_string());
} else {
checks.push(format!("v.maxValue({})", render_number(*n)));
}
}
Constraint::Length { min, max } => {
if let Some(n) = min {
checks.push(format!("v.minLength({n})"));
}
if let Some(n) = max {
checks.push(format!("v.maxLength({n})"));
}
}
Constraint::Pattern(re) => {
checks.push(format!("v.regex({})", regex_literal(re)));
}
Constraint::Email => checks.push("v.email()".to_string()),
Constraint::Url => checks.push("v.url()".to_string()),
Constraint::Custom(name) => {
comments.push(format!("/* custom:{name} — supply your own check */"));
}
}
}
if checks.is_empty() {
if comments.is_empty() {
base.to_string()
} else {
format!("{} {}", base, comments.join(" "))
}
} else {
let trail = if comments.is_empty() {
String::new()
} else {
format!(" {}", comments.join(" "))
};
format!("v.pipe({}, {}){}", base, checks.join(", "), trail)
}
}
fn apply_zod_constraints(base: &str, inner_ty: &TypeRef, constraints: &[Constraint]) -> String {
let mut chain = String::from(base);
let mut comments: Vec<String> = Vec::new();
for c in constraints {
match c {
Constraint::Min(n) => {
if is_string_typed(inner_ty) {
comments.push("/* min on string ignored — use length */".to_string());
} else {
let _ = write!(chain, ".min({})", render_number(*n));
}
}
Constraint::Max(n) => {
if is_string_typed(inner_ty) {
comments.push("/* max on string ignored — use length */".to_string());
} else {
let _ = write!(chain, ".max({})", render_number(*n));
}
}
Constraint::Length { min, max } => {
if let Some(n) = min {
let _ = write!(chain, ".min({n})");
}
if let Some(n) = max {
let _ = write!(chain, ".max({n})");
}
}
Constraint::Pattern(re) => {
let _ = write!(chain, ".regex({})", regex_literal(re));
}
Constraint::Email => chain.push_str(".email()"),
Constraint::Url => chain.push_str(".url()"),
Constraint::Custom(name) => {
comments.push(format!("/* custom:{name} — supply your own check */"));
}
}
}
if comments.is_empty() {
chain
} else {
format!("{chain} {}", comments.join(" "))
}
}
#[allow(clippy::match_same_arms)] fn render_primitive_schema(
p: Primitive,
validator: Validator,
tm_opts: &type_map::Options,
) -> String {
use Primitive::{
Bool, Bytes, DateTime, String, Unit, Uuid, F32, F64, I128, I16, I32, I64, I8, U128, U16,
U32, U64, U8,
};
let prefix = ns(validator);
if matches!(p, U64 | I64 | U128 | I128) && tm_opts.bigint == BigIntStrategy::Native {
return match validator {
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(),
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(),
Validator::None => unreachable!(),
};
}
let suffix = match p {
Bool => "boolean()",
U8 | U16 | U32 | I8 | I16 | I32 | F32 | F64 => "number()",
U64 | I64 | U128 | I128 => match tm_opts.bigint {
BigIntStrategy::Native => "bigint()",
BigIntStrategy::AsString => "string()",
},
String => "string()",
Bytes => "string()",
Unit => "null()",
DateTime => "string()",
Uuid => "string()",
};
format!("{prefix}.{suffix}")
}
fn ns(validator: Validator) -> &'static str {
match validator {
Validator::Valibot => "v",
Validator::Zod => "z",
Validator::None => "",
}
}
fn is_string_typed(t: &TypeRef) -> bool {
matches!(
t,
TypeRef::Primitive(
Primitive::String | Primitive::Bytes | Primitive::DateTime | Primitive::Uuid,
)
)
}
fn is_string_keyed(key: &TypeRef) -> bool {
matches!(
key,
TypeRef::Primitive(Primitive::String | Primitive::DateTime | Primitive::Uuid,)
)
}
fn render_number(n: f64) -> String {
#[allow(clippy::cast_possible_truncation)]
if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e16 {
format!("{}", n as i64)
} else {
format!("{n}")
}
}
fn regex_literal(re: &str) -> String {
let mut out = String::with_capacity(re.len() + 2);
out.push('/');
for ch in re.chars() {
if ch == '/' {
out.push_str("\\/");
} else {
out.push(ch);
}
}
out.push('/');
out
}
fn write_procedure_schemas(
out: &mut String,
ir: &Ir,
validator: Validator,
tm_opts: &type_map::Options,
) {
if matches!(validator, Validator::None) {
return;
}
let known: BTreeSet<&str> = ir.types.iter().map(|t| t.name.as_str()).collect();
out.push_str("// ---- procedure schemas ----\n\n");
let (schema_ty, parse_call) = match validator {
Validator::Valibot => (
"v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>",
"v.parse(schema, value)",
),
Validator::Zod => ("z.ZodTypeAny", "schema.parse(value)"),
Validator::None => unreachable!("guarded above"),
};
let _ = writeln!(
out,
"function __taut_wrap(schema: {schema_ty}): {{ parse(value: unknown): unknown }} {{",
);
let _ = writeln!(
out,
" return {{ parse: (value: unknown) => {parse_call} }};"
);
out.push_str("}\n\n");
for p in &ir.procedures {
let alias = procedure_alias_name(&p.name);
let input_expr = procedure_io_schema(&p.input, validator, tm_opts, &known);
let output_expr = procedure_io_schema(&p.output, validator, tm_opts, &known);
let _ = writeln!(out, "export const {alias}_inputSchema = {input_expr};");
let _ = writeln!(out, "export const {alias}_outputSchema = {output_expr};");
}
out.push('\n');
out.push_str("/** Procedure name -> { input, output } schema, for runtime validation. */\n");
out.push_str("export const procedureSchemas = {\n");
for p in &ir.procedures {
let alias = procedure_alias_name(&p.name);
let _ = writeln!(
out,
" {key}: {{ input: __taut_wrap({alias}_inputSchema), output: __taut_wrap({alias}_outputSchema) }},",
key = quoted(&p.name),
);
}
out.push_str("};\n\n");
}
fn procedure_io_schema(
t: &TypeRef,
validator: Validator,
tm_opts: &type_map::Options,
known: &BTreeSet<&str>,
) -> String {
if let TypeRef::Named(name) = t {
if known.contains(name.as_str()) {
return format!("{name}Schema");
}
return format!("{name}Schema");
}
render_schema(t, validator, tm_opts)
}
fn procedure_alias_name(proc_name: &str) -> String {
let mut s = String::with_capacity(5 + proc_name.len());
s.push_str("Proc_");
for ch in proc_name.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
s.push(ch);
} else {
s.push('_');
}
}
s
}
fn procedure_error_alias_name(proc_name: &str) -> String {
let mut s = procedure_alias_name(proc_name);
s.push_str("_Error");
s
}
fn quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
_ => out.push(ch),
}
}
out.push('"');
out
}
fn write_doc_comment(out: &mut String, doc: &str, indent: &str) {
let lines: Vec<&str> = doc.lines().collect();
if lines.len() == 1 {
let body = lines[0].trim();
let _ = writeln!(out, "{indent}/** {body} */");
return;
}
let _ = writeln!(out, "{indent}/**");
for line in lines {
let body = line.trim_end();
let _ = writeln!(out, "{indent} * {body}");
}
let _ = writeln!(out, "{indent} */");
}
#[cfg(test)]
mod tests {
use super::*;
use taut_rpc::ir::{
Constraint, EnumDef, Field, HttpMethod, Ir, Primitive, ProcKind, Procedure, TypeDef,
TypeRef, TypeShape, Variant, VariantPayload,
};
fn opts() -> CodegenOptions {
CodegenOptions::default()
}
fn opts_no_validator() -> CodegenOptions {
CodegenOptions {
validator: Validator::None,
..CodegenOptions::default()
}
}
#[test]
fn empty_ir_emits_header_imports_and_empty_procedures_map() {
let ir = Ir::empty();
let s = render_ts(&ir, &opts_no_validator());
assert!(s.contains("DO NOT EDIT"), "header missing:\n{s}");
assert!(
s.contains("import type { ProcedureDef } from \"taut-rpc\";"),
"type-only import missing:\n{s}"
);
assert!(
s.contains(
"import { createClient, type ClientOptions, type ClientOf } from \"taut-rpc\";"
),
"value import missing:\n{s}"
);
assert!(
s.contains("export type Procedures = {\n};"),
"empty Procedures map missing:\n{s}"
);
assert!(
s.contains("export const procedureKinds = {\n} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;"),
"empty procedureKinds missing:\n{s}"
);
assert!(
s.contains("export function createApi(opts: ClientOptions): ClientOf<Procedures>"),
"createApi missing:\n{s}"
);
assert!(
s.contains(
"createClient<Procedures>({ ...opts, kinds: opts.kinds ?? procedureKinds });"
),
"createApi should thread procedureKinds through to createClient:\n{s}"
);
}
#[test]
fn one_query_string_to_u32_emits_proc_alias() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![Procedure {
name: "ping".to_string(),
kind: ProcKind::Query,
input: TypeRef::Primitive(Primitive::String),
output: TypeRef::Primitive(Primitive::U32),
errors: vec![],
http_method: HttpMethod::Post,
doc: None,
}],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export type Proc_ping = ProcedureDef<string, number, never, \"query\">;"),
"proc alias wrong:\n{s}"
);
assert!(
s.contains("\"ping\": Proc_ping;"),
"Procedures key wrong:\n{s}"
);
assert!(
!s.contains("Proc_ping_Error"),
"zero-error procedure must not emit error alias:\n{s}"
);
assert!(
s.contains("\"ping\": \"query\","),
"procedureKinds entry missing:\n{s}"
);
}
#[test]
fn dotted_procedure_name_becomes_underscored_alias() {
assert_eq!(procedure_alias_name("users.get"), "Proc_users_get");
assert_eq!(procedure_alias_name("ping"), "Proc_ping");
assert_eq!(procedure_alias_name("a.b.c"), "Proc_a_b_c");
}
#[test]
fn struct_typedef_emits_interface_with_all_three_field_modes() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![TypeDef {
name: "User".to_string(),
doc: None,
shape: TypeShape::Struct(vec![
Field {
name: "id".to_string(),
ty: TypeRef::Primitive(Primitive::U32),
optional: false,
undefined: false,
doc: None,
constraints: vec![],
},
Field {
name: "nickname".to_string(),
ty: TypeRef::Primitive(Primitive::String),
optional: true,
undefined: false,
doc: None,
constraints: vec![],
},
Field {
name: "tagline".to_string(),
ty: TypeRef::Primitive(Primitive::String),
optional: false,
undefined: true,
doc: None,
constraints: vec![],
},
Field {
name: "avatar".to_string(),
ty: TypeRef::Primitive(Primitive::String),
optional: true,
undefined: true,
doc: None,
constraints: vec![],
},
]),
}],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(s.contains("export interface User {"), "no interface:\n{s}");
assert!(s.contains("id: number;"), "plain field wrong:\n{s}");
assert!(s.contains("nickname?: string;"), "optional wrong:\n{s}");
assert!(
s.contains("tagline: string | undefined;"),
"undefined wrong:\n{s}"
);
assert!(
s.contains("avatar?: string | undefined;"),
"both wrong:\n{s}"
);
}
#[test]
fn enum_typedef_emits_discriminated_union() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![TypeDef {
name: "Event".to_string(),
doc: None,
shape: TypeShape::Enum(EnumDef {
tag: "type".to_string(),
variants: vec![
Variant {
name: "Ping".to_string(),
payload: VariantPayload::Unit,
},
Variant {
name: "Message".to_string(),
payload: VariantPayload::Tuple(vec![TypeRef::Primitive(
Primitive::String,
)]),
},
Variant {
name: "Move".to_string(),
payload: VariantPayload::Struct(vec![
Field {
name: "x".to_string(),
ty: TypeRef::Primitive(Primitive::I32),
optional: false,
undefined: false,
doc: None,
constraints: vec![],
},
Field {
name: "y".to_string(),
ty: TypeRef::Primitive(Primitive::I32),
optional: false,
undefined: false,
doc: None,
constraints: vec![],
},
]),
},
],
}),
}],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(s.contains("export type Event ="), "header missing:\n{s}");
assert!(s.contains("{ type: \"Ping\" }"), "unit variant wrong:\n{s}");
assert!(
s.contains("{ type: \"Message\", payload: [string] }"),
"tuple variant wrong:\n{s}"
);
assert!(
s.contains("{ type: \"Move\","),
"struct variant header wrong:\n{s}"
);
assert!(s.contains("x: number"), "x field wrong:\n{s}");
assert!(s.contains("y: number"), "y field wrong:\n{s}");
}
#[test]
fn newtype_and_alias_emit_type_aliases() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![
TypeDef {
name: "UserId".to_string(),
doc: None,
shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U64)),
},
TypeDef {
name: "Maybe".to_string(),
doc: None,
shape: TypeShape::Alias(TypeRef::Option(Box::new(TypeRef::Primitive(
Primitive::String,
)))),
},
TypeDef {
name: "Pair".to_string(),
doc: None,
shape: TypeShape::Tuple(vec![
TypeRef::Primitive(Primitive::I32),
TypeRef::Primitive(Primitive::String),
]),
},
],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export type UserId = bigint;"),
"newtype wrong:\n{s}"
);
assert!(
s.contains("export type Maybe = string | null;"),
"alias wrong:\n{s}"
);
assert!(
s.contains("export type Pair = [number, string];"),
"tuple wrong:\n{s}"
);
}
#[test]
fn duplicate_typedefs_with_equal_bodies_dedup_silently() {
let dup = TypeDef {
name: "Same".to_string(),
doc: None,
shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U32)),
};
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![dup.clone(), dup],
};
let s = render_ts_checked(&ir, &opts_no_validator()).expect("dedup ok");
let count = s.matches("export type Same = number;").count();
assert_eq!(count, 1, "should appear exactly once:\n{s}");
}
#[test]
fn duplicate_typedefs_with_conflicting_bodies_error() {
let a = TypeDef {
name: "Same".to_string(),
doc: None,
shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::U32)),
};
let b = TypeDef {
name: "Same".to_string(),
doc: None,
shape: TypeShape::Newtype(TypeRef::Primitive(Primitive::String)),
};
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![a, b],
};
let err = render_ts_checked(&ir, &opts_no_validator()).unwrap_err();
assert!(err.contains("Same"), "err should mention name: {err}");
}
#[test]
fn multiple_errors_render_as_union() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![Procedure {
name: "p".to_string(),
kind: ProcKind::Mutation,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::Unit),
errors: vec![
TypeRef::Named("NotFound".to_string()),
TypeRef::Named("Unauthorized".to_string()),
],
http_method: HttpMethod::Post,
doc: None,
}],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export type Proc_p_Error = NotFound | Unauthorized;"),
"per-procedure error alias missing:\n{s}"
);
assert!(
s.contains(
"export type Proc_p = ProcedureDef<void, void, Proc_p_Error, \"mutation\">;"
),
"ProcedureDef should reference Proc_p_Error alias:\n{s}"
);
let alias_idx = s.find("export type Proc_p_Error =").expect("alias present");
let def_idx = s.find("export type Proc_p =").expect("def present");
assert!(alias_idx < def_idx, "alias must precede ProcedureDef:\n{s}");
assert!(
s.contains("\"p\": \"mutation\","),
"procedureKinds entry missing:\n{s}"
);
}
#[test]
fn single_error_emits_error_alias() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![Procedure {
name: "add".to_string(),
kind: ProcKind::Query,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::U32),
errors: vec![TypeRef::Named("AddError".to_string())],
http_method: HttpMethod::Post,
doc: None,
}],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export type Proc_add_Error = AddError;"),
"single-error alias missing:\n{s}"
);
assert!(
s.contains(
"export type Proc_add = ProcedureDef<void, number, Proc_add_Error, \"query\">;"
),
"ProcedureDef must reference single-error alias:\n{s}"
);
assert!(
s.contains("Wire-shape error union for procedure `add`. Narrow on `.code`."),
"alias doc comment missing or wrong:\n{s}"
);
}
#[test]
fn dotted_procedure_name_emits_dotted_error_alias() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![Procedure {
name: "users.get".to_string(),
kind: ProcKind::Query,
input: TypeRef::Primitive(Primitive::U32),
output: TypeRef::Named("User".to_string()),
errors: vec![TypeRef::Named("NotFound".to_string())],
http_method: HttpMethod::Get,
doc: None,
}],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export type Proc_users_get_Error = NotFound;"),
"dotted-name error alias missing:\n{s}"
);
assert!(
s.contains(
"export type Proc_users_get = ProcedureDef<number, User, Proc_users_get_Error, \"query\">;"
),
"dotted-name ProcedureDef wrong:\n{s}"
);
assert_eq!(
procedure_error_alias_name("users.get"),
"Proc_users_get_Error"
);
}
#[test]
fn procedure_kinds_const_emitted_with_satisfies_clause() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![
Procedure {
name: "ping".to_string(),
kind: ProcKind::Query,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::String),
errors: vec![],
http_method: HttpMethod::Post,
doc: None,
},
Procedure {
name: "do_thing".to_string(),
kind: ProcKind::Mutation,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::Unit),
errors: vec![],
http_method: HttpMethod::Post,
doc: None,
},
Procedure {
name: "events".to_string(),
kind: ProcKind::Subscription,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::String),
errors: vec![],
http_method: HttpMethod::Get,
doc: None,
},
],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export const procedureKinds = {"),
"procedureKinds const missing:\n{s}"
);
assert!(s.contains("\"ping\": \"query\","), "ping entry:\n{s}");
assert!(
s.contains("\"do_thing\": \"mutation\","),
"do_thing entry:\n{s}"
);
assert!(
s.contains("\"events\": \"subscription\","),
"events entry:\n{s}"
);
assert!(
s.contains("} as const satisfies Record<keyof Procedures, \"query\" | \"mutation\" | \"subscription\">;"),
"as-const-satisfies tail missing:\n{s}"
);
}
#[test]
fn subscription_procedure_emits_subscription_kind_in_alias_and_kinds_map() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![Procedure {
name: "ticker".to_string(),
kind: ProcKind::Subscription,
input: TypeRef::Primitive(Primitive::U32),
output: TypeRef::Primitive(Primitive::String),
errors: vec![],
http_method: HttpMethod::Get,
doc: None,
}],
types: vec![],
};
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains(
"export type Proc_ticker = ProcedureDef<number, string, never, \"subscription\">;"
),
"subscription proc alias wrong:\n{s}"
);
assert!(
s.contains("\"ticker\": \"subscription\","),
"procedureKinds entry must record subscription kind:\n{s}"
);
assert!(
s.contains(
"/** Subscription procedure — call via `.subscribe(input)`, returns AsyncIterable. */"
),
"subscription JSDoc hint missing:\n{s}"
);
let jsdoc_idx = s
.find("Subscription procedure — call via")
.expect("jsdoc present");
let alias_idx = s.find("export type Proc_ticker =").expect("alias present");
assert!(
jsdoc_idx < alias_idx,
"JSDoc must precede the type alias:\n{s}"
);
}
fn opts_zod() -> CodegenOptions {
CodegenOptions {
validator: Validator::Zod,
..CodegenOptions::default()
}
}
fn one_struct_ir(name: &str, fields: Vec<Field>) -> Ir {
Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![TypeDef {
name: name.to_string(),
doc: None,
shape: TypeShape::Struct(fields),
}],
}
}
fn plain_field(name: &str, ty: TypeRef, constraints: Vec<Constraint>) -> Field {
Field {
name: name.to_string(),
ty,
optional: false,
undefined: false,
doc: None,
constraints,
}
}
#[test]
fn valibot_emits_schema_for_simple_struct() {
let ir = one_struct_ir(
"User",
vec![
plain_field("id", TypeRef::Primitive(Primitive::U64), vec![]),
plain_field("name", TypeRef::Primitive(Primitive::String), vec![]),
],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("import * as v from \"valibot\";"),
"valibot import missing:\n{s}"
);
assert!(
s.contains("export const UserSchema = v.object({"),
"UserSchema header missing:\n{s}"
);
assert!(
s.contains("export interface User {"),
"interface should still be emitted:\n{s}"
);
assert!(
s.contains(
"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())"
),
"id field schema wrong (expected bigint coercion pipe):\n{s}"
);
assert!(
s.contains("name: v.string()"),
"name field schema wrong:\n{s}"
);
}
#[test]
fn valibot_applies_email_constraint_to_string_field() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"email",
TypeRef::Primitive(Primitive::String),
vec![Constraint::Email],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("email: v.pipe(v.string(), v.email())"),
"email constraint missing:\n{s}"
);
}
#[test]
fn valibot_applies_min_max_to_number_field() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"score",
TypeRef::Primitive(Primitive::U32),
vec![Constraint::Min(0.0), Constraint::Max(100.0)],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("score: v.pipe(v.number(), v.minValue(0), v.maxValue(100))"),
"min/max chain wrong:\n{s}"
);
}
#[test]
fn valibot_applies_length_to_string_field() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"name",
TypeRef::Primitive(Primitive::String),
vec![Constraint::Length {
min: Some(1),
max: Some(64),
}],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("name: v.pipe(v.string(), v.minLength(1), v.maxLength(64))"),
"length chain wrong:\n{s}"
);
}
#[test]
fn valibot_pattern_renders_regex_literal() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"slug",
TypeRef::Primitive(Primitive::String),
vec![Constraint::Pattern(r"^[a-z]+$".to_string())],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("slug: v.pipe(v.string(), v.regex(/^[a-z]+$/))"),
"regex literal wrong:\n{s}"
);
}
#[test]
fn valibot_custom_constraint_emits_breadcrumb_only() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"secret",
TypeRef::Primitive(Primitive::String),
vec![Constraint::Custom("must_be_prime".to_string())],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("custom:must_be_prime"),
"custom breadcrumb missing:\n{s}"
);
assert!(
!s.contains("v.must_be_prime"),
"custom must not become a validator call:\n{s}"
);
}
#[test]
fn valibot_emits_procedure_schemas_map() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![
Procedure {
name: "create_user".to_string(),
kind: ProcKind::Mutation,
input: TypeRef::Named("CreateUserInput".to_string()),
output: TypeRef::Named("User".to_string()),
errors: vec![],
http_method: HttpMethod::Post,
doc: None,
},
Procedure {
name: "ping".to_string(),
kind: ProcKind::Query,
input: TypeRef::Primitive(Primitive::Unit),
output: TypeRef::Primitive(Primitive::String),
errors: vec![],
http_method: HttpMethod::Post,
doc: None,
},
],
types: vec![
TypeDef {
name: "CreateUserInput".to_string(),
doc: None,
shape: TypeShape::Struct(vec![plain_field(
"name",
TypeRef::Primitive(Primitive::String),
vec![],
)]),
},
TypeDef {
name: "User".to_string(),
doc: None,
shape: TypeShape::Struct(vec![plain_field(
"id",
TypeRef::Primitive(Primitive::U64),
vec![],
)]),
},
],
};
let s = render_ts(&ir, &opts());
assert!(
s.contains("export const Proc_create_user_inputSchema = CreateUserInputSchema;"),
"input alias missing:\n{s}"
);
assert!(
s.contains("export const Proc_create_user_outputSchema = UserSchema;"),
"output alias missing:\n{s}"
);
assert!(
s.contains("export const Proc_ping_inputSchema = v.null();"),
"primitive input schema wrong:\n{s}"
);
assert!(
s.contains("export const Proc_ping_outputSchema = v.string();"),
"primitive output schema wrong:\n{s}"
);
assert!(
s.contains("export const procedureSchemas = {"),
"procedureSchemas header missing:\n{s}"
);
assert!(
s.contains(
"\"create_user\": { input: __taut_wrap(Proc_create_user_inputSchema), output: __taut_wrap(Proc_create_user_outputSchema) },"
),
"create_user map entry wrong:\n{s}"
);
assert!(
s.contains(
"\"ping\": { input: __taut_wrap(Proc_ping_inputSchema), output: __taut_wrap(Proc_ping_outputSchema) },"
),
"ping map entry wrong:\n{s}"
);
}
#[test]
fn zod_emits_z_namespace_import() {
let s = render_ts(&Ir::empty(), &opts_zod());
assert!(
s.contains("import { z } from \"zod\";"),
"zod import missing:\n{s}"
);
assert!(
!s.contains("import * as v from \"valibot\";"),
"valibot import should be absent:\n{s}"
);
}
#[test]
fn zod_applies_email_chain() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"email",
TypeRef::Primitive(Primitive::String),
vec![Constraint::Email],
)],
);
let s = render_ts(&ir, &opts_zod());
assert!(
s.contains("email: z.string().email()"),
"zod email chain wrong:\n{s}"
);
}
#[test]
fn zod_emits_bigint_coercion_for_u64() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"id",
TypeRef::Primitive(Primitive::U64),
vec![],
)],
);
let s = render_ts(&ir, &opts_zod());
assert!(
s.contains(
"id: z.union([z.bigint(), z.number(), z.string()]).transform(x => typeof x === \"bigint\" ? x : BigInt(x as any)).pipe(z.bigint())"
),
"zod bigint coercion missing:\n{s}"
);
}
#[test]
fn zod_applies_min_max_chain() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"age",
TypeRef::Primitive(Primitive::U8),
vec![Constraint::Min(0.0), Constraint::Max(120.0)],
)],
);
let s = render_ts(&ir, &opts_zod());
assert!(
s.contains("age: z.number().min(0).max(120)"),
"zod min/max chain wrong:\n{s}"
);
}
#[test]
fn none_validator_emits_no_schemas() {
let ir = one_struct_ir(
"User",
vec![plain_field(
"id",
TypeRef::Primitive(Primitive::U32),
vec![],
)],
);
let s = render_ts(&ir, &opts_no_validator());
assert!(
s.contains("export interface User {"),
"interface still emitted:\n{s}"
);
assert!(
!s.contains("UserSchema"),
"UserSchema must not appear:\n{s}"
);
assert!(
!s.contains("procedureSchemas"),
"procedureSchemas must not appear:\n{s}"
);
assert!(
!s.contains("import * as v from \"valibot\";"),
"valibot import must be absent:\n{s}"
);
assert!(
!s.contains("import { z } from \"zod\";"),
"zod import must be absent:\n{s}"
);
}
#[test]
fn valibot_named_type_references_named_schema() {
let ir = Ir {
ir_version: Ir::CURRENT_VERSION,
procedures: vec![],
types: vec![
TypeDef {
name: "Address".to_string(),
doc: None,
shape: TypeShape::Struct(vec![plain_field(
"city",
TypeRef::Primitive(Primitive::String),
vec![],
)]),
},
TypeDef {
name: "User".to_string(),
doc: None,
shape: TypeShape::Struct(vec![plain_field(
"address",
TypeRef::Named("Address".to_string()),
vec![],
)]),
},
],
};
let s = render_ts(&ir, &opts());
assert!(
s.contains("address: AddressSchema"),
"Named ref should resolve to AddressSchema:\n{s}"
);
}
#[test]
fn valibot_optional_field_wraps_constraints_in_nullable() {
let ir = one_struct_ir(
"User",
vec![Field {
name: "email".to_string(),
ty: TypeRef::Option(Box::new(TypeRef::Primitive(Primitive::String))),
optional: true,
undefined: false,
doc: None,
constraints: vec![Constraint::Email],
}],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("email: v.nullable(v.pipe(v.string(), v.email()))"),
"nullable+pipe composition wrong:\n{s}"
);
}
#[test]
fn valibot_vec_renders_array_schema() {
let ir = one_struct_ir(
"Page",
vec![plain_field(
"items",
TypeRef::Vec(Box::new(TypeRef::Primitive(Primitive::String))),
vec![],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("items: v.array(v.string())"),
"array schema wrong:\n{s}"
);
}
#[test]
fn valibot_map_renders_record_schema() {
let ir = one_struct_ir(
"Map",
vec![plain_field(
"counts",
TypeRef::Map {
key: Box::new(TypeRef::Primitive(Primitive::String)),
value: Box::new(TypeRef::Primitive(Primitive::U32)),
},
vec![],
)],
);
let s = render_ts(&ir, &opts());
assert!(
s.contains("counts: v.record(v.string(), v.number())"),
"record schema wrong:\n{s}"
);
}
}