1use proc_macro2::TokenStream;
8use quote::quote;
9use syn::{Data, DeriveInput, Fields};
10
11use crate::{
12 attributes::{ZodAttrs, apply_rename_rule, parse_serde_attrs, parse_zod_attrs},
13 errors::Result,
14 types::{
15 HASHMAP, OPTION, VEC, extract_first_generic_arg_string, is_primitive, try_extract_wrapper,
16 },
17};
18
19pub fn derive_zod_ts(input: DeriveInput) -> Result<TokenStream> {
25 let name = &input.ident;
26 let name_str = name.to_string();
27
28 match &input.data {
29 Data::Struct(data) => match &data.fields {
30 Fields::Named(fields) => expand_named_struct(name, &name_str, fields, &input),
31 Fields::Unnamed(_) | Fields::Unit => Err(syn::Error::new_spanned(
32 name,
33 "ZodTs: only structs with named fields are supported",
34 )
35 .into()),
36 },
37 Data::Enum(data) => expand_enum(name, &name_str, data, &input),
38 Data::Union(_) => {
39 Err(syn::Error::new_spanned(name, "ZodTs cannot be derived for unions").into())
40 }
41 }
42}
43
44fn expand_named_struct(
49 name: &syn::Ident,
50 name_str: &str,
51 fields: &syn::FieldsNamed,
52 _input: &DeriveInput,
53) -> Result<TokenStream> {
54 let mut field_tokens: Vec<TokenStream> = Vec::new();
55 let mut dep_type_names: Vec<String> = Vec::new();
56
57 for field in &fields.named {
58 let field_name = field.ident.as_ref().unwrap().to_string();
59 let serde = parse_serde_attrs(&field.attrs)?;
60
61 if serde.skip {
62 continue;
63 }
64
65 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
66 let zod = parse_zod_attrs(&field.attrs)?;
67 let is_opt = is_option_type(&field.ty);
68
69 let skip_if_none = is_opt
71 && matches!(
72 serde.skip_serializing_if.as_deref(),
73 Some("Option::is_none") | Some("std::option::Option::is_none")
74 );
75
76 let base_ty = if is_opt {
77 option_inner(&field.ty).unwrap_or(&field.ty)
78 } else {
79 &field.ty
80 };
81
82 let custom = innermost_custom_name(base_ty);
84 if let Some(ref c) = custom {
85 let bare = c.rsplit("::").next().unwrap_or(c.as_str());
87 dep_type_names.push(bare.to_string());
88 }
89
90 let field_tok =
92 if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
93 let zod_expr = if is_opt {
96 if skip_if_none {
97 format!("{}.optional()", container_expr)
98 } else {
99 format!("{}.nullable()", container_expr)
100 }
101 } else {
102 container_expr
103 };
104 quote! {
105 ::rorpc::FieldDef {
106 ts_name: #ts_key,
107 zod_expr: #zod_expr,
108 type_ref: "",
109 optional: #is_opt,
110 skip_if_none: #skip_if_none,
111 }
112 }
113 } else if let Some(ref type_ref) = custom {
114 let bare_ref = type_ref.rsplit("::").next().unwrap_or(type_ref.as_str());
116 quote! {
117 ::rorpc::FieldDef {
118 ts_name: #ts_key,
119 zod_expr: "",
120 type_ref: #bare_ref,
121 optional: #is_opt,
122 skip_if_none: #skip_if_none,
123 }
124 }
125 } else {
126 let zod_expr = rust_type_to_zod(base_ty, &zod);
128 let zod_expr = if is_opt {
129 if skip_if_none {
130 format!("{}.optional()", zod_expr)
131 } else {
132 format!("{}.nullable()", zod_expr)
133 }
134 } else {
135 zod_expr
136 };
137 quote! {
138 ::rorpc::FieldDef {
139 ts_name: #ts_key,
140 zod_expr: #zod_expr,
141 type_ref: "",
142 optional: #is_opt,
143 skip_if_none: #skip_if_none,
144 }
145 }
146 };
147
148 field_tokens.push(field_tok);
149 }
150
151 Ok(emit_registration(
152 name,
153 name_str,
154 field_tokens,
155 &dep_type_names,
156 ))
157}
158
159fn expand_enum(
164 name: &syn::Ident,
165 name_str: &str,
166 data: &syn::DataEnum,
167 input: &DeriveInput,
168) -> Result<TokenStream> {
169 let serde_container = parse_serde_attrs(&input.attrs)?;
170 let rename_all = serde_container.rename_all.as_deref();
171
172 let repr = if serde_container.untagged {
174 quote! { ::rorpc::EnumRepr::Untagged }
175 } else if let (Some(tag), Some(content)) = (&serde_container.tag, &serde_container.content) {
176 let tag_static: &'static str = Box::leak(tag.clone().into_boxed_str());
178 let content_static: &'static str = Box::leak(content.clone().into_boxed_str());
179 quote! { ::rorpc::EnumRepr::Adjacent { tag: #tag_static, content: #content_static } }
180 } else if let Some(tag) = &serde_container.tag {
181 let tag_static: &'static str = Box::leak(tag.clone().into_boxed_str());
182 quote! { ::rorpc::EnumRepr::Internal { tag: #tag_static } }
183 } else {
184 quote! { ::rorpc::EnumRepr::External }
185 };
186
187 let mut variant_tokens: Vec<TokenStream> = Vec::new();
188
189 for variant in &data.variants {
190 let serde_variant = parse_serde_attrs(&variant.attrs)?;
191 if serde_variant.skip {
192 continue;
193 }
194
195 let raw_name = variant.ident.to_string();
196 let variant_name = serde_variant
197 .rename
198 .as_deref()
199 .map(str::to_string)
200 .unwrap_or_else(|| {
201 rename_all
202 .map(|rule| apply_rename_rule(rule, &raw_name))
203 .unwrap_or(raw_name)
204 });
205
206 let kind_tok = generate_variant_def(&variant.fields)?;
207 variant_tokens.push(quote! {
208 ::rorpc::VariantDef {
209 serialized_name: #variant_name,
210 kind: #kind_tok,
211 }
212 });
213 }
214
215 Ok(emit_enum_registration(name, name_str, repr, variant_tokens))
216}
217
218fn generate_variant_def(fields: &Fields) -> Result<TokenStream> {
223 match fields {
224 Fields::Unit => Ok(quote! { ::rorpc::VariantKind::Unit }),
225
226 Fields::Unnamed(fields_unnamed) => {
227 let count = fields_unnamed.unnamed.len();
228 if count == 1 {
229 let field = fields_unnamed.unnamed.first().unwrap();
230 let zod = parse_zod_attrs(&field.attrs)?;
231 if let Some(custom) = innermost_custom_name(&field.ty) {
232 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
233 Ok(quote! { ::rorpc::VariantKind::NewtypeRef { type_ref: #bare_ref } })
234 } else {
235 let schema = rust_type_to_zod(&field.ty, &zod);
236 Ok(quote! { ::rorpc::VariantKind::NewtypeZod { zod_expr: #schema } })
237 }
238 } else {
239 Err(syn::Error::new_spanned(
241 fields_unnamed,
242 format!(
243 "multi-field tuple variants are not yet supported in #[derive(ZodTs)]; \
244 found {} fields, expected 0 (unit variant) or 1 (newtype variant)",
245 count
246 ),
247 )
248 .into())
249 }
250 }
251
252 Fields::Named(fields_named) => {
253 let mut field_tokens: Vec<TokenStream> = Vec::new();
254 for field in &fields_named.named {
255 let field_name = field.ident.as_ref().unwrap().to_string();
256 let serde = parse_serde_attrs(&field.attrs)?;
257 if serde.skip {
258 continue;
259 }
260 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
261 let zod_attrs = parse_zod_attrs(&field.attrs)?;
262 let is_opt = is_option_type(&field.ty);
263
264 let skip_if_none = is_opt
266 && matches!(
267 serde.skip_serializing_if.as_deref(),
268 Some("Option::is_none") | Some("std::option::Option::is_none")
269 );
270
271 let base_ty = if is_opt {
272 option_inner(&field.ty).unwrap_or(&field.ty)
273 } else {
274 &field.ty
275 };
276
277 let field_tok = if let Some(custom) = innermost_custom_name(base_ty) {
278 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
279 quote! {
280 ::rorpc::FieldDef {
281 ts_name: #ts_key,
282 zod_expr: "",
283 type_ref: #bare_ref,
284 optional: #is_opt,
285 skip_if_none: #skip_if_none,
286 }
287 }
288 } else {
289 let zod_expr = rust_type_to_zod(base_ty, &zod_attrs);
290 let zod_expr = if is_opt {
291 if skip_if_none {
292 format!("{}.optional()", zod_expr)
293 } else {
294 format!("{}.nullable()", zod_expr)
295 }
296 } else {
297 zod_expr
298 };
299 quote! {
300 ::rorpc::FieldDef {
301 ts_name: #ts_key,
302 zod_expr: #zod_expr,
303 type_ref: "",
304 optional: #is_opt,
305 skip_if_none: #skip_if_none,
306 }
307 }
308 };
309 field_tokens.push(field_tok);
310 }
311 Ok(quote! {
312 ::rorpc::VariantKind::Struct {
313 fields: &[ #(#field_tokens),* ]
314 }
315 })
316 }
317 }
318}
319
320fn emit_registration(
329 name: &syn::Ident,
330 name_str: &str,
331 items: Vec<TokenStream>,
332 dep_type_names: &[String],
333) -> TokenStream {
334 let dep_strs: Vec<&str> = dep_type_names.iter().map(String::as_str).collect();
335
336 quote! {
337 impl #name {
338 pub fn dependent_types() -> Vec<&'static str> {
339 vec![#(#dep_strs),*]
340 }
341 }
342
343 const _: () = {
344 static __FIELDS: &[::rorpc::FieldDef] = &[ #(#items),* ];
345 ::rorpc::inventory::submit! {
346 ::rorpc::SchemaRegistration {
347 type_name: #name_str,
348 module_path: concat!(module_path!(), "::", #name_str),
349 schema_def: ::rorpc::SchemaDef::Object { fields: __FIELDS },
350 dependent_types: #name::dependent_types,
351 }
352 }
353 };
354 }
355}
356
357fn emit_enum_registration(
359 name: &syn::Ident,
360 name_str: &str,
361 repr: TokenStream,
362 items: Vec<TokenStream>,
363) -> TokenStream {
364 quote! {
365 impl #name {
366 pub fn dependent_types() -> Vec<&'static str> {
367 vec![]
368 }
369 }
370
371 const _: () = {
372 static __VARIANTS: &[::rorpc::VariantDef] = &[ #(#items),* ];
373 ::rorpc::inventory::submit! {
374 ::rorpc::SchemaRegistration {
375 type_name: #name_str,
376 module_path: concat!(module_path!(), "::", #name_str),
377 schema_def: ::rorpc::SchemaDef::Enum { repr: #repr, variants: __VARIANTS },
378 dependent_types: #name::dependent_types,
379 }
380 }
381 };
382 }
383}
384
385pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
394 if is_option_type(ty)
396 && let Some(inner) = option_inner(ty)
397 {
398 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
399 return format!("{}.optional()", inner_schema);
400 }
401
402 if let Some(m) = try_extract_wrapper(ty, VEC)
404 && let Some(inner) = m.first_type()
405 {
406 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
407 let mut chain = format!("z.array({})", inner_schema);
408 if let Some(n) = attrs.length {
409 chain.push_str(&format!(".length({})", n));
410 }
411 if let Some(n) = attrs.min_length {
412 chain.push_str(&format!(".min({})", n));
413 }
414 if let Some(n) = attrs.max_length {
415 chain.push_str(&format!(".max({})", n));
416 }
417 return chain;
418 }
419
420 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
422 let types = m.all_types();
423 if types.len() == 2 {
424 let key_schema = rust_type_to_zod(types[0], &ZodAttrs::default());
425 let value_schema = rust_type_to_zod(types[1], &ZodAttrs::default());
426 return format!("z.record({}, {})", key_schema, value_schema);
427 } else {
428 return "z.record(z.string(), z.unknown())".to_string();
430 }
431 }
432
433 if let syn::Type::Path(type_path) = ty
435 && let Some(seg) = type_path.path.segments.last()
436 {
437 let name = seg.ident.to_string();
438 return match name.as_str() {
439 "String" | "str" => build_string_schema(attrs),
440 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
441 | "u128" | "usize" => build_integer_schema(attrs),
442 "f32" | "f64" => build_float_schema(attrs),
443 "bool" => "z.boolean()".to_string(),
444 "Uuid" => "z.uuid()".to_string(),
446 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
448 "Value" => "z.record(z.string(), z.unknown())".to_string(),
450 other => format!("{}Schema", other),
452 };
453 }
454
455 if let syn::Type::Tuple(t) = ty
457 && t.elems.is_empty()
458 {
459 return "z.void()".to_string();
460 }
461
462 "z.unknown()".to_string()
463}
464
465fn build_string_schema(attrs: &ZodAttrs) -> String {
470 let mut chain = String::from("z.string()");
471 if let Some(n) = attrs.length {
472 chain.push_str(&format!(".length({})", n));
473 }
474 if let Some(n) = attrs.min_length {
475 chain.push_str(&format!(".min({})", n));
476 }
477 if let Some(n) = attrs.max_length {
478 chain.push_str(&format!(".max({})", n));
479 }
480 if attrs.email {
481 chain.push_str(".email()");
482 }
483 if attrs.url {
484 chain.push_str(".url()");
485 }
486 if let Some(ref p) = attrs.regex {
487 chain.push_str(&format!(".regex(/{}/)", p));
488 }
489 if let Some(ref p) = attrs.starts_with {
490 chain.push_str(&format!(".startsWith(\"{}\")", p));
491 }
492 if let Some(ref p) = attrs.ends_with {
493 chain.push_str(&format!(".endsWith(\"{}\")", p));
494 }
495 if let Some(ref p) = attrs.includes {
496 chain.push_str(&format!(".includes(\"{}\")", p));
497 }
498 chain
499}
500
501fn build_integer_schema(attrs: &ZodAttrs) -> String {
502 let mut chain = String::from("z.number().int()");
503 append_number_validators(&mut chain, attrs);
504 chain
505}
506
507fn build_float_schema(attrs: &ZodAttrs) -> String {
508 let mut chain = String::from("z.number()");
509 if attrs.int {
510 chain.push_str(".int()");
511 }
512 append_number_validators(&mut chain, attrs);
513 chain
514}
515
516fn append_number_validators(chain: &mut String, attrs: &ZodAttrs) {
517 if let Some(n) = attrs.min {
518 chain.push_str(&format!(".min({})", n));
519 }
520 if let Some(n) = attrs.max {
521 chain.push_str(&format!(".max({})", n));
522 }
523 if attrs.positive {
524 chain.push_str(".positive()");
525 }
526 if attrs.negative {
527 chain.push_str(".negative()");
528 }
529 if attrs.nonnegative {
530 chain.push_str(".nonnegative()");
531 }
532 if attrs.nonpositive {
533 chain.push_str(".nonpositive()");
534 }
535 if attrs.finite {
536 chain.push_str(".finite()");
537 }
538}
539
540fn is_option_type(ty: &syn::Type) -> bool {
545 try_extract_wrapper(ty, OPTION).is_some()
546}
547
548fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
549 try_extract_wrapper(ty, OPTION)?.first_type()
550}
551
552fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
559 if let Some(m) = try_extract_wrapper(ty, VEC)
561 && let Some(inner) = m.first_type()
562 && let Some(custom_name) = innermost_custom_name(inner)
563 {
564 let bare = custom_name.rsplit("::").next().unwrap_or(&custom_name);
565 return Some(format!("z.array({}Schema)", bare));
566 }
567
568 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
570 let types = m.all_types();
571 if types.len() == 2 {
572 let key_has_custom = innermost_custom_name(types[0]).is_some();
573 let val_has_custom = innermost_custom_name(types[1]).is_some();
574
575 if key_has_custom || val_has_custom {
576 let key_expr = type_to_zod_or_schema_ref(types[0]);
577 let val_expr = type_to_zod_or_schema_ref(types[1]);
578 return Some(format!("z.record({}, {})", key_expr, val_expr));
579 }
580 }
581 }
582
583 None
584}
585
586fn type_to_zod_or_schema_ref(ty: &syn::Type) -> String {
590 if let Some(custom) = innermost_custom_name(ty) {
591 let bare = custom.rsplit("::").next().unwrap_or(&custom);
592 format!("{}Schema", bare)
593 } else {
594 rust_type_to_zod(ty, &ZodAttrs::default())
595 }
596}
597
598fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
601 if let Some(m) = try_extract_wrapper(ty, VEC) {
603 return m.first_type().and_then(innermost_custom_name);
604 }
605 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
607 if let Some(key) = m.first_type()
610 && let Some(name) = innermost_custom_name(key)
611 {
612 return Some(name);
613 }
614 if let Some(value) = m.nth_type(1) {
615 return innermost_custom_name(value);
616 }
617 return None;
618 }
619 if is_primitive(ty) {
620 return None;
621 }
622 if let syn::Type::Path(tp) = ty {
623 let segments: Vec<String> = tp
625 .path
626 .segments
627 .iter()
628 .map(|seg| seg.ident.to_string())
629 .collect();
630
631 if segments.is_empty() {
632 return None;
633 }
634
635 let last = segments.last().unwrap();
636 if last == "Value" {
638 return None;
639 }
640
641 return Some(segments.join("::"));
643 }
644 None
645}
646
647pub fn rust_type_to_ts_schema(raw: &str) -> String {
680 let raw = raw.replace(' ', "");
681
682 if raw.starts_with("Sse<") {
683 return "asyncIteratorObject(z.unknown() /* TODO: add #[derive(ZodTs)] to your stream event type */)".to_string();
684 }
685
686 let inner = if raw.starts_with("Result<") {
688 extract_first_generic_arg_string(&raw).unwrap_or(raw.clone())
689 } else {
690 raw.clone()
691 };
692
693 let inner = if inner.starts_with("Json<") && inner.ends_with('>') {
695 inner[5..inner.len() - 1].to_string()
696 } else {
697 inner
698 };
699
700 type_name_to_zod_ref(&inner)
701}
702
703pub(crate) fn primitive_zod_expr(type_name: &str) -> Option<&'static str> {
708 match type_name {
709 "String" | "str" => Some("z.string()"),
710 "bool" => Some("z.boolean()"),
711 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
712 | "usize" => Some("z.number().int()"),
713 "f32" | "f64" => Some("z.number()"),
714 "Uuid" => Some("z.uuid()"),
715 "DateTime" => Some("z.iso.datetime({ offset: true })"),
716 "Value" => Some("z.record(z.string(), z.unknown())"),
717 _ => None,
718 }
719}
720
721fn type_name_to_zod_ref(type_name: &str) -> String {
723 match type_name {
724 "()" => "z.void()".to_string(),
725 "" => String::new(),
726 _ => {
727 if let Some(zod) = primitive_zod_expr(type_name) {
729 return zod.to_string();
730 }
731
732 if type_name.starts_with("Vec<") && type_name.ends_with('>') {
734 let inner = &type_name[4..type_name.len() - 1];
735 return format!("z.array({})", type_name_to_zod_ref(inner));
736 }
737 if type_name.starts_with("HashMap<") && type_name.ends_with('>') {
738 let inner = &type_name[8..type_name.len() - 1];
739 let parts: Vec<&str> = inner.splitn(2, ',').collect();
741 if parts.len() == 2 {
742 let key_schema = type_name_to_zod_ref(parts[0].trim());
743 let value_schema = type_name_to_zod_ref(parts[1].trim());
744 return format!("z.record({}, {})", key_schema, value_schema);
745 } else {
746 return "z.record(z.string(), z.unknown())".to_string();
748 }
749 }
750 if type_name.starts_with("Option<") && type_name.ends_with('>') {
751 let inner = &type_name[7..type_name.len() - 1];
752 return format!("{}.optional()", type_name_to_zod_ref(inner));
753 }
754
755 let base = type_name.rsplit("::").next().unwrap_or(type_name);
757 format!("{}Schema", base)
758 }
759 }
760}
761
762pub fn to_schema_name(rust_type: &str) -> String {
764 format!("{}Schema", base_type_name(rust_type))
765}
766
767pub fn base_type_name(rust_type: &str) -> String {
771 let mut base = rust_type.trim().to_string();
772
773 if base.starts_with("Result<")
774 && let Some(inner) = extract_first_generic_arg_string(&base)
775 {
776 base = inner;
777 }
778 if base.starts_with("Json<") && base.ends_with('>') {
779 base = base[5..base.len() - 1].to_string();
780 }
781 if base.starts_with("Vec<") && base.ends_with('>') {
782 base = base[4..base.len() - 1].to_string();
783 }
784 if base.starts_with("Option<") && base.ends_with('>') {
785 base = base[7..base.len() - 1].to_string();
786 }
787
788 base.rsplit("::").next().unwrap_or(&base).to_string()
789}
790
791#[cfg(test)]
792mod runtime_conversion_tests {
793 use super::*;
794
795 #[test]
796 fn json_planet() {
797 assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
798 }
799
800 #[test]
801 fn json_vec_planet() {
802 assert_eq!(
803 rust_type_to_ts_schema("Json<Vec<Planet>>"),
804 "z.array(PlanetSchema)"
805 );
806 }
807
808 #[test]
809 fn result_json_planet() {
810 assert_eq!(
811 rust_type_to_ts_schema("Result<Json<Planet>, StatusCode>"),
812 "PlanetSchema"
813 );
814 }
815
816 #[test]
817 fn json_string() {
818 assert_eq!(rust_type_to_ts_schema("Json<String>"), "z.string()");
819 }
820
821 #[test]
822 fn unit_type() {
823 assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
824 }
825
826 #[test]
827 fn serde_json_value() {
828 assert_eq!(
829 rust_type_to_ts_schema("Json<serde_json::Value>"),
830 "z.record(z.string(), z.unknown())"
831 );
832 }
833
834 #[test]
835 fn schema_name_simple() {
836 assert_eq!(to_schema_name("Planet"), "PlanetSchema");
837 }
838
839 #[test]
840 fn schema_name_vec() {
841 assert_eq!(to_schema_name("Vec<Planet>"), "PlanetSchema");
842 }
843
844 #[test]
845 fn base_type_unwraps_wrappers() {
846 assert_eq!(base_type_name("Result<Json<Vec<Planet>>, E>"), "Planet");
847 assert_eq!(base_type_name("Json<Planet>"), "Planet");
848 assert_eq!(base_type_name("Vec<Planet>"), "Planet");
849 assert_eq!(base_type_name("Option<Planet>"), "Planet");
850 }
851
852 #[test]
853 fn base_type_strips_module_path() {
854 assert_eq!(base_type_name("models::Planet"), "Planet");
855 assert_eq!(base_type_name("crate::domain::Planet"), "Planet");
856 }
857
858 #[test]
859 fn hashmap_string_string() {
860 assert_eq!(
861 rust_type_to_ts_schema("HashMap<String, String>"),
862 "z.record(z.string(), z.string())"
863 );
864 }
865
866 #[test]
867 fn hashmap_with_custom_value() {
868 assert_eq!(
869 rust_type_to_ts_schema("HashMap<String, Planet>"),
870 "z.record(z.string(), PlanetSchema)"
871 );
872 }
873
874 #[test]
875 fn json_hashmap() {
876 assert_eq!(
877 rust_type_to_ts_schema("Json<HashMap<String, String>>"),
878 "z.record(z.string(), z.string())"
879 );
880 }
881
882 #[test]
883 fn vec_of_custom_type() {
884 let ty: syn::Type = syn::parse_str("Vec<Planet>").unwrap();
885 let expr = try_generate_container_with_custom_types(&ty);
886 assert_eq!(expr, Some("z.array(PlanetSchema)".to_string()));
887 }
888
889 #[test]
890 fn vec_of_primitive_returns_none() {
891 let ty: syn::Type = syn::parse_str("Vec<String>").unwrap();
892 let expr = try_generate_container_with_custom_types(&ty);
893 assert_eq!(expr, None);
894 }
895
896 #[test]
897 fn hashmap_with_custom_key() {
898 let ty: syn::Type = syn::parse_str("HashMap<Planet, String>").unwrap();
899 let expr = try_generate_container_with_custom_types(&ty);
900 assert_eq!(expr, Some("z.record(PlanetSchema, z.string())".to_string()));
901 }
902
903 #[test]
904 fn hashmap_fully_primitive_returns_none() {
905 let ty: syn::Type = syn::parse_str("HashMap<String, i32>").unwrap();
906 let expr = try_generate_container_with_custom_types(&ty);
907 assert_eq!(expr, None);
908 }
909}