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