diff --git a/specta-zod/src/context.rs b/specta-zod/src/context.rs index 5341595d..39b0e454 100644 --- a/specta-zod/src/context.rs +++ b/specta-zod/src/context.rs @@ -1,18 +1,17 @@ use std::{borrow::Cow, fmt}; -use crate::{export_config::ExportConfig, ImplLocation}; +use crate::Zod; #[derive(Clone, Debug)] pub(crate) enum PathItem { Type(Cow<'static, str>), - TypeExtended(Cow<'static, str>, ImplLocation), Field(Cow<'static, str>), Variant(Cow<'static, str>), } #[derive(Clone)] pub(crate) struct ExportContext<'a> { - pub(crate) cfg: &'a ExportConfig, + pub(crate) cfg: &'a Zod, pub(crate) path: Vec, // `false` when inline'ing and `true` when exporting as named. pub(crate) is_export: bool, @@ -42,7 +41,6 @@ impl ExportPath { while let Some(item) = path.next() { s.push_str(match item { PathItem::Type(v) => v, - PathItem::TypeExtended(_, loc) => loc.as_str(), PathItem::Field(v) => v, PathItem::Variant(v) => v, }); @@ -50,7 +48,6 @@ impl ExportPath { if let Some(next) = path.peek() { s.push_str(match next { PathItem::Type(_) => " -> ", - PathItem::TypeExtended(_, _) => " -> ", PathItem::Field(_) => ".", PathItem::Variant(_) => "::", }); diff --git a/specta-zod/src/error.rs b/specta-zod/src/error.rs index b247f073..3748c66c 100644 --- a/specta-zod/src/error.rs +++ b/specta-zod/src/error.rs @@ -1,10 +1,9 @@ use core::fmt; -use std::borrow::Cow; +use std::{borrow::Cow, panic::Location}; -use specta_serde::SerdeError; use thiserror::Error; -use crate::{context::ExportPath, ImplLocation}; +use crate::context::ExportPath; /// Describes where an error occurred. #[derive(Error, Debug, PartialEq)] @@ -24,16 +23,14 @@ impl fmt::Display for NamedLocation { } } -/// The error type for the TypeScript exporter. +/// The error type for the Zod exporter. #[derive(Error, Debug)] #[non_exhaustive] -pub enum ExportError { +pub enum Error { #[error("Attempted to export '{0}' but Specta configuration forbids exporting BigInt types (i64, u64, i128, u128) because we don't know if your se/deserializer supports it. You can change this behavior by editing your `ExportConfiguration`!")] BigIntForbidden(ExportPath), #[error("Serde error: {0}")] - Serde(#[from] SerdeError), - // #[error("Attempted to export '{0}' but was unable to export a tagged type which is unnamed")] - // UnableToTagUnnamedType(ExportPath), + Serde(#[from] specta_serde::Error), #[error("Attempted to export '{1}' but was unable to due to {0} name '{2}' conflicting with a reserved keyword in Typescript. Try renaming it or using `#[specta(rename = \"new name\")]`")] ForbiddenName(NamedLocation, ExportPath, &'static str), #[error("Attempted to export '{1}' but was unable to due to {0} name '{2}' containing an invalid character")] @@ -42,8 +39,11 @@ pub enum ExportError { InvalidTagging(ExportPath), #[error("Attempted to export '{0}' with internal tagging but the variant is a tuple struct.")] InvalidTaggedVariantContainingTupleStruct(ExportPath), - #[error("Unable to export type named '{0}' from locations '{:?}' '{:?}'", .1.as_str(), .2.as_str())] - DuplicateTypeName(Cow<'static, str>, ImplLocation, ImplLocation), + #[error("Unable to export type named '{name}' from multiple locations")] + DuplicateTypeName { + name: Cow<'static, str>, + types: (Location<'static>, Location<'static>), + }, #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Failed to export '{0}' due to error: {1}")] @@ -51,12 +51,11 @@ pub enum ExportError { } // TODO: This `impl` is cringe -impl PartialEq for ExportError { +impl PartialEq for Error { fn eq(&self, other: &Self) -> bool { match (self, other) { (Self::BigIntForbidden(l0), Self::BigIntForbidden(r0)) => l0 == r0, (Self::Serde(l0), Self::Serde(r0)) => l0 == r0, - // (Self::UnableToTagUnnamedType(l0), Self::UnableToTagUnnamedType(r0)) => l0 == r0, (Self::ForbiddenName(l0, l1, l2), Self::ForbiddenName(r0, r1, r2)) => { l0 == r0 && l1 == r1 && l2 == r2 } @@ -68,8 +67,8 @@ impl PartialEq for ExportError { Self::InvalidTaggedVariantContainingTupleStruct(l0), Self::InvalidTaggedVariantContainingTupleStruct(r0), ) => l0 == r0, - (Self::DuplicateTypeName(l0, l1, l2), Self::DuplicateTypeName(r0, r1, r2)) => { - l0 == r0 && l1 == r1 && l2 == r2 + (Self::DuplicateTypeName { name: l0, types: l1 }, Self::DuplicateTypeName { name: r0, types: r1 }) => { + l0 == r0 && l1 == r1 } (Self::Io(l0), Self::Io(r0)) => l0.to_string() == r0.to_string(), // This is a bit hacky but it will be fine for usage in unit tests! (Self::Other(l0, l1), Self::Other(r0, r1)) => l0 == r0 && l1 == r1, diff --git a/specta-zod/src/export_config.rs b/specta-zod/src/export_config.rs index 368f5c02..a4f58a88 100644 --- a/specta-zod/src/export_config.rs +++ b/specta-zod/src/export_config.rs @@ -1,103 +1,22 @@ -use std::{borrow::Cow, io, path::PathBuf}; -use specta_typescript::{comments, CommentFormatterFn}; -use crate::DeprecatedType; - -#[derive(Debug)] -#[non_exhaustive] -pub struct CommentFormatterArgs<'a> { - pub docs: &'a Cow<'static, str>, - pub deprecated: Option<&'a DeprecatedType>, -} - -/// The signature for a function responsible for exporting Typescript comments. -// pub type CommentFormatterFn = fn(CommentFormatterArgs) -> String; // TODO: Returning `Cow`??? - -/// The signature for a function responsible for formatter a Typescript file. -pub type FormatterFn = fn(PathBuf) -> io::Result<()>; - -/// Options for controlling the behavior of the Typescript exporter. -#[derive(Debug, Clone)] -pub struct ExportConfig { - /// How BigInts should be exported. - pub(crate) bigint: BigIntExportBehavior, - /// How comments should be rendered. - pub(crate) comment_exporter: Option, - /// How the resulting file should be formatted. - pub(crate) formatter: Option, -} - -impl ExportConfig { - /// Construct a new `ExportConfiguration` - pub fn new() -> Self { - Default::default() - } - - /// Configure the BigInt handling behaviour - pub fn bigint(mut self, bigint: BigIntExportBehavior) -> Self { - self.bigint = bigint; - self - } - - /// Configure a function which is responsible for styling the comments to be exported - /// - /// Implementations: - /// - [`js_doc`](crate::lang::ts::js_doc) - /// - /// Not calling this method will default to the [`js_doc`](crate::lang::ts::js_doc) exporter. - /// `None` will disable comment exporting. - /// `Some(exporter)` will enable comment exporting using the provided exporter. - pub fn comment_style(mut self, exporter: Option) -> Self { - self.comment_exporter = exporter; - self - } - - /// Configure a function which is responsible for formatting the result file or files - /// - /// - /// Implementations: - /// - [`prettier`](crate::lang::ts::prettier) - /// - [`ESLint`](crate::lang::ts::eslint) - pub fn formatter(mut self, formatter: FormatterFn) -> Self { - self.formatter = Some(formatter); - self - } - - /// Run the specified formatter on the given path. - pub fn run_format(&self, path: PathBuf) -> io::Result<()> { - if let Some(formatter) = self.formatter { - formatter(path)?; - } - Ok(()) - } -} - -impl Default for ExportConfig { - fn default() -> Self { - Self { - bigint: Default::default(), - comment_exporter: Some(comments::js_doc), - formatter: None, - } - } -} - -/// Allows you to configure how Specta's Typescript exporter will deal with BigInt types ([i64], [i128] etc). +/// Allows you to configure how Specta's Zod exporter will deal with BigInt types ([i64], [i128] etc). /// /// WARNING: None of these settings affect how your data is actually ser/deserialized. /// It's up to you to adjust your ser/deserialize settings. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum BigIntExportBehavior { - /// Export BigInt as a Typescript `string` + /// Export BigInt as a Zod `z.string()` /// /// Doing this in serde is [pretty simple](https://github.com/serde-rs/json/issues/329#issuecomment-305608405). String, - /// Export BigInt as a Typescript `number`. + /// Export BigInt as a Zod `z.number()`. /// /// WARNING: `JSON.parse` in JS will truncate your number resulting in data loss so ensure your deserializer supports large numbers. Number, - /// Export BigInt as a Typescript `BigInt`. + /// Export BigInt as a Zod `z.bigint()`. + /// + /// You must ensure your deserializer is able to support this. BigInt, /// Abort the export with an error. /// diff --git a/specta-zod/src/lib.rs b/specta-zod/src/lib.rs index 7230e78c..e6840193 100644 --- a/specta-zod/src/lib.rs +++ b/specta-zod/src/lib.rs @@ -1,804 +1,588 @@ -//! TODO +//! [Zod](https://zod.dev) schema exporter for TypeScript. +//! +//! # Usage +//! +//! Add `specta` and `specta-zod` to your project: +//! +//! ```bash +//! cargo add specta@2.0.0-rc.22 --features derive,export +//! cargo add specta-zod@0.0.1 +//! cargo add specta-serde@0.0.9 +//! ``` +//! +//! Next copy the following into your `main.rs` file: +//! +//! ```rust +//! use specta::{Type, TypeCollection}; +//! use specta_zod::Zod; +//! +//! #[derive(Type)] +//! pub struct MyType { +//! pub field: MyOtherType, +//! } +//! +//! #[derive(Type)] +//! pub struct MyOtherType { +//! pub other_field: String, +//! } +//! +//! fn main() { +//! let mut types = TypeCollection::default() +//! // We don't need to specify `MyOtherType` because it's referenced by `MyType` +//! .register::(); +//! +//! Zod::default() +//! .export_to("./schemas.ts", &types) +//! .unwrap(); +//! } +//! ``` +//! +//! Now your setup with Specta! +//! +//! If you get tired of listing all your types, checkout [`specta::export`]. +//! #![cfg_attr(docsrs, feature(doc_cfg))] #![doc( html_logo_url = "https://github.com/oscartbeaumont/specta/raw/main/.github/logo-128.png", html_favicon_url = "https://github.com/oscartbeaumont/specta/raw/main/.github/logo-128.png" )] -// mod context; -// mod error; -// mod export_config; - -// use context::{ExportContext, PathItem}; -// use specta::{ -// datatype::*, -// internal::{detect_duplicate_type_names, skip_fields, skip_fields_named, NonSkipField}, -// *, -// }; -// use std::{borrow::Cow, collections::VecDeque}; - -// pub use context::*; -// pub use error::*; -// pub use export_config::*; - -// macro_rules! primitive_def { -// ($($t:ident)+) => { -// $(Primitive::$t)|+ -// } -// } - -// type Result = std::result::Result; - -// pub(crate) type Output = Result; - -// /// Convert a type which implements [`Type`](crate::Type) to a TypeScript string with an export. -// /// -// /// Eg. `export const Foo = z.object({ demo: z.string() });` -// pub fn export_ref(_: &T, conf: &ExportConfig) -> Output { -// export::(conf) -// } - -// /// Convert a type which implements [`Type`](crate::Type) to a TypeScript string with an export. -// /// -// /// Eg. `export const Foo = z.object({ demo: string; });` -// pub fn export(conf: &ExportConfig) -> Output { -// let mut types = TypeCollection::default(); -// let named_data_type = T::definition_named_data_type(&mut types); -// // is_valid_ty(&named_data_type.inner, &types)?; -// let result = export_named_datatype(conf, &named_data_type, &types); - -// if let Some((ty_name, l0, l1)) = detect_duplicate_type_names(&types).into_iter().next() { -// return Err(ExportError::DuplicateTypeName(ty_name, l0, l1)); -// } - -// result -// } - -// /// Convert a type which implements [`Type`](crate::Type) to a TypeScript string. -// /// -// /// Eg. `z.object({ demo: z.string() });` -// pub fn inline_ref(_: &T, conf: &ExportConfig) -> Output { -// inline::(conf) -// } - -// /// Convert a type which implements [`Type`](crate::Type) to a TypeScript string. -// /// -// /// Eg. `z.object({ demo: z.string() });` -// pub fn inline(conf: &ExportConfig) -> Output { -// let mut types = TypeCollection::default(); -// let ty = T::inline(&mut types, specta::Generics::Definition); -// // is_valid_ty(&ty, &types)?; -// let result = datatype(conf, &ty, &types); - -// if let Some((ty_name, l0, l1)) = detect_duplicate_type_names(&types).into_iter().next() { -// return Err(ExportError::DuplicateTypeName(ty_name, l0, l1)); -// } - -// result -// } - -// /// Convert a DataType to a Zod validator -// /// -// /// Eg. `export const Name = z.object({ demo: z.string() });` -// pub fn export_named_datatype( -// conf: &ExportConfig, -// typ: &NamedDataType, -// types: &TypeCollection, -// ) -> Output { -// // TODO: Duplicate type name detection? - -// // is_valid_ty(&typ.inner, types)?; -// export_datatype_inner( -// ExportContext { -// cfg: conf, -// path: vec![], -// is_export: true, -// }, -// typ, -// types, -// ) -// } - -// #[allow(clippy::ptr_arg)] -// fn inner_comments( -// ctx: ExportContext, -// _deprecated: Option<&DeprecatedType>, -// _docs: &Cow<'static, str>, -// other: String, -// start_with_newline: bool, -// ) -> String { -// if !ctx.is_export { -// return other; -// } - -// // let comments = ctx -// // .cfg -// // .comment_exporter -// // .map(|v| v(CommentFormatterArgs { docs, deprecated })) -// // .unwrap_or_default(); -// let comments = ""; - -// let prefix = match start_with_newline && !comments.is_empty() { -// true => "\n", -// false => "", -// }; - -// format!("{prefix}{comments}{other}") -// } - -// fn export_datatype_inner( -// ctx: ExportContext, -// typ: &NamedDataType, -// types: &TypeCollection, -// ) -> Output { -// let ctx = ctx.with( -// typ.ext() -// .map(|v| PathItem::TypeExtended(typ.name().clone(), *v.impl_location())) -// .unwrap_or_else(|| PathItem::Type(typ.name().clone())), -// ); -// let name = sanitise_type_name(ctx.clone(), NamedLocation::Type, typ.name())?; - -// let _generics = typ -// .inner -// .generics() -// .filter(|generics| !generics.is_empty()) -// .map(|generics| format!("<{}>", generics.join(", "))) -// .unwrap_or_default(); - -// let inline_zod = datatype_inner(ctx.clone(), &typ.inner, types)?; - -// // {generics} -// Ok(inner_comments( -// ctx, -// typ.deprecated(), -// typ.docs(), -// format!("export const {name} = {inline_zod}"), -// false, -// )) -// } - -// /// Convert a DataType to a Zod validator -// /// -// /// Eg. `z.object({ demo: z.string(); })` -// pub fn datatype(conf: &ExportConfig, typ: &DataType, types: &TypeCollection) -> Output { -// // TODO: Duplicate type name detection? - -// datatype_inner( -// ExportContext { -// cfg: conf, -// path: vec![], -// is_export: false, -// }, -// typ, -// types, -// ) -// } - -// pub(crate) fn datatype_inner( -// ctx: ExportContext, -// typ: &DataType, -// types: &TypeCollection, -// ) -> Output { -// Ok(match &typ { -// DataType::Any => ANY.into(), -// DataType::Unknown => UNKNOWN.into(), -// DataType::Primitive(p) => { -// let ctx = ctx.with(PathItem::Type(p.to_rust_str().into())); -// match p { -// primitive_def!(i8 i16 i32 u8 u16 u32 f32 f64) => NUMBER.into(), -// primitive_def!(usize isize i64 u64 i128 u128) => match ctx.cfg.bigint { -// BigIntExportBehavior::String => STRING.into(), -// BigIntExportBehavior::Number => NUMBER.into(), -// BigIntExportBehavior::BigInt => BIGINT.into(), -// BigIntExportBehavior::Fail => { -// return Err(ExportError::BigIntForbidden(ctx.export_path())); -// } -// BigIntExportBehavior::FailWithReason(reason) => { -// return Err(ExportError::Other(ctx.export_path(), reason.to_owned())) -// } -// }, -// primitive_def!(String char) => STRING.into(), -// primitive_def!(bool) => BOOLEAN.into(), -// } -// } -// DataType::Literal(literal) => literal.to_zod(), -// DataType::Nullable(def) => { -// let dt = datatype_inner(ctx, def, types)?; - -// if dt.ends_with(NULLABLE) { -// dt -// } else { -// format!("{dt}{NULLABLE}") -// } -// } -// DataType::Map(def) => { -// format!( -// // We use this isn't of `Record` to avoid issues with circular references. -// "z.record({}, {})", -// datatype_inner(ctx.clone(), def.key_ty(), types)?, -// datatype_inner(ctx, def.value_ty(), types)? -// ) -// } -// // We use `T[]` instead of `Array` to avoid issues with circular references. -// DataType::List(def) => { -// let dt = datatype_inner(ctx, def.ty(), types)?; - -// if let Some(length) = def.length() { -// format!( -// "z.tuple([{}])", -// (0..length) -// .map(|_| dt.clone()) -// .collect::>() -// .join(", ") -// ) -// } else { -// format!("z.array({dt})") -// } -// } -// DataType::Struct(item) => struct_datatype( -// ctx, -// // ctx.with( -// // item.sid -// // .and_then(|sid| types.get(sid)) -// // .and_then(|v| v.ext()) -// // .map(|v| PathItem::TypeExtended(item.name().clone(), v.impl_location())) -// // .unwrap_or_else(|| PathItem::Type(item.name().clone())), -// // ), -// item.name(), -// item, -// types, -// )?, -// DataType::Enum(item) => { -// let ctx = ctx.clone(); -// let _cfg = ctx.cfg.clone().bigint(BigIntExportBehavior::Number); -// // if item.skip_bigint_checks { -// // ctx.cfg = &cfg; -// // } - -// enum_datatype( -// ctx.with(PathItem::Variant(item.name().clone())), -// item, -// types, -// )? -// } -// DataType::Tuple(tuple) => tuple_datatype(ctx, tuple, types)?, -// DataType::Reference(reference) => match &reference.generics()[..] { -// [] => reference.name().to_string(), -// _generics => { -// let name = reference.name(); -// let generics = reference -// .generics() -// .iter() -// .map(|(_, v)| { -// datatype_inner(ctx.with(PathItem::Type(name.clone())), v, types) -// }) -// .collect::>>()? -// .join(", "); - -// format!("{name}({generics})") -// } -// }, -// DataType::Generic(generic) => generic.to_string(), -// }) -// } - -// // Can be used with `StructUnnamedFields.fields` or `EnumNamedFields.fields` -// fn unnamed_fields_datatype( -// ctx: ExportContext, -// fields: &[NonSkipField], -// types: &TypeCollection, -// ) -> Output { -// match fields { -// [(field, ty)] => Ok(inner_comments( -// ctx.clone(), -// field.deprecated(), -// field.docs(), -// datatype_inner(ctx, ty, types)?, -// true, -// )), -// fields => Ok(format!( -// "z.tuple([{}])", -// fields -// .iter() -// .map(|(field, ty)| Ok(inner_comments( -// ctx.clone(), -// field.deprecated(), -// field.docs(), -// datatype_inner(ctx.clone(), ty, types)?, -// true -// ))) -// .collect::>>()? -// .join(", ") -// )), -// } -// } - -// fn tuple_datatype(ctx: ExportContext, tuple: &TupleType, types: &TypeCollection) -> Output { -// match &tuple.elements()[..] { -// [] => Ok(NULL.into()), -// tys => Ok(format!( -// "z.tuple([{}])", -// tys.iter() -// .map(|v| datatype_inner(ctx.clone(), v, types)) -// .collect::>>()? -// .join(", ") -// )), -// } -// } - -// fn struct_datatype( -// ctx: ExportContext, -// key: &str, -// s: &StructType, -// types: &TypeCollection, -// ) -> Output { -// match &s.fields() { -// Fields::Unit => Ok(NULL.into()), -// Fields::Unnamed(s) => { -// unnamed_fields_datatype(ctx, &skip_fields(s.fields()).collect::>(), types) -// } -// Fields::Named(s) => { -// let fields = skip_fields_named(s.fields()).collect::>(); - -// if fields.is_empty() { -// return Ok(s -// .tag() -// .as_ref() -// .map(|tag| { -// let tag = sanitise_key(tag.clone(), false); -// format!(r#"z.object({{ {tag}: z.literal("{key}") }})"#) -// }) -// .unwrap_or_else(|| format!("z.record({STRING}, {NEVER})"))); -// } - -// let (flattened, non_flattened): (Vec<_>, Vec<_>) = -// fields.iter().partition(|(_, (f, _))| f.flatten()); - -// let mut field_sections = flattened -// .into_iter() -// .map(|(key, (field, ty))| { -// datatype_inner(ctx.with(PathItem::Field(key.clone())), ty, types).map( -// |type_str| { -// inner_comments( -// ctx.clone(), -// field.deprecated(), -// field.docs(), -// type_str, -// true, -// ) -// }, -// ) -// }) -// .collect::>>()?; - -// let mut unflattened_fields = non_flattened -// .into_iter() -// .map(|(key, field_ref)| { -// let (field, _) = field_ref; - -// Ok(inner_comments( -// ctx.clone(), -// field.deprecated(), -// field.docs(), -// object_field_to_ts( -// ctx.with(PathItem::Field(key.clone())), -// key.clone(), -// field_ref, -// types, -// )?, -// true, -// )) -// }) -// .collect::>>()?; - -// if let Some(tag) = &s.tag() { -// let tag = sanitise_key(tag.clone(), false); -// unflattened_fields.push(format!(r#"{tag}: z.literal("{key}")"#)); -// } - -// if !unflattened_fields.is_empty() { -// if field_sections.is_empty() { -// field_sections -// .push_back(format!("z.object({{ {} }})", unflattened_fields.join(", "))); -// } else { -// field_sections.push_back(format!( -// ".and(z.object({{ {} }}))", -// unflattened_fields.join(", ") -// )); -// } -// } - -// Ok(field_sections.pop_front().expect("field_sections is empty") -// + &{ -// let vec: Vec<_> = field_sections.into(); -// vec.join("") -// }) -// } -// } -// } - -// fn enum_variant_datatype( -// ctx: ExportContext, -// types: &TypeCollection, -// name: Cow<'static, str>, -// variant: &EnumVariant, -// ) -> Result> { -// match &variant.fields() { -// // TODO: Remove unreachable in type system -// Fields::Unit => unreachable!("Unit enum variants have no type!"), -// Fields::Named(obj) => { -// let mut fields = if let Some(tag) = &obj.tag() { -// let sanitised_name = sanitise_key(name, true); -// let tag = sanitise_key(tag.clone(), false); -// vec![format!(r#"{tag}: z.literal({sanitised_name})"#)] -// } else { -// vec![] -// }; - -// fields.extend( -// skip_fields_named(obj.fields()) -// .map(|(name, field_ref)| { -// let (field, _) = field_ref; - -// Ok(inner_comments( -// ctx.clone(), -// field.deprecated(), -// field.docs(), -// object_field_to_ts( -// ctx.with(PathItem::Field(name.clone())), -// name.clone(), -// field_ref, -// types, -// )?, -// true, -// )) -// }) -// .collect::>>()?, -// ); - -// Ok(Some(match &fields[..] { -// [] => format!("z.record({STRING}, {NEVER})").to_string(), -// fields => format!("z.object({{ {} }})", fields.join(", ")), -// })) -// } -// Fields::Unnamed(obj) => { -// let fields = skip_fields(obj.fields()) -// .map(|(_, ty)| datatype_inner(ctx.clone(), ty, types)) -// .collect::>>()?; - -// Ok(match &fields[..] { -// [] => { -// // If the actual length is 0, we know `#[serde(skip)]` was not used. -// if obj.fields().is_empty() { -// Some("z.tuple([])".to_string()) -// } else { -// // We wanna render `{tag}` not `{tag}: {type}` (where `{type}` is what this function returns) -// None -// } -// } -// // If the actual length is 1, we know `#[serde(skip)]` was not used. -// [field] if obj.fields().len() == 1 => Some(field.to_string()), -// fields => Some(format!("z.tuple([{}])", fields.join(", "))), -// }) -// } -// } -// } - -// fn enum_datatype(ctx: ExportContext, e: &EnumType, types: &TypeCollection) -> Output { -// if e.variants().is_empty() { -// return Ok(NEVER.to_string()); -// } - -// Ok(match &e.repr() { -// EnumRepr::Untagged => { -// let mut variants = e -// .variants() -// .iter() -// .filter(|(_, variant)| !variant.skip()) -// .map(|(name, variant)| { -// Ok(match variant.fields() { -// Fields::Unit => NULL.into(), -// _ => inner_comments( -// ctx.clone(), -// variant.deprecated(), -// variant.docs(), -// enum_variant_datatype( -// ctx.with(PathItem::Variant(name.clone())), -// types, -// name.clone(), -// variant, -// )? -// .expect("Invalid Serde type"), -// true, -// ), -// }) -// }) -// .collect::>>()?; -// variants.dedup(); -// if variants.len() == 1 { -// variants.pop().expect("variants is empty") -// } else { -// format!("z.union([{}])", variants.join(", ")) -// } -// } -// repr => { -// let mut variants = e -// .variants() -// .iter() -// .filter(|(_, variant)| !variant.skip()) -// .map(|(variant_name, variant)| { -// let sanitised_name = sanitise_key(variant_name.clone(), true); - -// Ok(inner_comments( -// ctx.clone(), -// variant.deprecated(), -// variant.docs(), -// match (repr, &variant.fields()) { -// (EnumRepr::Untagged, _) => unreachable!(), -// (EnumRepr::Internal { tag }, Fields::Unit) => { -// let tag = sanitise_key(tag.clone(), false); -// format!(r#"z.object({{ {tag}: {sanitised_name} }})"#) -// } -// (EnumRepr::Internal { tag }, Fields::Unnamed(tuple)) => { -// let tag = sanitise_key(tag.clone(), false); -// let fields = skip_fields(tuple.fields()).collect::>(); - -// // This field is only required for `{ty}` not `[...]` so we only need to check when there one field -// let dont_join_ty = if tuple.fields().len() == 1 { -// let (_, ty) = fields.first().expect("checked length above"); -// validate_type_for_tagged_intersection( -// ctx.clone(), -// (**ty).clone(), -// types, -// )? -// } else { -// false -// }; - -// let typ = -// unnamed_fields_datatype(ctx.clone(), &fields, types)?; - -// if dont_join_ty { -// format!(r#"z.object({{ {tag}: {sanitised_name} }})"#) -// } else { -// format!( -// r#"z.and(z.object({{ {tag}: {sanitised_name} }}), {typ})"# -// ) -// } -// } -// (EnumRepr::Internal { tag }, Fields::Named(obj)) => { -// let tag = sanitise_key(tag.clone(), false); -// let mut fields = vec![format!("{tag}: {sanitised_name}")]; - -// fields.extend( -// skip_fields_named(obj.fields()) -// .map(|(name, field)| { -// object_field_to_ts( -// ctx.with(PathItem::Field(name.clone())), -// name.clone(), -// field, -// types, -// ) -// }) -// .collect::>>()?, -// ); - -// format!("z.object({{ {} }})", fields.join(", ")) -// } -// (EnumRepr::External, Fields::Unit) => format!("z.literal({})", sanitised_name), -// (EnumRepr::External, _) => { -// let ts_values = enum_variant_datatype( -// ctx.with(PathItem::Variant(variant_name.clone())), -// types, -// variant_name.clone(), -// variant, -// )?; -// let sanitised_name = sanitise_key(variant_name.clone(), false); - -// match ts_values { -// Some(ts_values) => { -// format!("z.object({{ {sanitised_name}: {ts_values} }})") -// } -// None => format!(r#"z.literal({sanitised_name})"#), -// } -// } -// (EnumRepr::Adjacent { tag, .. }, Fields::Unit) => { -// let tag = sanitise_key(tag.clone(), false); -// format!(r#"z.object({{ {tag}: z.literal({sanitised_name}) }})"#) -// } -// (EnumRepr::Adjacent { tag, content }, _) => { -// let tag = sanitise_key(tag.clone(), false); -// let content_values = enum_variant_datatype( -// ctx.with(PathItem::Variant(variant_name.clone())), -// types, -// variant_name.clone(), -// variant, -// )? -// .expect("Invalid Serde type"); - -// format!(r#"z.object({{ {tag}: z.literal({sanitised_name}), {content}: {content_values} }})"#) -// } -// }, -// true, -// )) -// }) -// .collect::>>()?; -// variants.dedup(); -// if variants.len() == 1 { -// variants.swap_remove(0) -// } else { -// format!("z.union([{}])", variants.join(", ")) -// } -// } -// }) -// } - -// trait ToZod { -// fn to_zod(&self) -> String; -// } -// impl ToZod for LiteralType { -// fn to_zod(&self) -> String { -// format!( -// "z.literal({})", -// match self { -// Self::i8(v) => v.to_string(), -// Self::i16(v) => v.to_string(), -// Self::i32(v) => v.to_string(), -// Self::u8(v) => v.to_string(), -// Self::u16(v) => v.to_string(), -// Self::u32(v) => v.to_string(), -// Self::f32(v) => v.to_string(), -// Self::f64(v) => v.to_string(), -// Self::bool(v) => v.to_string(), -// Self::String(v) => format!(r#""{v}""#), -// Self::char(v) => format!(r#""{v}""#), -// Self::None => return NULL.to_string(), -// _ => panic!("unhandled literal type!"), -// } -// ) -// } -// } - -// /// convert an object field into a Typescript string -// fn object_field_to_ts( -// ctx: ExportContext, -// key: Cow<'static, str>, -// (field, ty): NonSkipField, -// types: &TypeCollection, -// ) -> Output { -// let field_name_safe = sanitise_key(key, false); - -// // https://github.com/oscartbeaumont/rspc/issues/100#issuecomment-1373092211 -// let (key, ty) = match field.optional() { -// true => (format!("{field_name_safe}").into(), ty), // TODO: optional -// false => (field_name_safe, ty), -// }; - -// Ok(format!("{key}: {}", datatype_inner(ctx, ty, types)?)) -// } - -// /// sanitise a string to be a valid Typescript key -// fn sanitise_key<'a>(field_name: Cow<'static, str>, force_string: bool) -> Cow<'a, str> { -// let valid = field_name -// .chars() -// .all(|c| c.is_alphanumeric() || c == '_' || c == '$') -// && field_name -// .chars() -// .next() -// .map(|first| !first.is_numeric()) -// .unwrap_or(true); - -// if force_string || !valid { -// format!(r#""{field_name}""#).into() -// } else { -// field_name -// } -// } - -// pub(crate) fn sanitise_type_name(ctx: ExportContext, loc: NamedLocation, ident: &str) -> Output { -// // if let Some(name) = RESERVED_TYPE_NAMES.iter().find(|v| **v == ident) { -// // return Err(ExportError::ForbiddenName(loc, ctx.export_path(), name)); -// // } - -// if let Some(first_char) = ident.chars().next() { -// if !first_char.is_alphabetic() && first_char != '_' { -// return Err(ExportError::InvalidName( -// loc, -// ctx.export_path(), -// ident.to_string(), -// )); -// } -// } - -// if ident -// .find(|c: char| !c.is_alphanumeric() && c != '_') -// .is_some() -// { -// return Err(ExportError::InvalidName( -// loc, -// ctx.export_path(), -// ident.to_string(), -// )); -// } - -// Ok(ident.to_string()) -// } - -// fn validate_type_for_tagged_intersection( -// ctx: ExportContext, -// ty: DataType, -// types: &TypeCollection, -// ) -> Result { -// match ty { -// DataType::Any -// | DataType::Unknown -// | DataType::Primitive(_) -// // `T & null` is `never` but `T & (U | null)` (this variant) is `T & U` so it's fine. -// | DataType::Nullable(_) -// | DataType::List(_) -// | DataType::Map(_) -// | DataType::Generic(_) => Ok(false), -// DataType::Literal(v) => match v { -// LiteralType::None => Ok(true), -// _ => Ok(false), -// }, -// DataType::Struct(v) => match v.fields() { -// Fields::Unit => Ok(true), -// Fields::Unnamed(_) => { -// Err(ExportError::InvalidTaggedVariantContainingTupleStruct( -// ctx.export_path() -// )) -// } -// Fields::Named(fields) => { -// // Prevent `{ tag: "{tag}" } & Record` -// if fields.tag().is_none() && fields.fields().is_empty() { -// return Ok(true); -// } - -// Ok(false) -// } -// }, -// DataType::Enum(v) => { -// match v.repr() { -// EnumRepr::Untagged => { -// Ok(v.variants().iter().any(|(_, v)| match &v.fields() { -// // `{ .. } & null` is `never` -// Fields::Unit => true, -// // `{ ... } & Record` is not useful -// Fields::Named(v) => v.tag().is_none() && v.fields().is_empty(), -// Fields::Unnamed(_) => false, -// })) -// }, -// // All of these repr's are always objects. -// EnumRepr::Internal { .. } | EnumRepr::Adjacent { .. } | EnumRepr::External => Ok(false), -// } -// } -// DataType::Tuple(v) => { -// // Empty tuple is `null` -// if v.elements().is_empty() { -// return Ok(true); -// } - -// Ok(false) -// } -// DataType::Reference(r) => validate_type_for_tagged_intersection( -// ctx, -// types -// .get(r.sid()) -// .expect("TypeCollection should have been populated by now") -// .inner -// .clone(), -// types, -// ), -// } -// } - -// const ANY: &str = "z.any()"; -// const UNKNOWN: &str = "z.unknown()"; -// const NUMBER: &str = "z.number()"; -// const STRING: &str = "z.string()"; -// const BOOLEAN: &str = "z.boolean()"; -// const NULL: &str = "z.null()"; -// const NULLABLE: &str = ".nullable()"; -// const NEVER: &str = "z.never()"; -// const BIGINT: &str = "z.bigint()"; +mod context; +mod error; +mod export_config; + +use std::{ + borrow::Cow, + collections::HashMap, + path::Path, +}; + +use specta::{ + datatype::{DataType, EnumRepr, Fields, NamedDataType, Primitive}, + TypeCollection, +}; + +pub use context::*; +pub use error::{Error, NamedLocation}; +pub use export_config::*; + +/// Zod schema exporter. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Zod { + pub header: Cow<'static, str>, + pub framework_header: Cow<'static, str>, + pub bigint: BigIntExportBehavior, +} + +impl Default for Zod { + fn default() -> Self { + Self { + header: Cow::Borrowed(""), + framework_header: Cow::Borrowed( + "// This file has been generated by Specta. DO NOT EDIT.", + ), + bigint: Default::default(), + } + } +} + +impl Zod { + /// Construct a new Zod exporter with the default options configured. + pub fn new() -> Self { + Default::default() + } + + /// Override the header for the exported file. + /// You should prefer `Self::header` instead unless your a framework. + #[doc(hidden)] + pub fn framework_header(mut self, header: impl Into>) -> Self { + self.framework_header = header.into(); + self + } + + /// Configure a header for the file. + /// + /// This is perfect for configuring lint ignore rules or other file-level comments. + pub fn header(mut self, header: impl Into>) -> Self { + self.header = header.into(); + self + } + + /// Configure the BigInt handling behaviour + pub fn bigint(mut self, bigint: BigIntExportBehavior) -> Self { + self.bigint = bigint; + self + } + + /// Export the types into a single string. + pub fn export(&self, types: &TypeCollection) -> Result { + specta_serde::validate(types)?; + + // Check for duplicate type names + let mut map = HashMap::with_capacity(types.len()); + for dt in types.into_unsorted_iter() { + if let Some((existing_sid, existing_impl_location)) = + map.insert(dt.name().clone(), (dt.sid(), dt.location())) + { + if existing_sid != dt.sid() { + return Err(Error::DuplicateTypeName { + types: (dt.location(), existing_impl_location), + name: dt.name().clone(), + }); + } + } + } + + let mut out = self.header.to_string(); + if !out.is_empty() { + out.push('\n'); + } + out += &self.framework_header; + out.push_str("\n\n"); + + for (i, ndt) in types.into_sorted_iter().enumerate() { + if i != 0 { + out += "\n\n"; + } + + out += &self.export_named_datatype(&ndt, types)?; + } + + Ok(out) + } + + /// Export the types to a specific file. + pub fn export_to(&self, path: impl AsRef, types: &TypeCollection) -> Result<(), Error> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(path, self.export(types)?)?; + + Ok(()) + } + + /// Export a named data type to a Zod schema definition + fn export_named_datatype( + &self, + ndt: &NamedDataType, + types: &TypeCollection, + ) -> Result { + let ctx = ExportContext { + cfg: self, + path: vec![PathItem::Type(ndt.name().clone())], + is_export: true, + }; + + let name = sanitise_type_name(ctx.clone(), NamedLocation::Type, ndt.name())?; + + let generics = if !ndt.generics().is_empty() { + format!("<{}>", ndt.generics().join(", ")) + } else { + String::new() + }; + + let schema = self.datatype_to_zod(ctx, ndt.ty(), types)?; + + Ok(format!("export const {name}{generics} = {schema}")) + } + + /// Convert a DataType to a Zod schema + fn datatype_to_zod( + &self, + ctx: ExportContext, + dt: &DataType, + types: &TypeCollection, + ) -> Result { + Ok(match dt { + DataType::Primitive(p) => self.primitive_to_zod(&ctx, p)?, + DataType::Literal(l) => { + use specta::datatype::Literal::*; + match l { + i8(v) => format!("z.literal({v})"), + i16(v) => format!("z.literal({v})"), + i32(v) => format!("z.literal({v})"), + u8(v) => format!("z.literal({v})"), + u16(v) => format!("z.literal({v})"), + u32(v) => format!("z.literal({v})"), + f32(v) => format!("z.literal({v})"), + f64(v) => format!("z.literal({v})"), + bool(v) => format!("z.literal({v})"), + String(v) => format!(r#"z.literal("{v}")"#), + char(v) => format!(r#"z.literal("{v}")"#), + None => "z.null()".to_string(), + _ => return Err(Error::Other(ctx.export_path(), "Unsupported literal type".to_string())), + } + } + DataType::Nullable(inner) => { + let inner_zod = self.datatype_to_zod(ctx, inner, types)?; + if inner_zod.ends_with(".nullable()") { + inner_zod + } else { + format!("{inner_zod}.nullable()") + } + } + DataType::Map(map) => { + let key = self.datatype_to_zod(ctx.clone(), map.key_ty(), types)?; + let value = self.datatype_to_zod(ctx, map.value_ty(), types)?; + format!("z.record({key}, {value})") + } + DataType::List(list) => { + let element = self.datatype_to_zod(ctx, list.ty(), types)?; + if let Some(length) = list.length() { + let elements = (0..length) + .map(|_| element.clone()) + .collect::>() + .join(", "); + format!("z.tuple([{elements}])") + } else { + format!("z.array({element})") + } + } + DataType::Struct(s) => self.struct_to_zod(ctx, s, types)?, + DataType::Enum(e) => self.enum_to_zod(ctx, e, types)?, + DataType::Tuple(t) => { + if t.elements().is_empty() { + "z.null()".to_string() + } else { + let elements = t + .elements() + .iter() + .map(|elem| self.datatype_to_zod(ctx.clone(), elem, types)) + .collect::, _>>()? + .join(", "); + format!("z.tuple([{elements}])") + } + } + DataType::Reference(r) => { + let ndt = types.get(r.sid()).ok_or_else(|| { + Error::Other(ctx.export_path(), format!("Missing type reference: {:?}", r.sid())) + })?; + + if !r.generics().is_empty() { + let generics = r + .generics() + .iter() + .map(|(_, dt)| self.datatype_to_zod(ctx.clone(), dt, types)) + .collect::, _>>()? + .join(", "); + format!("{}({})", ndt.name(), generics) + } else { + ndt.name().to_string() + } + } + DataType::Generic(g) => g.to_string(), + }) + } + + fn primitive_to_zod(&self, ctx: &ExportContext, p: &Primitive) -> Result { + use Primitive::*; + Ok(match p { + i8 | i16 | i32 | u8 | u16 | u32 | f32 | f64 | f16 => "z.number()".to_string(), + usize | isize | i64 | u64 | i128 | u128 => match self.bigint { + BigIntExportBehavior::String => "z.string()".to_string(), + BigIntExportBehavior::Number => "z.number()".to_string(), + BigIntExportBehavior::BigInt => "z.bigint()".to_string(), + BigIntExportBehavior::Fail => { + return Err(Error::BigIntForbidden(ctx.export_path())); + } + BigIntExportBehavior::FailWithReason(reason) => { + return Err(Error::Other(ctx.export_path(), reason.to_string())); + } + }, + Primitive::bool => "z.boolean()".to_string(), + String | char => "z.string()".to_string(), + }) + } + + fn struct_to_zod( + &self, + ctx: ExportContext, + s: &specta::datatype::Struct, + types: &TypeCollection, + ) -> Result { + match s.fields() { + Fields::Unit => Ok("z.null()".to_string()), + Fields::Unnamed(fields) => { + let non_skipped: Vec<_> = fields + .fields() + .iter() + .filter(|f| f.ty().is_some()) + .collect(); + + if non_skipped.is_empty() { + return Ok("z.tuple([])".to_string()); + } + + if non_skipped.len() == 1 { + let field = non_skipped[0]; + return self.datatype_to_zod(ctx, field.ty().unwrap(), types); + } + + let elements = non_skipped + .iter() + .map(|f| self.datatype_to_zod(ctx.clone(), f.ty().unwrap(), types)) + .collect::, _>>()? + .join(", "); + Ok(format!("z.tuple([{elements}])")) + } + Fields::Named(fields) => { + let non_skipped: Vec<_> = fields + .fields() + .iter() + .filter(|(_, f)| f.ty().is_some()) + .collect(); + + if non_skipped.is_empty() { + // For empty structs, return an empty record + return Ok("z.record(z.string(), z.never())".to_string()); + } + + let (flattened, non_flattened): (Vec<_>, Vec<_>) = + non_skipped.into_iter().partition(|(_, f)| f.flatten()); + + let mut field_sections = Vec::new(); + + // Handle flattened fields + for (_, field) in flattened { + let ty = field.ty().unwrap(); + field_sections.push(self.datatype_to_zod(ctx.clone(), ty, types)?); + } + + // Handle non-flattened fields + let mut unflattened_fields = Vec::new(); + for (key, field) in non_flattened { + let ty = field.ty().unwrap(); + let field_key = sanitise_key(key.clone()); + let field_schema = self.datatype_to_zod( + ctx.with(PathItem::Field(key.clone())), + ty, + types, + )?; + + if field.optional() { + unflattened_fields.push(format!("{field_key}: {field_schema}.optional()")); + } else { + unflattened_fields.push(format!("{field_key}: {field_schema}")); + } + } + + // Note: For named structs in Zod, we don't typically add the tag like TypeScript + // as Zod schemas are runtime validators, not types + + if !unflattened_fields.is_empty() { + if field_sections.is_empty() { + field_sections.push(format!("z.object({{ {} }})", unflattened_fields.join(", "))); + } else { + field_sections.push(format!( + ".and(z.object({{ {} }}))", + unflattened_fields.join(", ") + )); + } + } + + Ok(field_sections.join("")) + } + } + } + + fn enum_to_zod( + &self, + ctx: ExportContext, + e: &specta::datatype::Enum, + types: &TypeCollection, + ) -> Result { + let variants: Vec<_> = e + .variants() + .iter() + .filter(|(_, v)| !v.skip()) + .collect(); + + if variants.is_empty() { + return Ok("z.never()".to_string()); + } + + let variant_schemas = variants + .iter() + .map(|(name, variant)| { + let ctx = ctx.with(PathItem::Variant(name.clone())); + match e.repr() { + Some(EnumRepr::Untagged) => match variant.fields() { + Fields::Unit => Ok("z.null()".to_string()), + fields => self.enum_variant_fields_to_zod(ctx, name, fields, types), + }, + Some(EnumRepr::External) | Some(EnumRepr::String { .. }) | None => match variant.fields() { + Fields::Unit => { + let sanitised_name = sanitise_key(name.clone()); + Ok(format!("z.literal({sanitised_name})")) + } + Fields::Unnamed(f) if f.fields().iter().filter(|f| f.ty().is_some()).count() == 0 => { + let sanitised_name = sanitise_key(name.clone()); + if f.fields().is_empty() { + let key = sanitise_key(name.clone()); + Ok(format!("z.object({{ {key}: z.tuple([]) }})")) + } else { + Ok(format!("z.literal({sanitised_name})")) + } + } + fields => { + let key = sanitise_key(name.clone()); + let value = self.enum_variant_fields_to_zod(ctx, name, fields, types)?; + Ok(format!("z.object({{ {key}: {value} }})")) + } + }, + Some(EnumRepr::Internal { tag }) => { + let tag = sanitise_key(tag.clone()); + let sanitised_name = sanitise_key(name.clone()); + match variant.fields() { + Fields::Unit => { + Ok(format!("z.object({{ {tag}: z.literal({sanitised_name}) }})")) + } + fields => { + let fields_schema = self.enum_variant_fields_to_zod(ctx, name, fields, types)?; + Ok(format!( + "z.object({{ {tag}: z.literal({sanitised_name}) }}).and({fields_schema})" + )) + } + } + } + Some(EnumRepr::Adjacent { tag, content }) => { + let tag = sanitise_key(tag.clone()); + let sanitised_name = sanitise_key(name.clone()); + match variant.fields() { + Fields::Unit => { + Ok(format!("z.object({{ {tag}: z.literal({sanitised_name}) }})")) + } + fields => { + let content_key = sanitise_key(content.clone()); + let fields_schema = self.enum_variant_fields_to_zod(ctx, name, fields, types)?; + Ok(format!( + "z.object({{ {tag}: z.literal({sanitised_name}), {content_key}: {fields_schema} }})" + )) + } + } + } + } + }) + .collect::, _>>()?; + + if variant_schemas.len() == 1 { + Ok(variant_schemas[0].clone()) + } else { + Ok(format!("z.union([{}])", variant_schemas.join(", "))) + } + } + + fn enum_variant_fields_to_zod( + &self, + ctx: ExportContext, + _variant_name: &Cow<'static, str>, + fields: &Fields, + types: &TypeCollection, + ) -> Result { + match fields { + Fields::Unit => Ok("z.null()".to_string()), + Fields::Unnamed(f) => { + let non_skipped: Vec<_> = f + .fields() + .iter() + .filter(|field| field.ty().is_some()) + .collect(); + + if non_skipped.is_empty() { + return Ok("z.tuple([])".to_string()); + } + + if non_skipped.len() == 1 { + return self.datatype_to_zod(ctx, non_skipped[0].ty().unwrap(), types); + } + + let elements = non_skipped + .iter() + .map(|field| self.datatype_to_zod(ctx.clone(), field.ty().unwrap(), types)) + .collect::, _>>()? + .join(", "); + Ok(format!("z.tuple([{elements}])")) + } + Fields::Named(f) => { + let non_skipped: Vec<_> = f + .fields() + .iter() + .filter(|(_, field)| field.ty().is_some()) + .collect(); + + if non_skipped.is_empty() { + return Ok("z.record(z.string(), z.never())".to_string()); + } + + let fields_str = non_skipped + .iter() + .map(|(key, field)| -> Result { + let ty = field.ty().unwrap(); + let field_key = sanitise_key(key.clone()); + let field_schema = self.datatype_to_zod( + ctx.with(PathItem::Field(key.clone())), + ty, + types, + )?; + + if field.optional() { + Ok(format!("{field_key}: {field_schema}.optional()")) + } else { + Ok(format!("{field_key}: {field_schema}")) + } + }) + .collect::, _>>()? + .join(", "); + + Ok(format!("z.object({{ {fields_str} }})")) + } + } + } +} + +fn sanitise_key(key: Cow<'static, str>) -> String { + let valid = key + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '$') + && key + .chars() + .next() + .map(|first| !first.is_numeric()) + .unwrap_or(true); + + if !valid { + format!(r#""{key}""#) + } else { + key.to_string() + } +} + +fn sanitise_type_name( + ctx: ExportContext, + loc: NamedLocation, + ident: &str, +) -> Result { + if let Some(first_char) = ident.chars().next() { + if !first_char.is_alphabetic() && first_char != '_' { + return Err(Error::InvalidName( + loc, + ctx.export_path(), + ident.to_string(), + )); + } + } + + if ident.find(|c: char| !c.is_alphanumeric() && c != '_').is_some() { + return Err(Error::InvalidName( + loc, + ctx.export_path(), + ident.to_string(), + )); + } + + Ok(ident.to_string()) +} diff --git a/specta-zod/tests/lib.rs b/specta-zod/tests/lib.rs index 8e1a2421..2c2ef2df 100644 --- a/specta-zod/tests/lib.rs +++ b/specta-zod/tests/lib.rs @@ -1,687 +1,254 @@ -// use std::{ -// cell::RefCell, -// collections::HashMap, -// convert::Infallible, -// marker::PhantomData, -// net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, -// path::PathBuf, -// }; - -// use specta::Type; -// use specta_util::Any; -// use specta_zod::{BigIntExportBehavior, ExportConfig, ExportError, ExportPath, NamedLocation}; - -// macro_rules! assert_zod { -// (error; $t:ty, $e:expr) => { -// assert_eq!( -// specta_zod::inline::<$t>(&Default::default()), -// Err($e.into()) -// ) -// }; -// ($t:ty, $e:expr) => { -// assert_eq!(specta_zod::inline::<$t>(&Default::default()), Ok($e.into())) -// }; - -// (() => $expr:expr, $e:expr) => { -// let _: () = { -// fn assert_ty_eq(_t: T) { -// assert_eq!(specta_zod::inline::(&Default::default()), Ok($e.into())); -// } -// assert_ty_eq($expr); -// }; -// }; -// } -// pub(crate) use assert_zod; - -// macro_rules! assert_ts_export { -// ($t:ty, $e:expr) => { -// assert_eq!(specta_zod::export::<$t>(&Default::default()), Ok($e.into())) -// }; -// (error; $t:ty, $e:expr) => { -// assert_eq!( -// specta_zod::export::<$t>(&Default::default()), -// Err($e.into()) -// ) -// }; -// ($t:ty, $e:expr; $cfg:expr) => { -// assert_eq!(specta_zod::export::<$t>($cfg), Ok($e.into())) -// }; -// (error; $t:ty, $e:expr; $cfg:expr) => { -// assert_eq!(specta_zod::export::<$t>($cfg), Err($e.into())) -// }; -// } -// pub(crate) use assert_ts_export; - -// // TODO: Unit test other `specta::Type` methods such as `::reference(...)` - -// #[test] -// fn typescript_types() { -// assert_zod!( -// Vec, -// r#"z.array(z.union([z.object({ A: z.string() }), z.object({ B: z.number() })]))"# -// ); - -// assert_zod!(i8, "z.number()"); -// assert_zod!(u8, "z.number()"); -// assert_zod!(i16, "z.number()"); -// assert_zod!(u16, "z.number()"); -// assert_zod!(i32, "z.number()"); -// assert_zod!(u32, "z.number()"); -// assert_zod!(f32, "z.number()"); -// assert_zod!(f64, "z.number()"); - -// assert_zod!(bool, "z.boolean()"); - -// assert_zod!((), "z.null()"); -// assert_zod!((String, i32), "z.tuple([z.string(), z.number()])"); -// assert_zod!( -// (String, i32, bool), -// "z.tuple([z.string(), z.number(), z.boolean()])" -// ); -// assert_zod!( -// (bool, bool, bool, bool, bool, bool, bool, bool, bool, bool, bool, bool), -// "z.tuple([z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean(), z.boolean()])" -// ); - -// assert_zod!(String, "z.string()"); -// // impossible since Path as a generic is unsized lol -// // assert_ts!(Path, "string"); -// assert_zod!(PathBuf, "z.string()"); -// assert_zod!(IpAddr, "z.string()"); -// assert_zod!(Ipv4Addr, "z.string()"); -// assert_zod!(Ipv6Addr, "z.string()"); -// assert_zod!(SocketAddr, "z.string()"); -// assert_zod!(SocketAddrV4, "z.string()"); -// assert_zod!(SocketAddrV6, "z.string()"); -// assert_zod!(char, "z.string()"); -// assert_zod!(&'static str, "z.string()"); - -// assert_zod!(&'static bool, "z.boolean()"); -// assert_zod!(&'static i32, "z.number()"); - -// assert_zod!(Vec, "z.array(z.number())"); -// assert_zod!(&[i32], "z.array(z.number())"); -// assert_zod!(&[i32; 3], "z.tuple([z.number(), z.number(), z.number()])"); - -// assert_zod!(Option, "z.number().nullable()"); - -// // // https://github.com/oscartbeaumont/specta/issues/88 -// assert_zod!(Unit1, "z.null()"); -// assert_zod!(Unit2, "z.record(z.string(), z.never())"); -// assert_zod!(Unit3, "z.tuple([])"); -// assert_zod!(Unit4, "z.null()"); -// assert_zod!(Unit5, r#"z.literal("A")"#); -// assert_zod!(Unit6, r#"z.object({ A: z.tuple([]) })"#); -// assert_zod!(Unit7, r#"z.object({ A: z.record(z.string(), z.never()) })"#); - -// assert_zod!( -// SimpleStruct, -// "z.object({ a: z.number(), b: z.string(), c: z.tuple([z.number(), z.string(), z.number()]), d: z.array(z.string()), e: z.string().nullable() })" -// ); -// assert_zod!(TupleStruct1, "z.number()"); -// assert_zod!( -// TupleStruct3, -// "z.tuple([z.number(), z.boolean(), z.string()])" -// ); - -// assert_zod!( -// TestEnum, -// r#"z.union([z.literal("Unit"), z.object({ Single: z.number() }), z.object({ Multiple: z.tuple([z.number(), z.number()]) }), z.object({ Struct: z.object({ a: z.number() }) })])"# -// ); -// assert_zod!(RefStruct, "TestEnum"); - -// assert_zod!( -// InlinerStruct, -// r#"z.object({ inline_this: z.object({ ref_struct: SimpleStruct, val: z.number() }), dont_inline_this: RefStruct })"# -// ); - -// // TODO: Fix these -// // assert_zod!(GenericStruct, "z.object({ arg: z.number() })"); -// // assert_zod!(GenericStruct, "z.object({ arg: z.string() })"); - -// assert_zod!( -// FlattenEnumStruct, -// r#"z.union([z.object({ tag: z.literal("One") }), z.object({ tag: z.literal("Two") }), z.object({ tag: z.literal("Three") })]).and(z.object({ outer: z.string() }))"# -// ); - -// assert_zod!(OverridenStruct, "z.object({ overriden_field: z.string() })"); -// assert_zod!(HasGenericAlias, "z.record(z.number(), z.string())"); - -// assert_zod!(SkipVariant, r#"z.object({ A: z.string() })"#); -// assert_zod!( -// SkipVariant2, -// r#"z.object({ tag: z.literal("A"), data: z.string() })"# -// ); -// assert_zod!( -// SkipVariant3, -// r#"z.object({ A: z.object({ a: z.string() }) })"# -// ); - -// assert_zod!( -// EnumMacroAttributes, -// r#"z.union([z.object({ A: z.string() }), z.object({ bbb: z.number() }), z.object({ cccc: z.number() }), z.object({ D: z.object({ a: z.string(), bbbbbb: z.number() }) })])"# -// ); - -// assert_zod!( -// Recursive, -// "z.object({ a: z.number(), children: z.array(Recursive) })" -// ); - -// assert_zod!( -// InlineEnumField, -// r#"z.object({ A: z.object({ a: z.string() }) })"# -// ); - -// assert_zod!( -// InlineOptionalType, -// "z.object({ optional_field: PlaceholderInnerField.nullable() })" -// ); - -// assert_ts_export!( -// RenameToValue, -// r#"export const RenameToValueNewName = z.object({ demo_new_name: z.number() })"# -// ); - -// assert_zod!( -// Rename, -// r#"z.union([z.literal("OneWord"), z.literal("Two words")])"# -// ); - -// assert_zod!(TransparentType, "TransparentTypeInner"); // TODO: I don't think this is correct for `Type::inline` -// assert_zod!(TransparentType2, "z.null()"); -// assert_zod!(TransparentTypeWithOverride, "z.string()"); - -// // I love serde but this is so mega cringe. Lack of support and the fact that `0..5` == `0..=5` is so dumb. -// assert_zod!(() => 0..5, r#"z.object({ start: z.number(), end: z.number() })"#); -// // assert_ts!(() => 0.., r#"{ start: 0 }"#); -// // assert_ts!(() => .., r#""#); -// assert_zod!(() => 0..=5, r#"z.object({ start: z.number(), end: z.number() })"#); -// // assert_ts!(() => ..5, r#"{ end: 5 }"#); -// // assert_ts!(() => ..=5, r#"{ end: 5 }"#); - -// // https://github.com/oscartbeaumont/specta/issues/66 -// assert_zod!( -// [Option; 3], -// r#"z.tuple([z.number().nullable(), z.number().nullable(), z.number().nullable()])"# -// ); - -// // https://github.com/oscartbeaumont/specta/issues/65 -// assert_zod!(HashMap, r#"z.record(z.union([z.literal("A"), z.literal("B")]), z.null())"#); - -// // https://github.com/oscartbeaumont/specta/issues/60 -// assert_zod!( -// Option>>>, -// r#"z.number().nullable()"# -// ); - -// // https://github.com/oscartbeaumont/specta/issues/71 -// assert_zod!( -// Vec, -// r#"z.array(z.object({ a: z.string() }))"# -// ); - -// // https://github.com/oscartbeaumont/specta/issues/77 -// assert_eq!( -// specta_zod::inline::( -// &ExportConfig::new().bigint(BigIntExportBehavior::Number) -// ), -// Ok(r#"z.object({ duration_since_epoch: z.number(), duration_since_unix_epoch: z.number() })"#.into()) -// ); -// assert_eq!( -// specta_zod::inline::( -// &ExportConfig::new().bigint(BigIntExportBehavior::String) -// ), -// Ok(r#"z.object({ duration_since_epoch: z.string(), duration_since_unix_epoch: z.number() })"#.into()) -// ); - -// assert_eq!( -// specta_zod::inline::( -// &ExportConfig::new().bigint(BigIntExportBehavior::Number) -// ), -// Ok(r#"z.object({ secs: z.number(), nanos: z.number() })"#.into()) -// ); -// assert_eq!( -// specta_zod::inline::( -// &ExportConfig::new().bigint(BigIntExportBehavior::String) -// ), -// Ok(r#"z.object({ secs: z.string(), nanos: z.number() })"#.into()) -// ); - -// assert_zod!(HashMap, r#"z.record(z.union([z.literal("A"), z.literal("B")]), z.number())"#); -// assert_ts_export!( -// EnumReferenceRecordKey, -// "export const EnumReferenceRecordKey = z.object({ a: z.record(BasicEnum, z.number()) })" -// ); - -// assert_zod!( -// FlattenOnNestedEnum, -// r#"z.union([z.object({ type: z.literal("a"), value: z.string() }), z.object({ type: z.literal("b"), value: z.number() })]).and(z.object({ id: z.string() }))"# -// ); - -// assert_zod!(PhantomData<()>, r#"z.null()"#); -// assert_zod!(PhantomData, r#"z.null()"#); -// assert_zod!(Infallible, r#"z.never()"#); - -// // assert_zod!(Result, r#"z.union([z.string(), z.number()])"#); -// // assert_zod!(Result, r#"z.number()"#); // TODO: simplify - -// #[cfg(feature = "either")] -// { -// assert_zod!(either::Either, r#"z.union([z.string() z.number()])"#); -// assert_zod!(either::Either, r#"z.number()"#); -// } - -// assert_zod!(Any, r#"z.any()"#); - -// assert_zod!(MyEmptyInput, "z.record(z.string(), z.never())"); -// assert_ts_export!( -// MyEmptyInput, -// "export const MyEmptyInput = z.record(z.string(), z.never())" -// ); - -// // https://github.com/oscartbeaumont/specta/issues/142 -// #[allow(unused_parens)] -// { -// assert_zod!((String), r#"z.string()"#); -// assert_zod!((String,), r#"z.tuple([z.string()])"#); -// } - -// // https://github.com/oscartbeaumont/specta/issues/148 -// assert_zod!( -// ExtraBracketsInTupleVariant, -// r#"z.object({ A: z.string() })"# -// ); -// assert_zod!(ExtraBracketsInUnnamedStruct, "z.string()"); - -// // https://github.com/oscartbeaumont/specta/issues/90 // TODO: Fix these -// // assert_zod!( -// // RenameWithWeirdCharsField, -// // r#"z.object({ "@odata.context": z.string() })"# -// // ); -// // assert_zod!( -// // RenameWithWeirdCharsVariant, -// // r#"z.object({ "@odata.context": z.string() })"# -// // ); -// // assert_ts_export!( -// // error; -// // RenameWithWeirdCharsStruct, -// // ExportError::InvalidName( -// // NamedLocation::Type, -// // #[cfg(not(windows))] -// // ExportPath::new_unsafe("crates/specta-zod/tests/lib.rs:661:10"), -// // #[cfg(windows)] -// // ExportPath::new_unsafe("crates\\specta-zod\\tests\\lib.rs:661:10"), -// // r#"@odata.context"#.to_string() -// // ) -// // ); -// // assert_ts_export!( -// // error; -// // RenameWithWeirdCharsEnum, -// // ExportError::InvalidName( -// // NamedLocation::Type, -// // #[cfg(not(windows))] -// // ExportPath::new_unsafe("crates/specta-zod/tests/lib.rs:665:10"), -// // #[cfg(windows)] -// // ExportPath::new_unsafe("crates\\specta-zod\\tests\\lib.rs:665:10"), -// // r#"@odata.context"#.to_string() -// // ) -// // ); - -// // https://github.com/oscartbeaumont/specta/issues/156 -// assert_zod!( -// Vec, -// r#"z.array(z.union([z.object({ A: z.string() }), z.object({ B: z.number() })]))"# -// ); - -// assert_zod!( -// InlineTuple, -// r#"z.object({ demo: z.tuple([z.string(), z.boolean()]) })"# -// ); -// assert_zod!( -// InlineTuple2, -// r#"z.object({ demo: z.tuple([z.object({ demo: z.tuple([z.string(), z.boolean()]) }), z.boolean()]) })"# -// ); - -// // https://github.com/oscartbeaumont/specta/issues/220 -// // assert_zod!(Box, r#"z.string()"#); -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct Unit1; - -// #[derive(Type)] -// #[specta(export = false)] -// struct Unit2 {} - -// #[derive(Type)] -// #[specta(export = false)] -// struct Unit3(); - -// #[derive(Type)] -// #[specta(export = false)] -// struct Unit4(()); - -// #[derive(Type)] -// #[specta(export = false)] -// enum Unit5 { -// A, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// enum Unit6 { -// A(), -// } - -// #[derive(Type)] -// #[specta(export = false)] -// enum Unit7 { -// A {}, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct SimpleStruct { -// a: i32, -// b: String, -// c: (i32, String, RefCell), -// d: Vec, -// e: Option, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct TupleStruct1(i32); - -// #[derive(Type)] -// #[specta(export = false)] -// struct TupleStruct3(i32, bool, String); - -// #[derive(Type)] -// #[specta(export = false)] -// #[specta(rename = "HasBeenRenamed")] -// struct RenamedStruct; - -// #[derive(Type)] -// #[specta(export = false)] -// enum TestEnum { -// Unit, -// Single(i32), -// Multiple(i32, i32), -// Struct { a: i32 }, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct RefStruct(TestEnum); - -// #[derive(Type)] -// #[specta(export = false)] -// struct InlineStruct { -// ref_struct: SimpleStruct, -// val: i32, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct InlinerStruct { -// #[specta(inline)] -// inline_this: InlineStruct, -// dont_inline_this: RefStruct, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct GenericStruct { -// arg: T, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct FlattenEnumStruct { -// outer: String, -// #[serde(flatten)] -// inner: FlattenEnum, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// #[serde(tag = "tag", content = "test")] -// enum FlattenEnum { -// One, -// Two, -// Three, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct OverridenStruct { -// #[specta(type = String)] -// overriden_field: i32, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// struct HasGenericAlias(GenericAlias); - -// type GenericAlias = std::collections::HashMap; - -// #[derive(Type)] -// #[specta(export = false)] -// enum SkipVariant { -// A(String), -// #[serde(skip)] -// B(i32), -// #[specta(skip)] -// C(i32), -// } - -// #[derive(Type)] -// #[specta(export = false)] -// #[serde(tag = "tag", content = "data")] -// enum SkipVariant2 { -// A(String), -// #[serde(skip)] -// B(i32), -// #[specta(skip)] -// C(i32), -// } - -// #[derive(Type)] -// #[specta(export = false)] -// enum SkipVariant3 { -// A { -// a: String, -// }, -// #[serde(skip)] -// B { -// b: i32, -// }, -// #[specta(skip)] -// C { -// b: i32, -// }, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub enum EnumMacroAttributes { -// A(#[specta(type = String)] i32), -// #[specta(rename = "bbb")] -// B(i32), -// #[specta(rename = "cccc")] -// C(#[specta(type = i32)] String), -// D { -// #[specta(type = String)] -// a: i32, -// #[specta(rename = "bbbbbb")] -// b: i32, -// }, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub struct PlaceholderInnerField { -// a: String, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub struct Recursive { -// a: i32, -// children: Vec, -// } - -// #[derive(Type)] -// #[specta(export = false)] - -// pub enum InlineEnumField { -// #[specta(inline)] -// A(PlaceholderInnerField), -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub struct InlineOptionalType { -// #[specta(inline)] -// pub optional_field: Option, -// } - -// const CONTAINER_NAME: &str = "RenameToValueNewName"; -// const FIELD_NAME: &str = "demo_new_name"; - -// // This is very much an advanced API. It is not recommended to use this unless you know what your doing. -// // For personal reference: Is used in PCR to apply an inflection to the dynamic name of the include/select macro. -// #[derive(Type)] -// #[specta(export = false, rename_from_path = CONTAINER_NAME)] -// pub struct RenameToValue { -// #[specta(rename_from_path = FIELD_NAME)] -// pub demo: i32, -// } - -// // Regression test for https://github.com/oscartbeaumont/specta/issues/56 -// #[derive(Type)] -// #[specta(export = false)] -// enum Rename { -// OneWord, -// #[serde(rename = "Two words")] -// TwoWords, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub struct TransparentTypeInner { -// inner: String, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// #[serde(transparent)] -// pub struct TransparentType(pub(crate) TransparentTypeInner); - -// #[derive(Type)] -// #[specta(export = false)] -// #[serde(transparent)] -// pub struct TransparentType2(pub(crate) ()); - -// #[derive()] -// pub struct NonTypeType; - -// #[derive(Type)] -// #[specta(export = false)] -// #[serde(transparent)] -// pub struct TransparentTypeWithOverride(#[specta(type = String)] NonTypeType); - -// #[derive(Type)] -// #[specta(export = false)] -// pub enum BasicEnum { -// A, -// B, -// } - -// #[derive(Type)] -// #[serde( -// export = false, -// tag = "type", -// content = "value", -// rename_all = "camelCase" -// )] -// pub enum NestedEnum { -// A(String), -// B(i32), -// } - -// #[derive(Type)] -// #[serde(export = false, rename_all = "camelCase")] -// pub struct FlattenOnNestedEnum { -// id: String, -// #[serde(flatten)] -// result: NestedEnum, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// pub struct EnumReferenceRecordKey { -// a: HashMap, -// } - -// // https://github.com/oscartbeaumont/specta/issues/88 -// #[derive(Type)] -// #[serde(export = false, rename_all = "camelCase")] -// #[serde(default)] -// pub struct MyEmptyInput {} - -// #[derive(Type)] -// #[specta(export = false)] -// #[allow(unused_parens)] -// pub enum ExtraBracketsInTupleVariant { -// A((String)), -// } - -// #[derive(Type)] -// #[specta(export = false)] -// #[allow(unused_parens)] -// pub struct ExtraBracketsInUnnamedStruct((String)); - -// #[derive(Type)] -// #[specta(export = false)] -// #[allow(unused_parens)] -// pub struct RenameWithWeirdCharsField { -// #[specta(rename = "@odata.context")] -// odata_context: String, -// } - -// #[derive(Type)] -// #[specta(export = false)] -// #[allow(unused_parens)] -// pub enum RenameWithWeirdCharsVariant { -// #[specta(rename = "@odata.context")] -// A(String), -// } - -// #[derive(Type)] -// #[specta(export = false, rename = "@odata.context")] -// pub struct RenameWithWeirdCharsStruct(String); - -// #[derive(Type)] -// #[specta(export = false, rename = "@odata.context")] -// pub enum RenameWithWeirdCharsEnum {} - -// #[derive(Type)] -// pub enum MyEnum { -// A(String), -// B(u32), -// } - -// #[derive(Type)] -// pub struct InlineTuple { -// #[specta(inline)] -// demo: (String, bool), -// } - -// #[derive(Type)] -// pub struct InlineTuple2 { -// #[specta(inline)] -// demo: (InlineTuple, bool), -// } +use specta::{Type, TypeCollection}; +use specta_zod::{BigIntExportBehavior, Zod}; + +#[derive(Type)] +#[specta(export = false)] +struct SimpleStruct { + a: i32, + b: String, + c: bool, +} + +#[derive(Type)] +#[specta(export = false)] +struct TupleStruct(i32, String); + +#[derive(Type)] +#[specta(export = false)] +enum SimpleEnum { + A, + B, + C, +} + +#[derive(Type)] +#[specta(export = false)] +enum ComplexEnum { + Unit, + Single(i32), + Multiple(i32, String), + Struct { a: i32, b: String }, +} + +#[test] +fn test_simple_struct() { + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("export const SimpleStruct = z.object")); + assert!(result.contains("a: z.number()")); + assert!(result.contains("b: z.string()")); + assert!(result.contains("c: z.boolean()")); +} + +#[test] +fn test_tuple_struct() { + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("export const TupleStruct = z.tuple")); + assert!(result.contains("z.number()")); + assert!(result.contains("z.string()")); +} + +#[test] +fn test_simple_enum() { + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("export const SimpleEnum = z.union")); + assert!(result.contains("z.literal(\"A\")") || result.contains("z.literal(\"B\")") || result.contains("z.literal(\"C\")")); +} + +#[test] +fn test_complex_enum() { + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("export const ComplexEnum")); + assert!(result.contains("z.union")); +} + +#[test] +fn test_nullable() { + #[derive(Type)] + #[specta(export = false)] + struct WithOptional { + optional_field: Option, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("z.string().nullable()")); +} + +#[test] +fn test_vec() { + #[derive(Type)] + #[specta(export = false)] + struct WithVec { + items: Vec, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("z.array(z.number())")); +} + +#[test] +fn test_bigint_string() { + #[derive(Type)] + #[specta(export = false)] + struct WithBigInt { + big: i64, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default() + .bigint(BigIntExportBehavior::String) + .export(&types) + .unwrap(); + + assert!(result.contains("z.string()")); +} + +#[test] +fn test_bigint_number() { + #[derive(Type)] + #[specta(export = false)] + struct WithBigInt { + big: i64, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default() + .bigint(BigIntExportBehavior::Number) + .export(&types) + .unwrap(); + + assert!(result.contains("z.number()")); +} + +#[test] +fn test_bigint_bigint() { + #[derive(Type)] + #[specta(export = false)] + struct WithBigInt { + big: i64, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default() + .bigint(BigIntExportBehavior::BigInt) + .export(&types) + .unwrap(); + + assert!(result.contains("z.bigint()")); +} + +#[test] +fn test_bigint_fail() { + #[derive(Type)] + #[specta(export = false)] + struct WithBigInt { + big: i64, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types); + + assert!(result.is_err()); +} + +#[test] +fn test_nested_types() { + #[derive(Type)] + #[specta(export = false)] + struct Inner { + value: String, + } + + #[derive(Type)] + #[specta(export = false)] + struct Outer { + inner: Inner, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("export const Inner")); + assert!(result.contains("export const Outer")); + assert!(result.contains("inner: Inner")); +} + +#[test] +fn test_tuple() { + #[derive(Type)] + #[specta(export = false)] + struct WithTuple { + tuple: (i32, String, bool), + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("z.tuple")); + assert!(result.contains("z.number()")); + assert!(result.contains("z.string()")); + assert!(result.contains("z.boolean()")); +} + +#[test] +fn test_header() { + let types = TypeCollection::default().register::(); + let result = Zod::default() + .header("// Custom header\n") + .export(&types) + .unwrap(); + + assert!(result.contains("// Custom header")); +} + +#[test] +fn test_framework_header() { + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("// This file has been generated by Specta. DO NOT EDIT.")); +} + +#[test] +fn test_literals() { + #[derive(Type)] + #[specta(export = false)] + enum Literals { + String, + Number, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("z.literal")); +} + +#[test] +fn test_map() { + use std::collections::HashMap; + + #[derive(Type)] + #[specta(export = false)] + struct WithMap { + map: HashMap, + } + + let types = TypeCollection::default().register::(); + let result = Zod::default().export(&types).unwrap(); + + assert!(result.contains("z.record")); + assert!(result.contains("z.string()")); + assert!(result.contains("z.number()")); +}