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        let base_ty = if is_opt {
68            option_inner(&field.ty).unwrap_or(&field.ty)
69        } else {
70            &field.ty
71        };
72
73        // Collect non-primitive custom types for dependency tracking
74        let custom = innermost_custom_name(base_ty);
75        if let Some(ref c) = custom {
76            // Store only the bare name for the dep list (last segment)
77            let bare = c.rsplit("::").next().unwrap_or(c.as_str());
78            dep_type_names.push(bare.to_string());
79        }
80
81        // Build FieldDef token: check containers with custom types first
82        let field_tok =
83            if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
84                // Vec<CustomType> or HashMap<K, V> with custom types
85                // Emit complete Zod expression with wrapper, not bare type_ref
86                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                // Bare custom type — emit as type_ref for resolution pass
101                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                // Primitive — compute the full Zod expression now.
112                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
139// ---------------------------------------------------------------------------
140// Enum expansion
141// ---------------------------------------------------------------------------
142
143fn 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    // Derive EnumRepr from serde container attributes
153    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        // Leak to &'static str for static storage
157        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
198// ---------------------------------------------------------------------------
199// Variant code generation — returns a VariantKind TokenStream
200// ---------------------------------------------------------------------------
201
202fn 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                // Multi-field tuple variants not yet supported — emit Unknown
220                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
278// ---------------------------------------------------------------------------
279// inventory::submit! emission
280// ---------------------------------------------------------------------------
281
282/// Emit the `inventory::submit!` block for a type.
283///
284/// `items`    — `FieldDef` tokens for structs, `VariantDef` tokens for enums.
285/// `is_enum`  — selects `SchemaDef::Enum` vs `SchemaDef::Object`.
286fn 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
315/// Emit the `inventory::submit!` block for an enum type.
316fn 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
343// ---------------------------------------------------------------------------
344// Type → Zod expression
345// ---------------------------------------------------------------------------
346
347/// Map a `syn::Type` to a Zod schema expression string.
348///
349/// Uses AST-based wrapper detection for `Option<T>` and `Vec<T>` —
350/// never string prefix matching.
351pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
352    // Option<T> — recurse on inner, then .optional()
353    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    // Vec<T>
361    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    // HashMap<K, V>
379    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            // Fallback for malformed HashMap
387            return "z.record(z.string(), z.unknown())".to_string();
388        }
389    }
390
391    // Primitives — match on the final path segment ident
392    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::Uuid → z.uuid()
403            "Uuid" => "z.uuid()".to_string(),
404            // chrono::DateTime<Utc> → z.iso.datetime()
405            "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
406            // serde_json::Value → z.record(z.string(), z.unknown())
407            "Value" => "z.record(z.string(), z.unknown())".to_string(),
408            // Custom type — reference its schema by name
409            other => format!("{}Schema", other),
410        };
411    }
412
413    // Unit type ()
414    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
423// ---------------------------------------------------------------------------
424// Schema builders
425// ---------------------------------------------------------------------------
426
427fn 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
498// ---------------------------------------------------------------------------
499// Type helpers — all AST-based, no string matching on type names
500// ---------------------------------------------------------------------------
501
502fn 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
510/// Generate a complete Zod expression for containers wrapping custom types.
511///
512/// Returns `Some("z.array(ItemSchema)")` for `Vec<Item>` where Item is custom.
513/// Returns `Some("z.record(z.string(), ItemSchema)")` for `HashMap<String, Item>`.
514/// Returns `None` for fully primitive containers (handled by rust_type_to_zod)
515/// or bare types (handled by type_ref resolution).
516fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
517    // Vec<T> where T is custom
518    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    // HashMap<K, V> where K or V is custom
527    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
544/// Convert a type to either a Zod primitive expression or a schema reference.
545///
546/// Used when building container expressions that mix primitives and custom types.
547fn 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
556/// Return the simple name of the innermost non-primitive, non-wrapper type,
557/// for dependency tracking in `dependent_types()`.
558fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
559    // Strip Vec<T>
560    if let Some(m) = try_extract_wrapper(ty, VEC) {
561        return m.first_type().and_then(innermost_custom_name);
562    }
563    // Strip HashMap<K, V> — check both K and V for custom types
564    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
565        // For HashMap, we need to check both key and value types
566        // Return the first custom type found
567        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        // Extract full path by joining all segments
582        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        // Exclude Value (serde_json) from dependency tracking
595        if last == "Value" {
596            return None;
597        }
598
599        // Return full path joined with ::
600        return Some(segments.join("::"));
601    }
602    None
603}
604
605// ---------------------------------------------------------------------------
606// Runtime type-to-zod conversion (for contract generation)
607// ---------------------------------------------------------------------------
608
609/// Convert a Rust type name string to a TypeScript Zod schema reference.
610///
611/// This is for runtime contract generation when you have type names as strings
612/// from handler metadata, not `syn::Type` ASTs. For compile-time AST-based
613/// conversion, use [`rust_type_to_zod`] instead.
614///
615/// # String-based parsing
616///
617/// This function uses string prefix/suffix matching because it operates on
618/// type name strings collected at link time via `inventory`. It handles:
619///
620/// - Wrapper unwrapping: `"Json<Planet>"` → `"PlanetSchema"`
621/// - Result unwrapping: `"Result<Json<Planet>, E>"` → `"PlanetSchema"`
622/// - Vec mapping: `"Json<Vec<Planet>>"` → `"z.array(PlanetSchema)"`
623/// - Primitive mapping: `"String"` → `"z.string()"`
624/// - SSE streams: `"Sse<...>"` → `"asyncIteratorObject(z.unknown())"`
625///
626/// # Examples
627///
628/// ```
629/// use rorpc_parse::codegen::rust_type_to_ts_schema;
630///
631/// assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
632/// assert_eq!(rust_type_to_ts_schema("Json<Vec<Planet>>"), "z.array(PlanetSchema)");
633/// assert_eq!(rust_type_to_ts_schema("Result<Json<Planet>, E>"), "PlanetSchema");
634/// assert_eq!(rust_type_to_ts_schema("String"), "z.string()");
635/// assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
636/// ```
637pub 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    // Unwrap Result<T, E> → T
645    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    // Unwrap Json<T> → T
652    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
661/// Map a bare type name to its Zod schema reference.
662fn 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            // Parse "K, V" from the HashMap generics
681            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                // Fallback if we can't parse the generics
688                "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
702/// Extract the first generic argument from a type string.
703///
704/// `"Result<Json<Planet>, E>"` → `Some("Json<Planet>")`
705fn 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
733/// Convert type name to schema constant name: `"Planet"` → `"PlanetSchema"`
734pub fn to_schema_name(rust_type: &str) -> String {
735    format!("{}Schema", base_type_name(rust_type))
736}
737
738/// Extract the base type name, stripping all wrappers.
739///
740/// `"Result<Json<Vec<Planet>>, E>"` → `"Planet"`
741pub 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}