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 = if let Some(container_expr) = try_generate_container_with_custom_types(base_ty) {
83            // Vec<CustomType> or HashMap<K, V> with custom types
84            // Emit complete Zod expression with wrapper, not bare type_ref
85            let zod_expr = if is_opt {
86                format!("{}.optional()", container_expr)
87            } else {
88                container_expr
89            };
90            quote! {
91                ::rorpc::FieldDef {
92                    ts_name:   #ts_key,
93                    zod_expr:  #zod_expr,
94                    type_ref:  "",
95                    optional:  #is_opt,
96                }
97            }
98        } else if let Some(ref type_ref) = custom {
99            // Bare custom type — emit as type_ref for resolution pass
100            let bare_ref = type_ref.rsplit("::").next().unwrap_or(type_ref.as_str());
101            quote! {
102                ::rorpc::FieldDef {
103                    ts_name:   #ts_key,
104                    zod_expr:  "",
105                    type_ref:  #bare_ref,
106                    optional:  #is_opt,
107                }
108            }
109        } else {
110            // Primitive — compute the full Zod expression now.
111            let zod_expr = rust_type_to_zod(base_ty, &zod);
112            let zod_expr = if is_opt {
113                format!("{}.optional()", zod_expr)
114            } else {
115                zod_expr
116            };
117            quote! {
118                ::rorpc::FieldDef {
119                    ts_name:   #ts_key,
120                    zod_expr:  #zod_expr,
121                    type_ref:  "",
122                    optional:  #is_opt,
123                }
124            }
125        };
126
127        field_tokens.push(field_tok);
128    }
129
130    Ok(emit_registration(
131        name,
132        name_str,
133        field_tokens,
134        &dep_type_names,
135    ))
136}
137
138// ---------------------------------------------------------------------------
139// Enum expansion
140// ---------------------------------------------------------------------------
141
142fn expand_enum(
143    name: &syn::Ident,
144    name_str: &str,
145    data: &syn::DataEnum,
146    input: &DeriveInput,
147) -> Result<TokenStream> {
148    let serde_container = parse_serde_attrs(&input.attrs)?;
149    let rename_all = serde_container.rename_all.as_deref();
150
151    let mut variant_tokens: Vec<TokenStream> = Vec::new();
152
153    for variant in &data.variants {
154        let serde_variant = parse_serde_attrs(&variant.attrs)?;
155        if serde_variant.skip {
156            continue;
157        }
158
159        let raw_name = variant.ident.to_string();
160        let variant_name = serde_variant
161            .rename
162            .as_deref()
163            .map(str::to_string)
164            .unwrap_or_else(|| {
165                rename_all
166                    .map(|rule| apply_rename_rule(rule, &raw_name))
167                    .unwrap_or(raw_name)
168            });
169
170        let kind_tok = generate_variant_def(&variant.fields)?;
171        variant_tokens.push(quote! {
172            ::rorpc::VariantDef {
173                serialized_name: #variant_name,
174                kind: #kind_tok,
175            }
176        });
177    }
178
179    Ok(emit_enum_registration(name, name_str, variant_tokens))
180}
181
182// ---------------------------------------------------------------------------
183// Variant code generation — returns a VariantKind TokenStream
184// ---------------------------------------------------------------------------
185
186fn generate_variant_def(fields: &Fields) -> Result<TokenStream> {
187    match fields {
188        Fields::Unit => Ok(quote! { ::rorpc::VariantKind::Unit }),
189
190        Fields::Unnamed(fields_unnamed) => {
191            let count = fields_unnamed.unnamed.len();
192            if count == 1 {
193                let field = fields_unnamed.unnamed.first().unwrap();
194                let zod = parse_zod_attrs(&field.attrs)?;
195                if let Some(custom) = innermost_custom_name(&field.ty) {
196                    let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
197                    Ok(quote! { ::rorpc::VariantKind::NewtypeRef { type_ref: #bare_ref } })
198                } else {
199                    let schema = rust_type_to_zod(&field.ty, &zod);
200                    Ok(quote! { ::rorpc::VariantKind::NewtypeZod { zod_expr: #schema } })
201                }
202            } else {
203                // Multi-field tuple variants not yet supported — emit Unknown
204                Ok(quote! { ::rorpc::VariantKind::Unit })
205            }
206        }
207
208        Fields::Named(fields_named) => {
209            let mut field_tokens: Vec<TokenStream> = Vec::new();
210            for field in &fields_named.named {
211                let field_name = field.ident.as_ref().unwrap().to_string();
212                let serde = parse_serde_attrs(&field.attrs)?;
213                if serde.skip {
214                    continue;
215                }
216                let ts_key = serde.rename.as_deref().unwrap_or(&field_name);
217                let zod_attrs = parse_zod_attrs(&field.attrs)?;
218                let is_opt = is_option_type(&field.ty);
219                let base_ty = if is_opt {
220                    option_inner(&field.ty).unwrap_or(&field.ty)
221                } else {
222                    &field.ty
223                };
224
225                let field_tok = if let Some(custom) = innermost_custom_name(base_ty) {
226                    let bare_ref = custom.rsplit("::").next().unwrap_or(custom.as_str());
227                    quote! {
228                        ::rorpc::FieldDef {
229                            ts_name:  #ts_key,
230                            zod_expr: "",
231                            type_ref: #bare_ref,
232                            optional: #is_opt,
233                        }
234                    }
235                } else {
236                    let zod_expr = rust_type_to_zod(base_ty, &zod_attrs);
237                    let zod_expr = if is_opt {
238                        format!("{}.optional()", zod_expr)
239                    } else {
240                        zod_expr
241                    };
242                    quote! {
243                        ::rorpc::FieldDef {
244                            ts_name:  #ts_key,
245                            zod_expr: #zod_expr,
246                            type_ref: "",
247                            optional: #is_opt,
248                        }
249                    }
250                };
251                field_tokens.push(field_tok);
252            }
253            Ok(quote! {
254                ::rorpc::VariantKind::Struct {
255                    fields: &[ #(#field_tokens),* ]
256                }
257            })
258        }
259    }
260}
261
262// ---------------------------------------------------------------------------
263// inventory::submit! emission
264// ---------------------------------------------------------------------------
265
266/// Emit the `inventory::submit!` block for a type.
267///
268/// `items`    — `FieldDef` tokens for structs, `VariantDef` tokens for enums.
269/// `is_enum`  — selects `SchemaDef::Enum` vs `SchemaDef::Object`.
270fn emit_registration(
271    name: &syn::Ident,
272    name_str: &str,
273    items: Vec<TokenStream>,
274    dep_type_names: &[String],
275) -> TokenStream {
276    let dep_strs: Vec<&str> = dep_type_names.iter().map(String::as_str).collect();
277
278    quote! {
279        impl #name {
280            pub fn dependent_types() -> Vec<&'static str> {
281                vec![#(#dep_strs),*]
282            }
283        }
284
285        const _: () = {
286            static __FIELDS: &[::rorpc::FieldDef] = &[ #(#items),* ];
287            ::rorpc::inventory::submit! {
288                ::rorpc::SchemaRegistration {
289                    type_name: #name_str,
290                    module_path: concat!(module_path!(), "::", #name_str),
291                    schema_def: ::rorpc::SchemaDef::Object { fields: __FIELDS },
292                    dependent_types: #name::dependent_types,
293                }
294            }
295        };
296    }
297}
298
299/// Emit the `inventory::submit!` block for an enum type.
300fn emit_enum_registration(
301    name: &syn::Ident,
302    name_str: &str,
303    items: Vec<TokenStream>,
304) -> TokenStream {
305    quote! {
306        impl #name {
307            pub fn dependent_types() -> Vec<&'static str> {
308                vec![]
309            }
310        }
311
312        const _: () = {
313            static __VARIANTS: &[::rorpc::VariantDef] = &[ #(#items),* ];
314            ::rorpc::inventory::submit! {
315                ::rorpc::SchemaRegistration {
316                    type_name: #name_str,
317                    module_path: concat!(module_path!(), "::", #name_str),
318                    schema_def: ::rorpc::SchemaDef::Enum { variants: __VARIANTS },
319                    dependent_types: #name::dependent_types,
320                }
321            }
322        };
323    }
324}
325
326// ---------------------------------------------------------------------------
327// Type → Zod expression
328// ---------------------------------------------------------------------------
329
330/// Map a `syn::Type` to a Zod schema expression string.
331///
332/// Uses AST-based wrapper detection for `Option<T>` and `Vec<T>` —
333/// never string prefix matching.
334pub fn rust_type_to_zod(ty: &syn::Type, attrs: &ZodAttrs) -> String {
335    // Option<T> — recurse on inner, then .optional()
336    if is_option_type(ty)
337        && let Some(inner) = option_inner(ty)
338    {
339        let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
340        return format!("{}.optional()", inner_schema);
341    }
342
343    // Vec<T>
344    if let Some(m) = try_extract_wrapper(ty, VEC)
345        && let Some(inner) = m.first_type()
346    {
347        let inner_schema = rust_type_to_zod(inner, &ZodAttrs::default());
348        let mut chain = format!("z.array({})", inner_schema);
349        if let Some(n) = attrs.length {
350            chain.push_str(&format!(".length({})", n));
351        }
352        if let Some(n) = attrs.min_length {
353            chain.push_str(&format!(".min({})", n));
354        }
355        if let Some(n) = attrs.max_length {
356            chain.push_str(&format!(".max({})", n));
357        }
358        return chain;
359    }
360
361    // HashMap<K, V>
362    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
363        let types = m.all_types();
364        if types.len() == 2 {
365            let key_schema = rust_type_to_zod(&types[0], &ZodAttrs::default());
366            let value_schema = rust_type_to_zod(&types[1], &ZodAttrs::default());
367            return format!("z.record({}, {})", key_schema, value_schema);
368        } else {
369            // Fallback for malformed HashMap
370            return "z.record(z.string(), z.unknown())".to_string();
371        }
372    }
373
374    // Primitives — match on the final path segment ident
375    if let syn::Type::Path(type_path) = ty
376        && let Some(seg) = type_path.path.segments.last()
377    {
378        let name = seg.ident.to_string();
379        return match name.as_str() {
380            "String" | "str" => build_string_schema(attrs),
381            "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64"
382            | "u128" | "usize" => build_integer_schema(attrs),
383            "f32" | "f64" => build_float_schema(attrs),
384            "bool" => "z.boolean()".to_string(),
385            // uuid::Uuid → z.uuid()
386            "Uuid" => "z.uuid()".to_string(),
387            // chrono::DateTime<Utc> → z.iso.datetime()
388            "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
389            // serde_json::Value → z.record(z.string(), z.unknown())
390            "Value" => "z.record(z.string(), z.unknown())".to_string(),
391            // Custom type — reference its schema by name
392            other => format!("{}Schema", other),
393        };
394    }
395
396    // Unit type ()
397    if let syn::Type::Tuple(t) = ty
398        && t.elems.is_empty()
399    {
400        return "z.void()".to_string();
401    }
402
403    "z.unknown()".to_string()
404}
405
406// ---------------------------------------------------------------------------
407// Schema builders
408// ---------------------------------------------------------------------------
409
410fn build_string_schema(attrs: &ZodAttrs) -> String {
411    let mut chain = String::from("z.string()");
412    if let Some(n) = attrs.length {
413        chain.push_str(&format!(".length({})", n));
414    }
415    if let Some(n) = attrs.min_length {
416        chain.push_str(&format!(".min({})", n));
417    }
418    if let Some(n) = attrs.max_length {
419        chain.push_str(&format!(".max({})", n));
420    }
421    if attrs.email {
422        chain.push_str(".email()");
423    }
424    if attrs.url {
425        chain.push_str(".url()");
426    }
427    if let Some(ref p) = attrs.regex {
428        chain.push_str(&format!(".regex(/{}/)", p));
429    }
430    if let Some(ref p) = attrs.starts_with {
431        chain.push_str(&format!(".startsWith(\"{}\")", p));
432    }
433    if let Some(ref p) = attrs.ends_with {
434        chain.push_str(&format!(".endsWith(\"{}\")", p));
435    }
436    if let Some(ref p) = attrs.includes {
437        chain.push_str(&format!(".includes(\"{}\")", p));
438    }
439    chain
440}
441
442fn build_integer_schema(attrs: &ZodAttrs) -> String {
443    let mut chain = String::from("z.number().int()");
444    append_number_validators(&mut chain, attrs);
445    chain
446}
447
448fn build_float_schema(attrs: &ZodAttrs) -> String {
449    let mut chain = String::from("z.number()");
450    if attrs.int {
451        chain.push_str(".int()");
452    }
453    append_number_validators(&mut chain, attrs);
454    chain
455}
456
457fn append_number_validators(chain: &mut String, attrs: &ZodAttrs) {
458    if let Some(n) = attrs.min {
459        chain.push_str(&format!(".min({})", n));
460    }
461    if let Some(n) = attrs.max {
462        chain.push_str(&format!(".max({})", n));
463    }
464    if attrs.positive {
465        chain.push_str(".positive()");
466    }
467    if attrs.negative {
468        chain.push_str(".negative()");
469    }
470    if attrs.nonnegative {
471        chain.push_str(".nonnegative()");
472    }
473    if attrs.nonpositive {
474        chain.push_str(".nonpositive()");
475    }
476    if attrs.finite {
477        chain.push_str(".finite()");
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Type helpers — all AST-based, no string matching on type names
483// ---------------------------------------------------------------------------
484
485fn is_option_type(ty: &syn::Type) -> bool {
486    try_extract_wrapper(ty, OPTION).is_some()
487}
488
489fn option_inner(ty: &syn::Type) -> Option<&syn::Type> {
490    try_extract_wrapper(ty, OPTION)?.first_type()
491}
492
493/// Generate a complete Zod expression for containers wrapping custom types.
494///
495/// Returns `Some("z.array(ItemSchema)")` for `Vec<Item>` where Item is custom.
496/// Returns `Some("z.record(z.string(), ItemSchema)")` for `HashMap<String, Item>`.
497/// Returns `None` for fully primitive containers (handled by rust_type_to_zod)
498/// or bare types (handled by type_ref resolution).
499fn try_generate_container_with_custom_types(ty: &syn::Type) -> Option<String> {
500    // Vec<T> where T is custom
501    if let Some(m) = try_extract_wrapper(ty, VEC) {
502        if let Some(inner) = m.first_type() {
503            if let Some(custom_name) = innermost_custom_name(inner) {
504                let bare = custom_name.rsplit("::").next().unwrap_or(&custom_name);
505                return Some(format!("z.array({}Schema)", bare));
506            }
507        }
508    }
509
510    // HashMap<K, V> where K or V is custom
511    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
512        let types = m.all_types();
513        if types.len() == 2 {
514            let key_has_custom = innermost_custom_name(&types[0]).is_some();
515            let val_has_custom = innermost_custom_name(&types[1]).is_some();
516
517            if key_has_custom || val_has_custom {
518                let key_expr = type_to_zod_or_schema_ref(&types[0]);
519                let val_expr = type_to_zod_or_schema_ref(&types[1]);
520                return Some(format!("z.record({}, {})", key_expr, val_expr));
521            }
522        }
523    }
524
525    None
526}
527
528/// Convert a type to either a Zod primitive expression or a schema reference.
529///
530/// Used when building container expressions that mix primitives and custom types.
531fn type_to_zod_or_schema_ref(ty: &syn::Type) -> String {
532    if let Some(custom) = innermost_custom_name(ty) {
533        let bare = custom.rsplit("::").next().unwrap_or(&custom);
534        format!("{}Schema", bare)
535    } else {
536        rust_type_to_zod(ty, &ZodAttrs::default())
537    }
538}
539
540/// Return the simple name of the innermost non-primitive, non-wrapper type,
541/// for dependency tracking in `dependent_types()`.
542fn innermost_custom_name(ty: &syn::Type) -> Option<String> {
543    // Strip Vec<T>
544    if let Some(m) = try_extract_wrapper(ty, VEC) {
545        return m.first_type().and_then(innermost_custom_name);
546    }
547    // Strip HashMap<K, V> — check both K and V for custom types
548    if let Some(m) = try_extract_wrapper(ty, HASHMAP) {
549        // For HashMap, we need to check both key and value types
550        // Return the first custom type found
551        if let Some(key) = m.first_type() {
552            if let Some(name) = innermost_custom_name(key) {
553                return Some(name);
554            }
555        }
556        if let Some(value) = m.nth_type(1) {
557            return innermost_custom_name(value);
558        }
559        return None;
560    }
561    if is_primitive(ty) {
562        return None;
563    }
564    if let syn::Type::Path(tp) = ty {
565        // Extract full path by joining all segments
566        let segments: Vec<String> = tp
567            .path
568            .segments
569            .iter()
570            .map(|seg| seg.ident.to_string())
571            .collect();
572
573        if segments.is_empty() {
574            return None;
575        }
576
577        let last = segments.last().unwrap();
578        // Exclude Value (serde_json) from dependency tracking
579        if last == "Value" {
580            return None;
581        }
582
583        // Return full path joined with ::
584        return Some(segments.join("::"));
585    }
586    None
587}
588
589// ---------------------------------------------------------------------------
590// Runtime type-to-zod conversion (for contract generation)
591// ---------------------------------------------------------------------------
592
593/// Convert a Rust type name string to a TypeScript Zod schema reference.
594///
595/// This is for runtime contract generation when you have type names as strings
596/// from handler metadata, not `syn::Type` ASTs. For compile-time AST-based
597/// conversion, use [`rust_type_to_zod`] instead.
598///
599/// # String-based parsing
600///
601/// This function uses string prefix/suffix matching because it operates on
602/// type name strings collected at link time via `inventory`. It handles:
603///
604/// - Wrapper unwrapping: `"Json<Planet>"` → `"PlanetSchema"`
605/// - Result unwrapping: `"Result<Json<Planet>, E>"` → `"PlanetSchema"`
606/// - Vec mapping: `"Json<Vec<Planet>>"` → `"z.array(PlanetSchema)"`
607/// - Primitive mapping: `"String"` → `"z.string()"`
608/// - SSE streams: `"Sse<...>"` → `"asyncIteratorObject(z.unknown())"`
609///
610/// # Examples
611///
612/// ```
613/// use rorpc_parse::codegen::rust_type_to_ts_schema;
614///
615/// assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
616/// assert_eq!(rust_type_to_ts_schema("Json<Vec<Planet>>"), "z.array(PlanetSchema)");
617/// assert_eq!(rust_type_to_ts_schema("Result<Json<Planet>, E>"), "PlanetSchema");
618/// assert_eq!(rust_type_to_ts_schema("String"), "z.string()");
619/// assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
620/// ```
621pub fn rust_type_to_ts_schema(raw: &str) -> String {
622    let raw = raw.replace(' ', "");
623
624    if raw.starts_with("Sse<") {
625        return "asyncIteratorObject(z.unknown() /* TODO: add #[derive(ZodTs)] to your stream event type */)".to_string();
626    }
627
628    // Unwrap Result<T, E> → T
629    let inner = if raw.starts_with("Result<") {
630        extract_first_generic_arg_string(&raw).unwrap_or(raw.clone())
631    } else {
632        raw.clone()
633    };
634
635    // Unwrap Json<T> → T
636    let inner = if inner.starts_with("Json<") && inner.ends_with('>') {
637        inner[5..inner.len() - 1].to_string()
638    } else {
639        inner
640    };
641
642    type_name_to_zod_ref(&inner)
643}
644
645/// Map a bare type name to its Zod schema reference.
646fn type_name_to_zod_ref(type_name: &str) -> String {
647    match type_name {
648        "()" => "z.void()".to_string(),
649        "" => String::new(),
650        "String" | "str" => "z.string()".to_string(),
651        "bool" => "z.boolean()".to_string(),
652        "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
653        | "usize" => "z.number().int()".to_string(),
654        "f32" | "f64" => "z.number()".to_string(),
655        "Uuid" => "z.uuid()".to_string(),
656        "DateTime" => "z.iso.datetime({ offset: true })".to_string(),
657        "serde_json::Value" | "Value" => "z.record(z.string(), z.unknown())".to_string(),
658        _ if type_name.starts_with("Vec<") && type_name.ends_with('>') => {
659            let inner = &type_name[4..type_name.len() - 1];
660            format!("z.array({})", type_name_to_zod_ref(inner))
661        }
662        _ if type_name.starts_with("HashMap<") && type_name.ends_with('>') => {
663            let inner = &type_name[8..type_name.len() - 1];
664            // Parse "K, V" from the HashMap generics
665            let parts: Vec<&str> = inner.splitn(2, ',').collect();
666            if parts.len() == 2 {
667                let key_schema = type_name_to_zod_ref(parts[0].trim());
668                let value_schema = type_name_to_zod_ref(parts[1].trim());
669                format!("z.record({}, {})", key_schema, value_schema)
670            } else {
671                // Fallback if we can't parse the generics
672                "z.record(z.string(), z.unknown())".to_string()
673            }
674        }
675        _ if type_name.starts_with("Option<") && type_name.ends_with('>') => {
676            let inner = &type_name[7..type_name.len() - 1];
677            format!("{}.optional()", type_name_to_zod_ref(inner))
678        }
679        _ => {
680            let base = type_name.rsplit("::").next().unwrap_or(type_name);
681            format!("{}Schema", base)
682        }
683    }
684}
685
686/// Extract the first generic argument from a type string.
687///
688/// `"Result<Json<Planet>, E>"` → `Some("Json<Planet>")`
689fn extract_first_generic_arg_string(type_str: &str) -> Option<String> {
690    let start = type_str.find('<')? + 1;
691    let mut depth = 0;
692    let mut end = start;
693
694    for (i, ch) in type_str[start..].char_indices() {
695        match ch {
696            '<' => depth += 1,
697            '>' if depth == 0 => {
698                end = start + i;
699                break;
700            }
701            '>' => depth -= 1,
702            ',' if depth == 0 => {
703                end = start + i;
704                break;
705            }
706            _ => {}
707        }
708    }
709
710    if end > start {
711        Some(type_str[start..end].to_string())
712    } else {
713        None
714    }
715}
716
717/// Convert type name to schema constant name: `"Planet"` → `"PlanetSchema"`
718pub fn to_schema_name(rust_type: &str) -> String {
719    format!("{}Schema", base_type_name(rust_type))
720}
721
722/// Extract the base type name, stripping all wrappers.
723///
724/// `"Result<Json<Vec<Planet>>, E>"` → `"Planet"`
725pub fn base_type_name(rust_type: &str) -> String {
726    let mut base = rust_type.trim();
727
728    if base.starts_with("Result<")
729        && let Some(inner) = extract_first_generic_arg_string(base)
730    {
731        base = Box::leak(inner.into_boxed_str());
732    }
733    if base.starts_with("Json<") && base.ends_with('>') {
734        base = &base[5..base.len() - 1];
735    }
736    if base.starts_with("Vec<") && base.ends_with('>') {
737        base = &base[4..base.len() - 1];
738    }
739    if base.starts_with("Option<") && base.ends_with('>') {
740        base = &base[7..base.len() - 1];
741    }
742
743    base.rsplit("::").next().unwrap_or(base).to_string()
744}
745
746#[cfg(test)]
747mod runtime_conversion_tests {
748    use super::*;
749
750    #[test]
751    fn json_planet() {
752        assert_eq!(rust_type_to_ts_schema("Json<Planet>"), "PlanetSchema");
753    }
754
755    #[test]
756    fn json_vec_planet() {
757        assert_eq!(
758            rust_type_to_ts_schema("Json<Vec<Planet>>"),
759            "z.array(PlanetSchema)"
760        );
761    }
762
763    #[test]
764    fn result_json_planet() {
765        assert_eq!(
766            rust_type_to_ts_schema("Result<Json<Planet>, StatusCode>"),
767            "PlanetSchema"
768        );
769    }
770
771    #[test]
772    fn json_string() {
773        assert_eq!(rust_type_to_ts_schema("Json<String>"), "z.string()");
774    }
775
776    #[test]
777    fn unit_type() {
778        assert_eq!(rust_type_to_ts_schema("()"), "z.void()");
779    }
780
781    #[test]
782    fn serde_json_value() {
783        assert_eq!(
784            rust_type_to_ts_schema("Json<serde_json::Value>"),
785            "z.record(z.string(), z.unknown())"
786        );
787    }
788
789    #[test]
790    fn schema_name_simple() {
791        assert_eq!(to_schema_name("Planet"), "PlanetSchema");
792    }
793
794    #[test]
795    fn schema_name_vec() {
796        assert_eq!(to_schema_name("Vec<Planet>"), "PlanetSchema");
797    }
798
799    #[test]
800    fn base_type_unwraps_wrappers() {
801        assert_eq!(base_type_name("Result<Json<Vec<Planet>>, E>"), "Planet");
802        assert_eq!(base_type_name("Json<Planet>"), "Planet");
803        assert_eq!(base_type_name("Vec<Planet>"), "Planet");
804        assert_eq!(base_type_name("Option<Planet>"), "Planet");
805    }
806
807    #[test]
808    fn base_type_strips_module_path() {
809        assert_eq!(base_type_name("models::Planet"), "Planet");
810        assert_eq!(base_type_name("crate::domain::Planet"), "Planet");
811    }
812
813    #[test]
814    fn hashmap_string_string() {
815        assert_eq!(
816            rust_type_to_ts_schema("HashMap<String, String>"),
817            "z.record(z.string(), z.string())"
818        );
819    }
820
821    #[test]
822    fn hashmap_with_custom_value() {
823        assert_eq!(
824            rust_type_to_ts_schema("HashMap<String, Planet>"),
825            "z.record(z.string(), PlanetSchema)"
826        );
827    }
828
829    #[test]
830    fn json_hashmap() {
831        assert_eq!(
832            rust_type_to_ts_schema("Json<HashMap<String, String>>"),
833            "z.record(z.string(), z.string())"
834        );
835    }
836
837    #[test]
838    fn vec_of_custom_type() {
839        let ty: syn::Type = syn::parse_str("Vec<Planet>").unwrap();
840        let expr = try_generate_container_with_custom_types(&ty);
841        assert_eq!(expr, Some("z.array(PlanetSchema)".to_string()));
842    }
843
844    #[test]
845    fn vec_of_primitive_returns_none() {
846        let ty: syn::Type = syn::parse_str("Vec<String>").unwrap();
847        let expr = try_generate_container_with_custom_types(&ty);
848        assert_eq!(expr, None);
849    }
850
851    #[test]
852    fn hashmap_with_custom_key() {
853        let ty: syn::Type = syn::parse_str("HashMap<Planet, String>").unwrap();
854        let expr = try_generate_container_with_custom_types(&ty);
855        assert_eq!(expr, Some("z.record(PlanetSchema, z.string())".to_string()));
856    }
857
858    #[test]
859    fn hashmap_fully_primitive_returns_none() {
860        let ty: syn::Type = syn::parse_str("HashMap<String, i32>").unwrap();
861        let expr = try_generate_container_with_custom_types(&ty);
862        assert_eq!(expr, None);
863    }
864}