Skip to main content

rorpc_parse/codegen/
zod_ts.rs

1//! Code generation for `#[derive(ZodTs)]`.
2//!
3//! Generates a `fn zod_ts() -> String` method that returns a complete
4//! TypeScript block with a Zod schema and a `z.infer` type alias,
5//! plus an `inventory::submit!` for `SchemaRegistration`.
6
7use 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
17// ---------------------------------------------------------------------------
18// Public entry point
19// ---------------------------------------------------------------------------
20
21/// Generate the `#[derive(ZodTs)]` expansion.
22pub 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
42// ---------------------------------------------------------------------------
43// Struct expansion
44// ---------------------------------------------------------------------------
45
46fn 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        // Check if this Option field has skip_serializing_if = "Option::is_none"
68        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        // Collect non-primitive custom types for dependency tracking
80        let custom = innermost_custom_name(base_ty);
81        if let Some(ref c) = custom {
82            // Store only the bare name for the dep list (last segment)
83            let bare = c.rsplit("::").next().unwrap_or(c.as_str());
84            dep_type_names.push(bare.to_string());
85        }
86
87        // Build FieldDef token: check containers with custom types first
88        let field_tok =
89            if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
90                // Vec<CustomType> or HashMap<K, V> with custom types
91                // Emit complete Zod expression with wrapper, not bare type_ref
92                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                // Bare custom type — emit as type_ref for resolution pass
112                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                // Primitive — compute the full Zod expression now.
124                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
156// ---------------------------------------------------------------------------
157// Enum expansion
158// ---------------------------------------------------------------------------
159
160fn 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    // Derive EnumRepr from serde container attributes
170    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        // Leak to &'static str for static storage
174        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
215// ---------------------------------------------------------------------------
216// Variant code generation — returns a VariantKind TokenStream
217// ---------------------------------------------------------------------------
218
219fn 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                // Multi-field tuple variants not yet supported — emit Unknown
237                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                // Check if this Option field has skip_serializing_if = "Option::is_none"
254                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
308// ---------------------------------------------------------------------------
309// inventory::submit! emission
310// ---------------------------------------------------------------------------
311
312/// Emit the `inventory::submit!` block for a type.
313///
314/// `items`    — `FieldDef` tokens for structs, `VariantDef` tokens for enums.
315/// `is_enum`  — selects `SchemaDef::Enum` vs `SchemaDef::Object`.
316fn 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
345/// Emit the `inventory::submit!` block for an enum type.
346fn 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
373// ---------------------------------------------------------------------------
374// Type → Zod expression
375// ---------------------------------------------------------------------------
376
377/// Map a `syn::Type` to a Zod schema expression string.
378///
379/// Uses AST-based wrapper detection for `Option<T>` and `Vec<T>` —
380/// never string prefix matching.
381pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
382    // Option<T> — recurse on inner, then .optional()
383    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    // Vec<T>
391    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    // HashMap<K, V>
409    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            // Fallback for malformed HashMap
417            return "z.record(z.string(), z.unknown())".to_string();
418        }
419    }
420
421    // Primitives — match on the final path segment ident
422    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::Uuid → z.uuid()
433            "Uuid" => "z.uuid()".to_string(),
434            // chrono::DateTime<Utc> → z.iso.datetime()
435            "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
436            // serde_json::Value → z.record(z.string(), z.unknown())
437            "Value" => "z.record(z.string(), z.unknown())".to_string(),
438            // Custom type — reference its schema by name
439            other => format!("{}Schema", other),
440        };
441    }
442
443    // Unit type ()
444    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
453// ---------------------------------------------------------------------------
454// Schema builders
455// ---------------------------------------------------------------------------
456
457fn 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
528// ---------------------------------------------------------------------------
529// Type helpers — all AST-based, no string matching on type names
530// ---------------------------------------------------------------------------
531
532fn 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
540/// Generate a complete Zod expression for containers wrapping custom types.
541///
542/// Returns `Some("z.array(ItemSchema)")` for `Vec<Item>` where Item is custom.
543/// Returns `Some("z.record(z.string(), ItemSchema)")` for `HashMap<String, Item>`.
544/// Returns `None` for fully primitive containers (handled by rust_type_to_zod)
545/// or bare types (handled by type_ref resolution).
546fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
547    // Vec<T> where T is custom
548    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    // HashMap<K, V> where K or V is custom
557    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
574/// Convert a type to either a Zod primitive expression or a schema reference.
575///
576/// Used when building container expressions that mix primitives and custom types.
577fn 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
586/// Return the simple name of the innermost non-primitive, non-wrapper type,
587/// for dependency tracking in `dependent_types()`.
588fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
589    // Strip Vec<T>
590    if let Some(m) = try_extract_wrapper(ty, VEC) {
591        return m.first_type().and_then(innermost_custom_name);
592    }
593    // Strip HashMap<K, V> — check both K and V for custom types
594    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
595        // For HashMap, we need to check both key and value types
596        // Return the first custom type found
597        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        // Extract full path by joining all segments
612        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        // Exclude Value (serde_json) from dependency tracking
625        if last == "Value" {
626            return None;
627        }
628
629        // Return full path joined with ::
630        return Some(segments.join("::"));
631    }
632    None
633}
634
635// ---------------------------------------------------------------------------
636// Runtime type-to-zod conversion (for contract generation)
637// ---------------------------------------------------------------------------
638
639/// Convert a Rust type name string to a TypeScript Zod schema reference.
640///
641/// This is for runtime contract generation when you have type names as strings
642/// from handler metadata, not `syn::Type` ASTs. For compile-time AST-based
643/// conversion, use [`rust_type_to_zod`] instead.
644///
645/// # String-based parsing
646///
647/// This function uses string prefix/suffix matching because it operates on
648/// type name strings collected at link time via `inventory`. It handles:
649///
650/// - Wrapper unwrapping: `"Json<Planet>"` → `"PlanetSchema"`
651/// - Result unwrapping: `"Result<Json<Planet>, E>"` → `"PlanetSchema"`
652/// - Vec mapping: `"Json<Vec<Planet>>"` → `"z.array(PlanetSchema)"`
653/// - Primitive mapping: `"String"` → `"z.string()"`
654/// - SSE streams: `"Sse<...>"` → `"asyncIteratorObject(z.unknown())"`
655///
656/// # Examples
657///
658/// ```
659/// use rorpc_parse::codegen::rust_type_to_ts_schema;
660///
661/// assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
662/// assert_eq!(rust_type_to_ts_schema("Json<Vec<Planet>>"), "z.array(PlanetSchema)");
663/// assert_eq!(rust_type_to_ts_schema("Result<Json<Planet>, E>"), "PlanetSchema");
664/// assert_eq!(rust_type_to_ts_schema("String"), "z.string()");
665/// assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
666/// ```
667pub 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    // Unwrap Result<T, E> → T
675    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    // Unwrap Json<T> → T
682    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
691/// Map a bare type name to its Zod schema reference.
692fn 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            // Parse "K, V" from the HashMap generics
711            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                // Fallback if we can't parse the generics
718                "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
732/// Extract the first generic argument from a type string.
733///
734/// `"Result<Json<Planet>, E>"` → `Some("Json<Planet>")`
735fn 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
763/// Convert type name to schema constant name: `"Planet"` → `"PlanetSchema"`
764pub fn to_schema_name(rust_type: &str) -> String {
765    format!("{}Schema", base_type_name(rust_type))
766}
767
768/// Extract the base type name, stripping all wrappers.
769///
770/// `"Result<Json<Vec<Planet>>, E>"` → `"Planet"`
771pub 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}