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 base_ty = if is_opt {
68 option_inner(&field.ty).unwrap_or(&field.ty)
69 } else {
70 &field.ty
71 };
72
73 let custom = innermost_custom_name(base_ty);
75 if let Some(ref c) = custom {
76 let bare = c.rsplit("::").next().unwrap_or(c.as_str());
78 dep_type_names.push(bare.to_string());
79 }
80
81 let field_tok =
83 if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
84 let zod_expr = if is_opt {
87 format!("{}.optional()", container_expr)
88 } else {
89 container_expr
90 };
91 quote! {
92 ::rorpc::FieldDef {
93 ts_name: #ts_key,
94 zod_expr: #zod_expr,
95 type_ref: "",
96 optional: #is_opt,
97 }
98 }
99 } else if let Some(ref type_ref) = custom {
100 let bare_ref = type_ref.rsplit("::").next().unwrap_or(type_ref.as_str());
102 quote! {
103 ::rorpc::FieldDef {
104 ts_name: #ts_key,
105 zod_expr: "",
106 type_ref: #bare_ref,
107 optional: #is_opt,
108 }
109 }
110 } else {
111 let zod_expr = rust_type_to_zod(base_ty, &zod);
113 let zod_expr = if is_opt {
114 format!("{}.optional()", zod_expr)
115 } else {
116 zod_expr
117 };
118 quote! {
119 ::rorpc::FieldDef {
120 ts_name: #ts_key,
121 zod_expr: #zod_expr,
122 type_ref: "",
123 optional: #is_opt,
124 }
125 }
126 };
127
128 field_tokens.push(field_tok);
129 }
130
131 Ok(emit_registration(
132 name,
133 name_str,
134 field_tokens,
135 &dep_type_names,
136 ))
137}
138
139fn expand_enum(
144 name: &syn::Ident,
145 name_str: &str,
146 data: &syn::DataEnum,
147 input: &DeriveInput,
148) -> Result<TokenStream> {
149 let serde_container = parse_serde_attrs(&input.attrs)?;
150 let rename_all = serde_container.rename_all.as_deref();
151
152 let repr = if serde_container.untagged {
154 quote! { ::rorpc::EnumRepr::Untagged }
155 } else if let (Some(tag), Some(content)) = (&serde_container.tag, &serde_container.content) {
156 let tag_static: &'static str = Box::leak(tag.clone().into_boxed_str());
158 let content_static: &'static str = Box::leak(content.clone().into_boxed_str());
159 quote! { ::rorpc::EnumRepr::Adjacent { tag: #tag_static, content: #content_static } }
160 } else if let Some(tag) = &serde_container.tag {
161 let tag_static: &'static str = Box::leak(tag.clone().into_boxed_str());
162 quote! { ::rorpc::EnumRepr::Internal { tag: #tag_static } }
163 } else {
164 quote! { ::rorpc::EnumRepr::External }
165 };
166
167 let mut variant_tokens: Vec<TokenStream> = Vec::new();
168
169 for variant in &data.variants {
170 let serde_variant = parse_serde_attrs(&variant.attrs)?;
171 if serde_variant.skip {
172 continue;
173 }
174
175 let raw_name = variant.ident.to_string();
176 let variant_name = serde_variant
177 .rename
178 .as_deref()
179 .map(str::to_string)
180 .unwrap_or_else(|| {
181 rename_all
182 .map(|rule| apply_rename_rule(rule, &raw_name))
183 .unwrap_or(raw_name)
184 });
185
186 let kind_tok = generate_variant_def(&variant.fields)?;
187 variant_tokens.push(quote! {
188 ::rorpc::VariantDef {
189 serialized_name: #variant_name,
190 kind: #kind_tok,
191 }
192 });
193 }
194
195 Ok(emit_enum_registration(name, name_str, repr, variant_tokens))
196}
197
198fn generate_variant_def(fields: &Fields) -> Result<TokenStream> {
203 match fields {
204 Fields::Unit => Ok(quote! { ::rorpc::VariantKind::Unit }),
205
206 Fields::Unnamed(fields_unnamed) => {
207 let count = fields_unnamed.unnamed.len();
208 if count == 1 {
209 let field = fields_unnamed.unnamed.first().unwrap();
210 let zod = parse_zod_attrs(&field.attrs)?;
211 if let Some(custom) = innermost_custom_name(&field.ty) {
212 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
213 Ok(quote! { ::rorpc::VariantKind::NewtypeRef { type_ref: #bare_ref } })
214 } else {
215 let schema = rust_type_to_zod(&field.ty, &zod);
216 Ok(quote! { ::rorpc::VariantKind::NewtypeZod { zod_expr: #schema } })
217 }
218 } else {
219 Ok(quote! { ::rorpc::VariantKind::Unit })
221 }
222 }
223
224 Fields::Named(fields_named) => {
225 let mut field_tokens: Vec<TokenStream> = Vec::new();
226 for field in &fields_named.named {
227 let field_name = field.ident.as_ref().unwrap().to_string();
228 let serde = parse_serde_attrs(&field.attrs)?;
229 if serde.skip {
230 continue;
231 }
232 let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
233 let zod_attrs = parse_zod_attrs(&field.attrs)?;
234 let is_opt = is_option_type(&field.ty);
235 let base_ty = if is_opt {
236 option_inner(&field.ty).unwrap_or(&field.ty)
237 } else {
238 &field.ty
239 };
240
241 let field_tok = if let Some(custom) = innermost_custom_name(base_ty) {
242 let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
243 quote! {
244 ::rorpc::FieldDef {
245 ts_name: #ts_key,
246 zod_expr: "",
247 type_ref: #bare_ref,
248 optional: #is_opt,
249 }
250 }
251 } else {
252 let zod_expr = rust_type_to_zod(base_ty, &zod_attrs);
253 let zod_expr = if is_opt {
254 format!("{}.optional()", zod_expr)
255 } else {
256 zod_expr
257 };
258 quote! {
259 ::rorpc::FieldDef {
260 ts_name: #ts_key,
261 zod_expr: #zod_expr,
262 type_ref: "",
263 optional: #is_opt,
264 }
265 }
266 };
267 field_tokens.push(field_tok);
268 }
269 Ok(quote! {
270 ::rorpc::VariantKind::Struct {
271 fields: &[ #(#field_tokens),* ]
272 }
273 })
274 }
275 }
276}
277
278fn emit_registration(
287 name: &syn::Ident,
288 name_str: &str,
289 items: Vec<TokenStream>,
290 dep_type_names: &[String],
291) -> TokenStream {
292 let dep_strs: Vec<&str> = dep_type_names.iter().map(String::as_str).collect();
293
294 quote! {
295 impl #name {
296 pub fn dependent_types() -> Vec<&'static str> {
297 vec![#(#dep_strs),*]
298 }
299 }
300
301 const _: () = {
302 static __FIELDS: &[::rorpc::FieldDef] = &[ #(#items),* ];
303 ::rorpc::inventory::submit! {
304 ::rorpc::SchemaRegistration {
305 type_name: #name_str,
306 module_path: concat!(module_path!(), "::", #name_str),
307 schema_def: ::rorpc::SchemaDef::Object { fields: __FIELDS },
308 dependent_types: #name::dependent_types,
309 }
310 }
311 };
312 }
313}
314
315fn emit_enum_registration(
317 name: &syn::Ident,
318 name_str: &str,
319 repr: TokenStream,
320 items: Vec<TokenStream>,
321) -> TokenStream {
322 quote! {
323 impl #name {
324 pub fn dependent_types() -> Vec<&'static str> {
325 vec![]
326 }
327 }
328
329 const _: () = {
330 static __VARIANTS: &[::rorpc::VariantDef] = &[ #(#items),* ];
331 ::rorpc::inventory::submit! {
332 ::rorpc::SchemaRegistration {
333 type_name: #name_str,
334 module_path: concat!(module_path!(), "::", #name_str),
335 schema_def: ::rorpc::SchemaDef::Enum { repr: #repr, variants: __VARIANTS },
336 dependent_types: #name::dependent_types,
337 }
338 }
339 };
340 }
341}
342
343pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
352 if is_option_type(ty)
354 && let Some(inner) = option_inner(ty)
355 {
356 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
357 return format!("{}.optional()", inner_schema);
358 }
359
360 if let Some(m) = try_extract_wrapper(ty, VEC)
362 && let Some(inner) = m.first_type()
363 {
364 let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
365 let mut chain = format!("z.array({})", inner_schema);
366 if let Some(n) = attrs.length {
367 chain.push_str(&format!(".length({})", n));
368 }
369 if let Some(n) = attrs.min_length {
370 chain.push_str(&format!(".min({})", n));
371 }
372 if let Some(n) = attrs.max_length {
373 chain.push_str(&format!(".max({})", n));
374 }
375 return chain;
376 }
377
378 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
380 let types = m.all_types();
381 if types.len() == 2 {
382 let key_schema = rust_type_to_zod(types[0], &ZodAttrs::default());
383 let value_schema = rust_type_to_zod(types[1], &ZodAttrs::default());
384 return format!("z.record({}, {})", key_schema, value_schema);
385 } else {
386 return "z.record(z.string(), z.unknown())".to_string();
388 }
389 }
390
391 if let syn::Type::Path(type_path) = ty
393 && let Some(seg) = type_path.path.segments.last()
394 {
395 let name = seg.ident.to_string();
396 return match name.as_str() {
397 "String" | "str" => build_string_schema(attrs),
398 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
399 | "u128" | "usize" => build_integer_schema(attrs),
400 "f32" | "f64" => build_float_schema(attrs),
401 "bool" => "z.boolean()".to_string(),
402 "Uuid" => "z.uuid()".to_string(),
404 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
406 "Value" => "z.record(z.string(), z.unknown())".to_string(),
408 other => format!("{}Schema", other),
410 };
411 }
412
413 if let syn::Type::Tuple(t) = ty
415 && t.elems.is_empty()
416 {
417 return "z.void()".to_string();
418 }
419
420 "z.unknown()".to_string()
421}
422
423fn build_string_schema(attrs: &ZodAttrs) -> String {
428 let mut chain = String::from("z.string()");
429 if let Some(n) = attrs.length {
430 chain.push_str(&format!(".length({})", n));
431 }
432 if let Some(n) = attrs.min_length {
433 chain.push_str(&format!(".min({})", n));
434 }
435 if let Some(n) = attrs.max_length {
436 chain.push_str(&format!(".max({})", n));
437 }
438 if attrs.email {
439 chain.push_str(".email()");
440 }
441 if attrs.url {
442 chain.push_str(".url()");
443 }
444 if let Some(ref p) = attrs.regex {
445 chain.push_str(&format!(".regex(/{}/)", p));
446 }
447 if let Some(ref p) = attrs.starts_with {
448 chain.push_str(&format!(".startsWith(\"{}\")", p));
449 }
450 if let Some(ref p) = attrs.ends_with {
451 chain.push_str(&format!(".endsWith(\"{}\")", p));
452 }
453 if let Some(ref p) = attrs.includes {
454 chain.push_str(&format!(".includes(\"{}\")", p));
455 }
456 chain
457}
458
459fn build_integer_schema(attrs: &ZodAttrs) -> String {
460 let mut chain = String::from("z.number().int()");
461 append_number_validators(&mut chain, attrs);
462 chain
463}
464
465fn build_float_schema(attrs: &ZodAttrs) -> String {
466 let mut chain = String::from("z.number()");
467 if attrs.int {
468 chain.push_str(".int()");
469 }
470 append_number_validators(&mut chain, attrs);
471 chain
472}
473
474fn append_number_validators(chain: &mut String, attrs: &ZodAttrs) {
475 if let Some(n) = attrs.min {
476 chain.push_str(&format!(".min({})", n));
477 }
478 if let Some(n) = attrs.max {
479 chain.push_str(&format!(".max({})", n));
480 }
481 if attrs.positive {
482 chain.push_str(".positive()");
483 }
484 if attrs.negative {
485 chain.push_str(".negative()");
486 }
487 if attrs.nonnegative {
488 chain.push_str(".nonnegative()");
489 }
490 if attrs.nonpositive {
491 chain.push_str(".nonpositive()");
492 }
493 if attrs.finite {
494 chain.push_str(".finite()");
495 }
496}
497
498fn is_option_type(ty: &syn::Type) -> bool {
503 try_extract_wrapper(ty, OPTION).is_some()
504}
505
506fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
507 try_extract_wrapper(ty, OPTION)?.first_type()
508}
509
510fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
517 if let Some(m) = try_extract_wrapper(ty, VEC)
519 && let Some(inner) = m.first_type()
520 && let Some(custom_name) = innermost_custom_name(inner)
521 {
522 let bare = custom_name.rsplit("::").next().unwrap_or(&custom_name);
523 return Some(format!("z.array({}Schema)", bare));
524 }
525
526 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
528 let types = m.all_types();
529 if types.len() == 2 {
530 let key_has_custom = innermost_custom_name(types[0]).is_some();
531 let val_has_custom = innermost_custom_name(types[1]).is_some();
532
533 if key_has_custom || val_has_custom {
534 let key_expr = type_to_zod_or_schema_ref(types[0]);
535 let val_expr = type_to_zod_or_schema_ref(types[1]);
536 return Some(format!("z.record({}, {})", key_expr, val_expr));
537 }
538 }
539 }
540
541 None
542}
543
544fn type_to_zod_or_schema_ref(ty: &syn::Type) -> String {
548 if let Some(custom) = innermost_custom_name(ty) {
549 let bare = custom.rsplit("::").next().unwrap_or(&custom);
550 format!("{}Schema", bare)
551 } else {
552 rust_type_to_zod(ty, &ZodAttrs::default())
553 }
554}
555
556fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
559 if let Some(m) = try_extract_wrapper(ty, VEC) {
561 return m.first_type().and_then(innermost_custom_name);
562 }
563 if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
565 if let Some(key) = m.first_type()
568 && let Some(name) = innermost_custom_name(key)
569 {
570 return Some(name);
571 }
572 if let Some(value) = m.nth_type(1) {
573 return innermost_custom_name(value);
574 }
575 return None;
576 }
577 if is_primitive(ty) {
578 return None;
579 }
580 if let syn::Type::Path(tp) = ty {
581 let segments: Vec<String> = tp
583 .path
584 .segments
585 .iter()
586 .map(|seg| seg.ident.to_string())
587 .collect();
588
589 if segments.is_empty() {
590 return None;
591 }
592
593 let last = segments.last().unwrap();
594 if last == "Value" {
596 return None;
597 }
598
599 return Some(segments.join("::"));
601 }
602 None
603}
604
605pub fn rust_type_to_ts_schema(raw: &str) -> String {
638 let raw = raw.replace(' ', "");
639
640 if raw.starts_with("Sse<") {
641 return "asyncIteratorObject(z.unknown() /* TODO: add #[derive(ZodTs)] to your stream event type */)".to_string();
642 }
643
644 let inner = if raw.starts_with("Result<") {
646 extract_first_generic_arg_string(&raw).unwrap_or(raw.clone())
647 } else {
648 raw.clone()
649 };
650
651 let inner = if inner.starts_with("Json<") && inner.ends_with('>') {
653 inner[5..inner.len() - 1].to_string()
654 } else {
655 inner
656 };
657
658 type_name_to_zod_ref(&inner)
659}
660
661fn type_name_to_zod_ref(type_name: &str) -> String {
663 match type_name {
664 "()" => "z.void()".to_string(),
665 "" => String::new(),
666 "String" | "str" => "z.string()".to_string(),
667 "bool" => "z.boolean()".to_string(),
668 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
669 | "usize" => "z.number().int()".to_string(),
670 "f32" | "f64" => "z.number()".to_string(),
671 "Uuid" => "z.uuid()".to_string(),
672 "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
673 "serde_json::Value" | "Value" => "z.record(z.string(), z.unknown())".to_string(),
674 _ if type_name.starts_with("Vec<") && type_name.ends_with('>') => {
675 let inner = &type_name[4..type_name.len() - 1];
676 format!("z.array({})", type_name_to_zod_ref(inner))
677 }
678 _ if type_name.starts_with("HashMap<") && type_name.ends_with('>') => {
679 let inner = &type_name[8..type_name.len() - 1];
680 let parts: Vec<&str> = inner.splitn(2, ',').collect();
682 if parts.len() == 2 {
683 let key_schema = type_name_to_zod_ref(parts[0].trim());
684 let value_schema = type_name_to_zod_ref(parts[1].trim());
685 format!("z.record({}, {})", key_schema, value_schema)
686 } else {
687 "z.record(z.string(), z.unknown())".to_string()
689 }
690 }
691 _ if type_name.starts_with("Option<") && type_name.ends_with('>') => {
692 let inner = &type_name[7..type_name.len() - 1];
693 format!("{}.optional()", type_name_to_zod_ref(inner))
694 }
695 _ => {
696 let base = type_name.rsplit("::").next().unwrap_or(type_name);
697 format!("{}Schema", base)
698 }
699 }
700}
701
702fn extract_first_generic_arg_string(type_str: &str) -> Option<String> {
706 let start = type_str.find('<')? + 1;
707 let mut depth = 0;
708 let mut end = start;
709
710 for (i, ch) in type_str[start..].char_indices() {
711 match ch {
712 '<' => depth += 1,
713 '>' if depth == 0 => {
714 end = start + i;
715 break;
716 }
717 '>' => depth -= 1,
718 ',' if depth == 0 => {
719 end = start + i;
720 break;
721 }
722 _ => {}
723 }
724 }
725
726 if end > start {
727 Some(type_str[start..end].to_string())
728 } else {
729 None
730 }
731}
732
733pub fn to_schema_name(rust_type: &str) -> String {
735 format!("{}Schema", base_type_name(rust_type))
736}
737
738pub fn base_type_name(rust_type: &str) -> String {
742 let mut base = rust_type.trim();
743
744 if base.starts_with("Result<")
745 && let Some(inner) = extract_first_generic_arg_string(base)
746 {
747 base = Box::leak(inner.into_boxed_str());
748 }
749 if base.starts_with("Json<") && base.ends_with('>') {
750 base = &base[5..base.len() - 1];
751 }
752 if base.starts_with("Vec<") && base.ends_with('>') {
753 base = &base[4..base.len() - 1];
754 }
755 if base.starts_with("Option<") && base.ends_with('>') {
756 base = &base[7..base.len() - 1];
757 }
758
759 base.rsplit("::").next().unwrap_or(base).to_string()
760}
761
762#[cfg(test)]
763mod runtime_conversion_tests {
764 use super::*;
765
766 #[test]
767 fn json_planet() {
768 assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
769 }
770
771 #[test]
772 fn json_vec_planet() {
773 assert_eq!(
774 rust_type_to_ts_schema("Json<Vec<Planet>>"),
775 "z.array(PlanetSchema)"
776 );
777 }
778
779 #[test]
780 fn result_json_planet() {
781 assert_eq!(
782 rust_type_to_ts_schema("Result<Json<Planet>, StatusCode>"),
783 "PlanetSchema"
784 );
785 }
786
787 #[test]
788 fn json_string() {
789 assert_eq!(rust_type_to_ts_schema("Json<String>"), "z.string()");
790 }
791
792 #[test]
793 fn unit_type() {
794 assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
795 }
796
797 #[test]
798 fn serde_json_value() {
799 assert_eq!(
800 rust_type_to_ts_schema("Json<serde_json::Value>"),
801 "z.record(z.string(), z.unknown())"
802 );
803 }
804
805 #[test]
806 fn schema_name_simple() {
807 assert_eq!(to_schema_name("Planet"), "PlanetSchema");
808 }
809
810 #[test]
811 fn schema_name_vec() {
812 assert_eq!(to_schema_name("Vec<Planet>"), "PlanetSchema");
813 }
814
815 #[test]
816 fn base_type_unwraps_wrappers() {
817 assert_eq!(base_type_name("Result<Json<Vec<Planet>>, E>"), "Planet");
818 assert_eq!(base_type_name("Json<Planet>"), "Planet");
819 assert_eq!(base_type_name("Vec<Planet>"), "Planet");
820 assert_eq!(base_type_name("Option<Planet>"), "Planet");
821 }
822
823 #[test]
824 fn base_type_strips_module_path() {
825 assert_eq!(base_type_name("models::Planet"), "Planet");
826 assert_eq!(base_type_name("crate::domain::Planet"), "Planet");
827 }
828
829 #[test]
830 fn hashmap_string_string() {
831 assert_eq!(
832 rust_type_to_ts_schema("HashMap<String, String>"),
833 "z.record(z.string(), z.string())"
834 );
835 }
836
837 #[test]
838 fn hashmap_with_custom_value() {
839 assert_eq!(
840 rust_type_to_ts_schema("HashMap<String, Planet>"),
841 "z.record(z.string(), PlanetSchema)"
842 );
843 }
844
845 #[test]
846 fn json_hashmap() {
847 assert_eq!(
848 rust_type_to_ts_schema("Json<HashMap<String, String>>"),
849 "z.record(z.string(), z.string())"
850 );
851 }
852
853 #[test]
854 fn vec_of_custom_type() {
855 let ty: syn::Type = syn::parse_str("Vec<Planet>").unwrap();
856 let expr = try_generate_container_with_custom_types(&ty);
857 assert_eq!(expr, Some("z.array(PlanetSchema)".to_string()));
858 }
859
860 #[test]
861 fn vec_of_primitive_returns_none() {
862 let ty: syn::Type = syn::parse_str("Vec<String>").unwrap();
863 let expr = try_generate_container_with_custom_types(&ty);
864 assert_eq!(expr, None);
865 }
866
867 #[test]
868 fn hashmap_with_custom_key() {
869 let ty: syn::Type = syn::parse_str("HashMap<Planet, String>").unwrap();
870 let expr = try_generate_container_with_custom_types(&ty);
871 assert_eq!(expr, Some("z.record(PlanetSchema, z.string())".to_string()));
872 }
873
874 #[test]
875 fn hashmap_fully_primitive_returns_none() {
876 let ty: syn::Type = syn::parse_str("HashMap<String, i32>").unwrap();
877 let expr = try_generate_container_with_custom_types(&ty);
878 assert_eq!(expr, None);
879 }
880}