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::{
15        HASHMAP, OPTION, VEC, extract_first_generic_arg_string, is_primitive, try_extract_wrapper,
16    },
17};
18
19// ---------------------------------------------------------------------------
20// Public entry point
21// ---------------------------------------------------------------------------
22
23/// Generate the `#[derive(ZodTs)]` expansion.
24pub 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
44// ---------------------------------------------------------------------------
45// Struct expansion
46// ---------------------------------------------------------------------------
47
48fn 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        // Check if this Option field has skip_serializing_if = "Option::is_none"
70        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        // Collect non-primitive custom types for dependency tracking
83        let custom = innermost_custom_name(base_ty);
84        if let Some(ref c) = custom {
85            // Store only the bare name for the dep list (last segment)
86            let bare = c.rsplit("::").next().unwrap_or(c.as_str());
87            dep_type_names.push(bare.to_string());
88        }
89
90        // Build FieldDef token: check containers with custom types first
91        let field_tok =
92            if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
93                // Vec<CustomType> or HashMap<K, V> with custom types
94                // Emit complete Zod expression with wrapper, not bare type_ref
95                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                // Bare custom type — emit as type_ref for resolution pass
115                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                // Primitive — compute the full Zod expression now.
127                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
159// ---------------------------------------------------------------------------
160// Enum expansion
161// ---------------------------------------------------------------------------
162
163fn 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    // Derive EnumRepr from serde container attributes
173    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        // Leak to &'static str for static storage
177        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
218// ---------------------------------------------------------------------------
219// Variant code generation — returns a VariantKind TokenStream
220// ---------------------------------------------------------------------------
221
222fn 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                // Multi-field tuple variants not yet supported — emit compile error
240                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                // Check if this Option field has skip_serializing_if = "Option::is_none"
265                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
320// ---------------------------------------------------------------------------
321// inventory::submit! emission
322// ---------------------------------------------------------------------------
323
324/// Emit the `inventory::submit!` block for a type.
325///
326/// `items`    — `FieldDef` tokens for structs, `VariantDef` tokens for enums.
327/// `is_enum`  — selects `SchemaDef::Enum` vs `SchemaDef::Object`.
328fn 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
357/// Emit the `inventory::submit!` block for an enum type.
358fn 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
385// ---------------------------------------------------------------------------
386// Type → Zod expression
387// ---------------------------------------------------------------------------
388
389/// Map a `syn::Type` to a Zod schema expression string.
390///
391/// Uses AST-based wrapper detection for `Option<T>` and `Vec<T>` —
392/// never string prefix matching.
393pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
394    // Option<T> — recurse on inner, then .optional()
395    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    // Vec<T>
403    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    // HashMap<K, V>
421    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            // Fallback for malformed HashMap
429            return "z.record(z.string(), z.unknown())".to_string();
430        }
431    }
432
433    // Primitives — match on the final path segment ident
434    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::Uuid → z.uuid()
445            "Uuid" => "z.uuid()".to_string(),
446            // chrono::DateTime<Utc> → z.iso.datetime()
447            "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
448            // serde_json::Value → z.record(z.string(), z.unknown())
449            "Value" => "z.record(z.string(), z.unknown())".to_string(),
450            // Custom type — reference its schema by name
451            other => format!("{}Schema", other),
452        };
453    }
454
455    // Unit type ()
456    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
465// ---------------------------------------------------------------------------
466// Schema builders
467// ---------------------------------------------------------------------------
468
469fn 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
540// ---------------------------------------------------------------------------
541// Type helpers — all AST-based, no string matching on type names
542// ---------------------------------------------------------------------------
543
544fn 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
552/// Generate a complete Zod expression for containers wrapping custom types.
553///
554/// Returns `Some("z.array(ItemSchema)")` for `Vec<Item>` where Item is custom.
555/// Returns `Some("z.record(z.string(), ItemSchema)")` for `HashMap<String, Item>`.
556/// Returns `None` for fully primitive containers (handled by rust_type_to_zod)
557/// or bare types (handled by type_ref resolution).
558fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
559    // Vec<T> where T is custom
560    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    // HashMap<K, V> where K or V is custom
569    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
586/// Convert a type to either a Zod primitive expression or a schema reference.
587///
588/// Used when building container expressions that mix primitives and custom types.
589fn 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
598/// Return the simple name of the innermost non-primitive, non-wrapper type,
599/// for dependency tracking in `dependent_types()`.
600fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
601    // Strip Vec<T>
602    if let Some(m) = try_extract_wrapper(ty, VEC) {
603        return m.first_type().and_then(innermost_custom_name);
604    }
605    // Strip HashMap<K, V> — check both K and V for custom types
606    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
607        // For HashMap, we need to check both key and value types
608        // Return the first custom type found
609        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        // Extract full path by joining all segments
624        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        // Exclude Value (serde_json) from dependency tracking
637        if last == "Value" {
638            return None;
639        }
640
641        // Return full path joined with ::
642        return Some(segments.join("::"));
643    }
644    None
645}
646
647// ---------------------------------------------------------------------------
648// Runtime type-to-zod conversion (for contract generation)
649// ---------------------------------------------------------------------------
650
651/// Convert a Rust type name string to a TypeScript Zod schema reference.
652///
653/// This is for runtime contract generation when you have type names as strings
654/// from handler metadata, not `syn::Type` ASTs. For compile-time AST-based
655/// conversion, use [`rust_type_to_zod`] instead.
656///
657/// # String-based parsing
658///
659/// This function uses string prefix/suffix matching because it operates on
660/// type name strings collected at link time via `inventory`. It handles:
661///
662/// - Wrapper unwrapping: `"Json<Planet>"` → `"PlanetSchema"`
663/// - Result unwrapping: `"Result<Json<Planet>, E>"` → `"PlanetSchema"`
664/// - Vec mapping: `"Json<Vec<Planet>>"` → `"z.array(PlanetSchema)"`
665/// - Primitive mapping: `"String"` → `"z.string()"`
666/// - SSE streams: `"Sse<...>"` → `"asyncIteratorObject(z.unknown())"`
667///
668/// # Examples
669///
670/// ```
671/// use rorpc_parse::codegen::rust_type_to_ts_schema;
672///
673/// assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
674/// assert_eq!(rust_type_to_ts_schema("Json<Vec<Planet>>"), "z.array(PlanetSchema)");
675/// assert_eq!(rust_type_to_ts_schema("Result<Json<Planet>, E>"), "PlanetSchema");
676/// assert_eq!(rust_type_to_ts_schema("String"), "z.string()");
677/// assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
678/// ```
679pub 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    // Unwrap Result<T, E> → T
687    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    // Unwrap Json<T> → T
694    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
703/// Map a primitive type name to its base Zod expression.
704///
705/// Returns `Some("z.string()")` for primitives, `None` for custom types.
706/// This is the single source of truth for primitive type mappings.
707pub(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
721/// Map a bare type name to its Zod schema reference.
722fn type_name_to_zod_ref(type_name: &str) -> String {
723    match type_name {
724        "()" => "z.void()".to_string(),
725        "" => String::new(),
726        _ => {
727            // Check primitives first
728            if let Some(zod) = primitive_zod_expr(type_name) {
729                return zod.to_string();
730            }
731
732            // Handle compound types
733            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                // Parse "K, V" from the HashMap generics
740                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                    // Fallback if we can't parse the generics
747                    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            // Custom type — reference its schema by name
756            let base = type_name.rsplit("::").next().unwrap_or(type_name);
757            format!("{}Schema", base)
758        }
759    }
760}
761
762/// Convert type name to schema constant name: `"Planet"` → `"PlanetSchema"`
763pub fn to_schema_name(rust_type: &str) -> String {
764    format!("{}Schema", base_type_name(rust_type))
765}
766
767/// Extract the base type name, stripping all wrappers.
768///
769/// `"Result<Json<Vec<Planet>>, E>"` → `"Planet"`
770pub 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}