Skip to main content

ferro_cli/commands/
generate_types.rs

1use console::style;
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::Path;
5use syn::visit::Visit;
6use syn::{Attribute, Fields, GenericArgument, ItemStruct, Meta, PathArguments, Type};
7use walkdir::WalkDir;
8
9/// Serde rename_all case transformation
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum SerdeCase {
12    #[default]
13    None,
14    CamelCase,
15    SnakeCase,
16    PascalCase,
17    ScreamingSnakeCase,
18    KebabCase,
19}
20
21impl SerdeCase {
22    /// Parse from serde attribute value
23    fn from_str(s: &str) -> Self {
24        match s {
25            "camelCase" => Self::CamelCase,
26            "snake_case" => Self::SnakeCase,
27            "PascalCase" => Self::PascalCase,
28            "SCREAMING_SNAKE_CASE" => Self::ScreamingSnakeCase,
29            "kebab-case" => Self::KebabCase,
30            _ => Self::None,
31        }
32    }
33
34    /// Apply case transformation to a field name
35    fn apply(&self, name: &str) -> String {
36        match self {
37            Self::None | Self::SnakeCase => name.to_string(),
38            Self::CamelCase => snake_to_camel(name),
39            Self::PascalCase => snake_to_pascal(name),
40            Self::ScreamingSnakeCase => name.to_uppercase(),
41            Self::KebabCase => name.replace('_', "-"),
42        }
43    }
44}
45
46/// Convert snake_case to camelCase
47fn snake_to_camel(s: &str) -> String {
48    let mut result = String::new();
49    let mut capitalize_next = false;
50
51    for c in s.chars() {
52        if c == '_' {
53            capitalize_next = true;
54        } else if capitalize_next {
55            result.push(c.to_ascii_uppercase());
56            capitalize_next = false;
57        } else {
58            result.push(c);
59        }
60    }
61
62    result
63}
64
65/// Convert snake_case to PascalCase
66fn snake_to_pascal(s: &str) -> String {
67    let mut result = String::new();
68    let mut capitalize_next = true;
69
70    for c in s.chars() {
71        if c == '_' {
72            capitalize_next = true;
73        } else if capitalize_next {
74            result.push(c.to_ascii_uppercase());
75            capitalize_next = false;
76        } else {
77            result.push(c);
78        }
79    }
80
81    result
82}
83
84/// Represents a parsed InertiaProps struct
85#[derive(Debug, Clone)]
86pub struct InertiaPropsStruct {
87    pub name: String,
88    pub fields: Vec<StructField>,
89    /// Serde rename_all attribute on the struct
90    pub rename_all: SerdeCase,
91    /// Module path where this struct is defined (e.g., "shelter::applications")
92    /// Used to generate unique namespaced TypeScript interface names
93    #[allow(dead_code)] // Will be used in namespaced interface generation (Task 2)
94    pub module_path: String,
95}
96
97#[derive(Debug, Clone)]
98pub struct StructField {
99    pub name: String,
100    pub ty: RustType,
101    /// Per-field serde rename override
102    pub serde_rename: Option<String>,
103}
104
105#[derive(Debug, Clone)]
106pub enum RustType {
107    String,
108    Number,
109    Bool,
110    DateTime,
111    /// serde_json::Value or sea_orm::Json - arbitrary JSON
112    JsonValue,
113    /// ferro::ValidationErrors - Record<string, string[]>
114    ValidationErrors,
115    Option(Box<RustType>),
116    Vec(Box<RustType>),
117    HashMap(Box<RustType>, Box<RustType>),
118    Custom(String),
119}
120
121/// Visitor that collects structs with #[derive(InertiaProps)]
122struct InertiaPropsVisitor {
123    structs: Vec<InertiaPropsStruct>,
124    /// Module path for the current file being scanned
125    module_path: String,
126}
127
128impl InertiaPropsVisitor {
129    fn new(module_path: String) -> Self {
130        Self {
131            structs: Vec::new(),
132            module_path,
133        }
134    }
135
136    fn has_inertia_props_derive(&self, attrs: &[Attribute]) -> bool {
137        for attr in attrs {
138            if attr.path().is_ident("derive") {
139                if let Ok(nested) = attr.parse_args_with(
140                    syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
141                ) {
142                    for path in nested {
143                        if path.is_ident("InertiaProps") {
144                            return true;
145                        }
146                        // Also check for ferro::InertiaProps
147                        if path.segments.len() == 2 {
148                            let first = &path.segments[0].ident;
149                            let second = &path.segments[1].ident;
150                            if first == "ferro" && second == "InertiaProps" {
151                                return true;
152                            }
153                        }
154                    }
155                }
156            }
157        }
158        false
159    }
160
161    /// Parse #[serde(rename_all = "...")] from struct attributes
162    fn parse_serde_rename_all(attrs: &[Attribute]) -> SerdeCase {
163        for attr in attrs {
164            if attr.path().is_ident("serde") {
165                if let Meta::List(meta_list) = &attr.meta {
166                    // Parse the token stream to find rename_all = "value"
167                    let tokens_str = meta_list.tokens.to_string();
168                    if let Some(rename_all) = parse_serde_rename_all_value(&tokens_str) {
169                        return SerdeCase::from_str(&rename_all);
170                    }
171                }
172            }
173        }
174        SerdeCase::None
175    }
176
177    /// Parse #[serde(rename = "...")] from field attributes
178    fn parse_serde_field_rename(attrs: &[Attribute]) -> Option<String> {
179        for attr in attrs {
180            if attr.path().is_ident("serde") {
181                if let Meta::List(meta_list) = &attr.meta {
182                    let tokens_str = meta_list.tokens.to_string();
183                    if let Some(rename) = parse_serde_rename_value(&tokens_str) {
184                        return Some(rename);
185                    }
186                }
187            }
188        }
189        None
190    }
191}
192
193/// Parse rename_all = "value" from serde attribute tokens
194fn parse_serde_rename_all_value(tokens: &str) -> Option<String> {
195    // Look for rename_all = "..."
196    if let Some(start) = tokens.find("rename_all") {
197        let rest = &tokens[start..];
198        // Find the value between quotes
199        if let Some(quote_start) = rest.find('"') {
200            let after_quote = &rest[quote_start + 1..];
201            if let Some(quote_end) = after_quote.find('"') {
202                return Some(after_quote[..quote_end].to_string());
203            }
204        }
205    }
206    None
207}
208
209/// Parse rename = "value" from serde attribute tokens (but not rename_all)
210fn parse_serde_rename_value(tokens: &str) -> Option<String> {
211    // Look for "rename" followed by "=" but not "rename_all"
212    let mut search_from = 0;
213    while let Some(pos) = tokens[search_from..].find("rename") {
214        let actual_pos = search_from + pos;
215        let rest = &tokens[actual_pos..];
216        // Check if it's "rename_all"
217        if rest.starts_with("rename_all") {
218            search_from = actual_pos + 10;
219            continue;
220        }
221        // Find the value between quotes after =
222        if let Some(eq_pos) = rest.find('=') {
223            let after_eq = &rest[eq_pos..];
224            if let Some(quote_start) = after_eq.find('"') {
225                let after_quote = &after_eq[quote_start + 1..];
226                if let Some(quote_end) = after_quote.find('"') {
227                    return Some(after_quote[..quote_end].to_string());
228                }
229            }
230        }
231        break;
232    }
233    None
234}
235
236impl InertiaPropsVisitor {
237    fn parse_type(ty: &Type) -> RustType {
238        match ty {
239            Type::Path(type_path) => {
240                let segment = type_path.path.segments.last().unwrap();
241                let ident = segment.ident.to_string();
242
243                match ident.as_str() {
244                    "String" | "str" => RustType::String,
245                    "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32"
246                    | "u64" | "u128" | "usize" | "f32" | "f64" => RustType::Number,
247                    "bool" => RustType::Bool,
248                    "Option" => {
249                        if let PathArguments::AngleBracketed(args) = &segment.arguments {
250                            if let Some(GenericArgument::Type(inner_ty)) = args.args.first() {
251                                return RustType::Option(Box::new(Self::parse_type(inner_ty)));
252                            }
253                        }
254                        RustType::Option(Box::new(RustType::Custom("unknown".to_string())))
255                    }
256                    "Vec" => {
257                        if let PathArguments::AngleBracketed(args) = &segment.arguments {
258                            if let Some(GenericArgument::Type(inner_ty)) = args.args.first() {
259                                return RustType::Vec(Box::new(Self::parse_type(inner_ty)));
260                            }
261                        }
262                        RustType::Vec(Box::new(RustType::Custom("unknown".to_string())))
263                    }
264                    "HashMap" | "BTreeMap" => {
265                        if let PathArguments::AngleBracketed(args) = &segment.arguments {
266                            let mut iter = args.args.iter();
267                            if let (
268                                Some(GenericArgument::Type(key_ty)),
269                                Some(GenericArgument::Type(val_ty)),
270                            ) = (iter.next(), iter.next())
271                            {
272                                return RustType::HashMap(
273                                    Box::new(Self::parse_type(key_ty)),
274                                    Box::new(Self::parse_type(val_ty)),
275                                );
276                            }
277                        }
278                        RustType::HashMap(
279                            Box::new(RustType::String),
280                            Box::new(RustType::Custom("unknown".to_string())),
281                        )
282                    }
283                    // serde_json::Value and sea_orm::Json map to JsonValue
284                    "Value" | "Json" => RustType::JsonValue,
285                    // ferro::ValidationErrors or ValidationErrors
286                    "ValidationErrors" => RustType::ValidationErrors,
287                    // Chrono datetime types serialize to ISO8601 strings
288                    "DateTime" | "NaiveDateTime" | "NaiveDate" | "NaiveTime" | "DateTimeUtc"
289                    | "DateTimeLocal" | "Date" | "Time" => RustType::DateTime,
290                    other => RustType::Custom(other.to_string()),
291                }
292            }
293            Type::Reference(type_ref) => {
294                // Handle &str as String
295                if let Type::Path(inner) = &*type_ref.elem {
296                    if inner
297                        .path
298                        .segments
299                        .last()
300                        .map(|s| s.ident == "str")
301                        .unwrap_or(false)
302                    {
303                        return RustType::String;
304                    }
305                }
306                Self::parse_type(&type_ref.elem)
307            }
308            _ => RustType::Custom("unknown".to_string()),
309        }
310    }
311}
312
313impl<'ast> Visit<'ast> for InertiaPropsVisitor {
314    fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
315        if self.has_inertia_props_derive(&node.attrs) {
316            let name = node.ident.to_string();
317            let rename_all = Self::parse_serde_rename_all(&node.attrs);
318
319            let fields = match &node.fields {
320                Fields::Named(named) => named
321                    .named
322                    .iter()
323                    .filter_map(|f| {
324                        f.ident.as_ref().map(|ident| StructField {
325                            name: ident.to_string(),
326                            ty: Self::parse_type(&f.ty),
327                            serde_rename: Self::parse_serde_field_rename(&f.attrs),
328                        })
329                    })
330                    .collect(),
331                _ => Vec::new(),
332            };
333
334            self.structs.push(InertiaPropsStruct {
335                name,
336                fields,
337                rename_all,
338                module_path: self.module_path.clone(),
339            });
340        }
341
342        // Continue visiting nested items
343        syn::visit::visit_item_struct(self, node);
344    }
345}
346
347/// Visitor that collects structs with #[derive(Serialize)] matching target type names
348struct SerializeStructVisitor {
349    /// Target type names to find
350    target_types: HashSet<String>,
351    /// Found structs matching target types
352    structs: Vec<InertiaPropsStruct>,
353    /// Module path for the current file being scanned
354    module_path: String,
355}
356
357impl SerializeStructVisitor {
358    fn new(target_types: HashSet<String>, module_path: String) -> Self {
359        Self {
360            target_types,
361            structs: Vec::new(),
362            module_path,
363        }
364    }
365
366    /// Check if struct has Serialize derive (but not InertiaProps which is handled separately)
367    fn has_serialize_derive(&self, attrs: &[Attribute]) -> bool {
368        for attr in attrs {
369            if attr.path().is_ident("derive") {
370                if let Ok(nested) = attr.parse_args_with(
371                    syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
372                ) {
373                    for path in nested {
374                        // Check for Serialize
375                        if path.is_ident("Serialize") {
376                            return true;
377                        }
378                        // Check for serde::Serialize
379                        if path.segments.len() == 2 {
380                            let first = &path.segments[0].ident;
381                            let second = &path.segments[1].ident;
382                            if first == "serde" && second == "Serialize" {
383                                return true;
384                            }
385                        }
386                    }
387                }
388            }
389        }
390        false
391    }
392}
393
394impl<'ast> Visit<'ast> for SerializeStructVisitor {
395    fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
396        let name = node.ident.to_string();
397
398        // Only process if this is a target type and has Serialize derive
399        if self.target_types.contains(&name) && self.has_serialize_derive(&node.attrs) {
400            let rename_all = InertiaPropsVisitor::parse_serde_rename_all(&node.attrs);
401
402            let fields = match &node.fields {
403                Fields::Named(named) => named
404                    .named
405                    .iter()
406                    .filter_map(|f| {
407                        f.ident.as_ref().map(|ident| StructField {
408                            name: ident.to_string(),
409                            ty: InertiaPropsVisitor::parse_type(&f.ty),
410                            serde_rename: InertiaPropsVisitor::parse_serde_field_rename(&f.attrs),
411                        })
412                    })
413                    .collect(),
414                _ => Vec::new(),
415            };
416
417            self.structs.push(InertiaPropsStruct {
418                name,
419                fields,
420                rename_all,
421                module_path: self.module_path.clone(),
422            });
423        }
424
425        // Continue visiting nested items
426        syn::visit::visit_item_struct(self, node);
427    }
428}
429
430/// Compute module path from file path relative to src directory.
431///
432/// Strips "src/" prefix and ".rs" extension, removes "controllers::" prefix if present,
433/// and converts path separators to "::".
434///
435/// Examples:
436/// - "src/controllers/shelter/applications.rs" -> "shelter::applications"
437/// - "src/controllers/user.rs" -> "user"
438/// - "src/models/animal.rs" -> "models::animal"
439fn compute_module_path(file_path: &Path, src_path: &Path) -> String {
440    let relative = file_path
441        .strip_prefix(src_path)
442        .unwrap_or(file_path)
443        .with_extension("");
444
445    let path_str = relative
446        .to_string_lossy()
447        .replace(std::path::MAIN_SEPARATOR, "::");
448
449    // Remove "mod" suffix if the file is mod.rs
450    let path_str = path_str.strip_suffix("::mod").unwrap_or(&path_str);
451
452    // Strip "controllers::" prefix for cleaner namespacing
453    path_str
454        .strip_prefix("controllers::")
455        .unwrap_or(path_str)
456        .to_string()
457}
458
459/// Generate a unique namespaced TypeScript interface name from module path and struct name.
460///
461/// Combines the module path with the struct name using PascalCase.
462/// Skips the prefix when the struct name already starts with it to avoid
463/// redundant names like `MenuMenuListProps`.
464///
465/// Examples:
466/// - ("shelter::applications", "ShowProps") -> "ShelterApplicationsShowProps"
467/// - ("adopter::applications", "ShowProps") -> "AdopterApplicationsShowProps"
468/// - ("user", "IndexProps") -> "UserIndexProps"
469/// - ("menu", "MenuListProps") -> "MenuListProps" (already prefixed)
470/// - ("public::menu", "PublicMenuProps") -> "PublicMenuProps" (already prefixed)
471/// - ("", "GlobalProps") -> "GlobalProps" (root-level, no namespace)
472fn generate_namespaced_name(module_path: &str, struct_name: &str) -> String {
473    if module_path.is_empty() {
474        return struct_name.to_string();
475    }
476
477    // Convert module path segments to PascalCase and join
478    let namespace: String = module_path.split("::").map(snake_to_pascal).collect();
479
480    // Skip prefix if struct name already starts with the namespace
481    // (case-insensitive to handle QRCode vs Qrcode, etc.)
482    if struct_name
483        .to_lowercase()
484        .starts_with(&namespace.to_lowercase())
485    {
486        return struct_name.to_string();
487    }
488
489    format!("{namespace}{struct_name}")
490}
491
492/// Scan all Rust files for Serialize structs matching the target type names
493pub fn scan_serialize_structs(
494    project_path: &Path,
495    target_types: &HashSet<String>,
496) -> Vec<InertiaPropsStruct> {
497    if target_types.is_empty() {
498        return Vec::new();
499    }
500
501    let src_path = project_path.join("src");
502    let mut all_structs = Vec::new();
503
504    for entry in WalkDir::new(&src_path)
505        .into_iter()
506        .filter_map(|e| e.ok())
507        .filter(|e| e.path().extension().map(|ext| ext == "rs").unwrap_or(false))
508    {
509        if let Ok(content) = fs::read_to_string(entry.path()) {
510            if let Ok(syntax) = syn::parse_file(&content) {
511                let module_path = compute_module_path(entry.path(), &src_path);
512                let mut visitor = SerializeStructVisitor::new(target_types.clone(), module_path);
513                visitor.visit_file(&syntax);
514                all_structs.extend(visitor.structs);
515            }
516        }
517    }
518
519    all_structs
520}
521
522/// Scan all Rust files in the src directory for InertiaProps structs
523pub fn scan_inertia_props(project_path: &Path) -> Vec<InertiaPropsStruct> {
524    let src_path = project_path.join("src");
525    let mut all_structs = Vec::new();
526
527    for entry in WalkDir::new(&src_path)
528        .into_iter()
529        .filter_map(|e| e.ok())
530        .filter(|e| e.path().extension().map(|ext| ext == "rs").unwrap_or(false))
531    {
532        if let Ok(content) = fs::read_to_string(entry.path()) {
533            if let Ok(syntax) = syn::parse_file(&content) {
534                let module_path = compute_module_path(entry.path(), &src_path);
535                let mut visitor = InertiaPropsVisitor::new(module_path);
536                visitor.visit_file(&syntax);
537                all_structs.extend(visitor.structs);
538            }
539        }
540    }
541
542    all_structs
543}
544
545/// Sort structs topologically so dependencies come first
546fn topological_sort(structs: &[InertiaPropsStruct]) -> Vec<&InertiaPropsStruct> {
547    let struct_map: HashMap<_, _> = structs.iter().map(|s| (s.name.clone(), s)).collect();
548    let struct_names: HashSet<_> = structs.iter().map(|s| s.name.clone()).collect();
549
550    // Build dependency graph
551    let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
552    for s in structs {
553        let mut s_deps = HashSet::new();
554        for field in &s.fields {
555            collect_type_deps(&field.ty, &mut s_deps, &struct_names);
556        }
557        deps.insert(s.name.clone(), s_deps);
558    }
559
560    // Kahn's algorithm for topological sort
561    let mut in_degree: HashMap<String, usize> =
562        struct_names.iter().map(|n| (n.clone(), 0)).collect();
563    for s_deps in deps.values() {
564        for dep in s_deps {
565            if let Some(count) = in_degree.get_mut(dep) {
566                *count += 1;
567            }
568        }
569    }
570
571    let mut queue: Vec<_> = in_degree
572        .iter()
573        .filter(|(_, &count)| count == 0)
574        .map(|(name, _)| name.clone())
575        .collect();
576    let mut result = Vec::new();
577
578    while let Some(name) = queue.pop() {
579        if let Some(s) = struct_map.get(&name) {
580            result.push(*s);
581        }
582        if let Some(s_deps) = deps.get(&name) {
583            for dep in s_deps {
584                if let Some(count) = in_degree.get_mut(dep) {
585                    *count = count.saturating_sub(1);
586                    if *count == 0 {
587                        queue.push(dep.clone());
588                    }
589                }
590            }
591        }
592    }
593
594    result
595}
596
597fn collect_type_deps(ty: &RustType, deps: &mut HashSet<String>, known: &HashSet<String>) {
598    match ty {
599        RustType::Custom(name) if known.contains(name) => {
600            deps.insert(name.clone());
601        }
602        RustType::Option(inner) | RustType::Vec(inner) => {
603            collect_type_deps(inner, deps, known);
604        }
605        RustType::HashMap(key, val) => {
606            collect_type_deps(key, deps, known);
607            collect_type_deps(val, deps, known);
608        }
609        _ => {}
610    }
611}
612
613/// Parse shared.ts to find exported type names
614/// Returns set of exported type/interface/enum names
615pub fn parse_shared_types(project_path: &Path) -> HashSet<String> {
616    let shared_path = project_path.join("frontend/src/types/shared.ts");
617
618    if !shared_path.exists() {
619        return HashSet::new();
620    }
621
622    let content = match fs::read_to_string(&shared_path) {
623        Ok(c) => c,
624        Err(_) => return HashSet::new(),
625    };
626
627    let mut types = HashSet::new();
628
629    // Match: export interface Name, export type Name, export enum Name
630    let patterns = [
631        r"export\s+interface\s+(\w+)",
632        r"export\s+type\s+(\w+)",
633        r"export\s+enum\s+(\w+)",
634    ];
635
636    for pattern in patterns {
637        if let Ok(re) = regex::Regex::new(pattern) {
638            for cap in re.captures_iter(&content) {
639                if let Some(name) = cap.get(1) {
640                    types.insert(name.as_str().to_string());
641                }
642            }
643        }
644    }
645
646    types
647}
648
649/// Collect all custom types referenced in InertiaProps structs
650fn collect_referenced_types(structs: &[InertiaPropsStruct]) -> HashSet<String> {
651    let mut types = HashSet::new();
652
653    for s in structs {
654        for field in &s.fields {
655            collect_custom_types(&field.ty, &mut types);
656        }
657    }
658
659    types
660}
661
662/// Recursively collect custom type names from a RustType
663fn collect_custom_types(ty: &RustType, types: &mut HashSet<String>) {
664    match ty {
665        RustType::Custom(name) => {
666            types.insert(name.clone());
667        }
668        RustType::Option(inner) | RustType::Vec(inner) => {
669            collect_custom_types(inner, types);
670        }
671        RustType::HashMap(key, val) => {
672            collect_custom_types(key, types);
673            collect_custom_types(val, types);
674        }
675        _ => {}
676    }
677}
678
679/// Generate TypeScript interfaces from the structs
680/// Apply serde renaming to get the final TypeScript field name
681fn apply_field_rename(field: &StructField, rename_all: SerdeCase) -> String {
682    // Per-field rename takes precedence over rename_all
683    if let Some(ref rename) = field.serde_rename {
684        return rename.clone();
685    }
686    // Apply struct-level rename_all
687    rename_all.apply(&field.name)
688}
689
690/// Generate TypeScript interfaces from the structs (self-contained, no shared.ts imports)
691pub fn generate_typescript(structs: &[InertiaPropsStruct]) -> String {
692    let sorted = topological_sort(structs);
693
694    // Build name mapping: original name -> namespaced name
695    // Also check for collisions
696    let name_map = build_name_map(structs);
697
698    let mut output = String::new();
699
700    // Header with instructions
701    output.push_str(
702        "// =============================================================================\n",
703    );
704    output.push_str("// Auto-generated by `ferro generate-types`\n");
705    output.push_str("// Do not edit manually - changes will be overwritten\n");
706    output.push_str("//\n");
707    output.push_str("// To regenerate: ferro generate-types\n");
708    output.push_str("// Auto-watch: ferro serve (types regenerate on file changes)\n");
709    output.push_str("//\n");
710    output.push_str("// For custom types not generated here, create manual type files in:\n");
711    output.push_str("// frontend/src/types/\n");
712    output.push_str(
713        "// =============================================================================\n\n",
714    );
715
716    // Utility types
717    output.push_str("// Utility types\n\n");
718    output.push_str("/** Represents any valid JSON value */\n");
719    output.push_str("export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\n\n");
720    output.push_str("/** Validation error messages keyed by field name */\n");
721    output.push_str("export type ValidationErrors = Record<string, string[]>;\n\n");
722
723    for s in sorted {
724        let interface_name = name_map.get(&s.name).unwrap_or(&s.name);
725        output.push_str(&format!("export interface {interface_name} {{\n"));
726        for field in &s.fields {
727            let ts_type = rust_type_to_ts_with_mapping(&field.ty, &name_map);
728            let ts_name = apply_field_rename(field, s.rename_all);
729            output.push_str(&format!("  {ts_name}: {ts_type};\n"));
730        }
731        output.push_str("}\n\n");
732    }
733
734    output
735}
736
737/// Build a mapping from original struct name to namespaced TypeScript name.
738/// Detects collisions where two different structs would produce the same namespaced name.
739fn build_name_map(structs: &[InertiaPropsStruct]) -> HashMap<String, String> {
740    let mut name_map = HashMap::new();
741    let mut reverse_map: HashMap<String, Vec<(String, String)>> = HashMap::new(); // namespaced -> [(original, module_path), ...]
742
743    for s in structs {
744        let namespaced = generate_namespaced_name(&s.module_path, &s.name);
745        name_map.insert(s.name.clone(), namespaced.clone());
746        reverse_map
747            .entry(namespaced)
748            .or_default()
749            .push((s.name.clone(), s.module_path.clone()));
750    }
751
752    // Check for collisions
753    for (namespaced, sources) in &reverse_map {
754        if sources.len() > 1 {
755            let collision_info: Vec<String> = sources
756                .iter()
757                .map(|(name, path)| format!("{path}::{name}"))
758                .collect();
759            eprintln!(
760                "Warning: TypeScript name collision detected for '{}'. Sources: {}",
761                namespaced,
762                collision_info.join(", ")
763            );
764        }
765    }
766
767    name_map
768}
769
770/// Convert RustType to TypeScript type string, applying name mapping for custom types
771fn rust_type_to_ts_with_mapping(ty: &RustType, name_map: &HashMap<String, String>) -> String {
772    match ty {
773        RustType::String => "string".to_string(),
774        RustType::Number => "number".to_string(),
775        RustType::Bool => "boolean".to_string(),
776        RustType::DateTime => "string".to_string(),
777        RustType::JsonValue => "JsonValue".to_string(),
778        RustType::ValidationErrors => "ValidationErrors".to_string(),
779        RustType::Option(inner) => {
780            format!("{} | null", rust_type_to_ts_with_mapping(inner, name_map))
781        }
782        RustType::Vec(inner) => format!("{}[]", rust_type_to_ts_with_mapping(inner, name_map)),
783        RustType::HashMap(k, v) => format!(
784            "Record<{}, {}>",
785            rust_type_to_ts_with_mapping(k, name_map),
786            rust_type_to_ts_with_mapping(v, name_map)
787        ),
788        RustType::Custom(name) => {
789            // Apply name mapping if this is a type we've defined
790            name_map.get(name).cloned().unwrap_or_else(|| name.clone())
791        }
792    }
793}
794
795/// Resolve all nested types referenced by InertiaProps structs
796///
797/// This function recursively finds all types referenced by the initial structs,
798/// scans for their Serialize definitions, and returns them.
799pub fn resolve_nested_types(
800    project_path: &Path,
801    initial_structs: &[InertiaPropsStruct],
802    shared_types: &HashSet<String>,
803) -> Vec<InertiaPropsStruct> {
804    let mut known_types: HashSet<String> = initial_structs.iter().map(|s| s.name.clone()).collect();
805    let mut all_nested = Vec::new();
806    let mut types_to_find: HashSet<String> = collect_referenced_types(initial_structs);
807
808    // Filter out types we already know about (initial structs and shared.ts types)
809    types_to_find.retain(|t| !known_types.contains(t) && !shared_types.contains(t));
810
811    // Fixed-point iteration: keep looking for nested types until none are found
812    while !types_to_find.is_empty() {
813        let found = scan_serialize_structs(project_path, &types_to_find);
814
815        if found.is_empty() {
816            // No more types could be resolved - remaining types are unknown
817            // We could emit warnings here if needed
818            break;
819        }
820
821        // Mark found types as known
822        for s in &found {
823            known_types.insert(s.name.clone());
824        }
825
826        // Collect types referenced by newly found structs
827        let mut next_types = collect_referenced_types(&found);
828        next_types.retain(|t| !known_types.contains(t) && !shared_types.contains(t));
829
830        all_nested.extend(found);
831        types_to_find = next_types;
832    }
833
834    all_nested
835}
836
837/// Generate types and write to the output file
838pub fn generate_types_to_file(project_path: &Path, output_path: &Path) -> Result<usize, String> {
839    let mut structs = scan_inertia_props(project_path);
840
841    if structs.is_empty() {
842        return Ok(0);
843    }
844
845    // Parse shared.ts types (used to avoid regenerating types already in shared.ts)
846    let shared_types = parse_shared_types(project_path);
847
848    // Resolve nested types
849    let nested_types = resolve_nested_types(project_path, &structs, &shared_types);
850    structs.extend(nested_types);
851
852    // Ensure output directory exists
853    if let Some(parent) = output_path.parent() {
854        fs::create_dir_all(parent)
855            .map_err(|e| format!("Failed to create output directory: {e}"))?;
856    }
857
858    // Generate self-contained TypeScript (no shared.ts imports/re-exports)
859    let typescript = generate_typescript(&structs);
860    fs::write(output_path, typescript)
861        .map_err(|e| format!("Failed to write TypeScript file: {e}"))?;
862
863    Ok(structs.len())
864}
865
866/// Main entry point for the generate-types command
867pub fn run(output: Option<String>, watch: bool) {
868    let project_path = Path::new(".");
869
870    // Validate Ferro project
871    let cargo_toml = project_path.join("Cargo.toml");
872    if !cargo_toml.exists() {
873        eprintln!(
874            "{} Not a Ferro project (no Cargo.toml found)",
875            style("Error:").red().bold()
876        );
877        std::process::exit(1);
878    }
879
880    let output_path = output
881        .map(std::path::PathBuf::from)
882        .unwrap_or_else(|| project_path.join("frontend/src/types/inertia-props.ts"));
883
884    println!("{}", style("Scanning for InertiaProps structs...").cyan());
885
886    match generate_types_to_file(project_path, &output_path) {
887        Ok(0) => {
888            println!("{}", style("No InertiaProps structs found.").yellow());
889        }
890        Ok(count) => {
891            println!(
892                "{} Found {} InertiaProps struct(s)",
893                style("->").green(),
894                count
895            );
896            println!("{} Generated {}", style("✓").green(), output_path.display());
897        }
898        Err(e) => {
899            eprintln!("{} {}", style("Error:").red().bold(), e);
900            std::process::exit(1);
901        }
902    }
903
904    // Also generate route types
905    generate_route_types(project_path);
906
907    if watch {
908        println!("{}", style("Watching for changes...").dim());
909        if let Err(e) = start_watcher(project_path, &output_path) {
910            eprintln!(
911                "{} Failed to start watcher: {}",
912                style("Error:").red().bold(),
913                e
914            );
915            std::process::exit(1);
916        }
917    }
918}
919
920/// Generate route types
921fn generate_route_types(project_path: &Path) {
922    let routes_output = project_path.join("frontend/src/types/routes.ts");
923
924    println!(
925        "{}",
926        style("Scanning routes for type-safe generation...").cyan()
927    );
928
929    match super::generate_routes::generate_routes_to_file(project_path, &routes_output) {
930        Ok(0) => {
931            println!("{}", style("No routes found in src/routes.rs").yellow());
932        }
933        Ok(count) => {
934            println!("{} Found {} route(s)", style("->").green(), count);
935            println!(
936                "{} Generated {}",
937                style("✓").green(),
938                routes_output.display()
939            );
940        }
941        Err(e) => {
942            eprintln!(
943                "{} Route generation error: {}",
944                style("Warning:").yellow(),
945                e
946            );
947        }
948    }
949}
950
951/// Start file watcher for automatic type regeneration
952fn start_watcher(project_path: &Path, output_path: &Path) -> Result<(), String> {
953    use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
954    use std::sync::mpsc::channel;
955    use std::time::Duration;
956
957    let (tx, rx) = channel();
958    let src_path = project_path.join("src");
959
960    let mut watcher = RecommendedWatcher::new(
961        move |res| {
962            if let Ok(event) = res {
963                let _ = tx.send(event);
964            }
965        },
966        Config::default().with_poll_interval(Duration::from_secs(1)),
967    )
968    .map_err(|e| format!("Failed to create watcher: {e}"))?;
969
970    watcher
971        .watch(&src_path, RecursiveMode::Recursive)
972        .map_err(|e| format!("Failed to watch directory: {e}"))?;
973
974    println!(
975        "{} Watching {} for changes",
976        style("->").cyan(),
977        src_path.display()
978    );
979
980    let output_path = output_path.to_path_buf();
981    let project_path = project_path.to_path_buf();
982
983    loop {
984        match rx.recv() {
985            Ok(event) => {
986                // Check if it's a Rust file change
987                let is_rust_change = event
988                    .paths
989                    .iter()
990                    .any(|p| p.extension().map(|e| e == "rs").unwrap_or(false));
991
992                if is_rust_change {
993                    println!("{}", style("Detected changes, regenerating types...").dim());
994                    match generate_types_to_file(&project_path, &output_path) {
995                        Ok(count) => {
996                            println!("{} Regenerated {} type(s)", style("✓").green(), count);
997                        }
998                        Err(e) => {
999                            eprintln!("{} Failed to regenerate: {}", style("Error:").red(), e);
1000                        }
1001                    }
1002                }
1003            }
1004            Err(e) => {
1005                return Err(format!("Watch error: {e}"));
1006            }
1007        }
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    #[test]
1016    fn test_snake_to_camel() {
1017        assert_eq!(snake_to_camel("created_at"), "createdAt");
1018        assert_eq!(snake_to_camel("user_id"), "userId");
1019        assert_eq!(snake_to_camel("some_long_name"), "someLongName");
1020        assert_eq!(snake_to_camel("name"), "name");
1021    }
1022
1023    #[test]
1024    fn test_snake_to_pascal() {
1025        assert_eq!(snake_to_pascal("created_at"), "CreatedAt");
1026        assert_eq!(snake_to_pascal("user_id"), "UserId");
1027        assert_eq!(snake_to_pascal("some_long_name"), "SomeLongName");
1028        assert_eq!(snake_to_pascal("name"), "Name");
1029    }
1030
1031    #[test]
1032    fn test_serde_case_apply() {
1033        assert_eq!(SerdeCase::CamelCase.apply("created_at"), "createdAt");
1034        assert_eq!(SerdeCase::PascalCase.apply("created_at"), "CreatedAt");
1035        assert_eq!(
1036            SerdeCase::ScreamingSnakeCase.apply("created_at"),
1037            "CREATED_AT"
1038        );
1039        assert_eq!(SerdeCase::KebabCase.apply("created_at"), "created-at");
1040        assert_eq!(SerdeCase::None.apply("created_at"), "created_at");
1041        assert_eq!(SerdeCase::SnakeCase.apply("created_at"), "created_at");
1042    }
1043
1044    #[test]
1045    fn test_serde_case_from_str() {
1046        assert_eq!(SerdeCase::from_str("camelCase"), SerdeCase::CamelCase);
1047        assert_eq!(SerdeCase::from_str("PascalCase"), SerdeCase::PascalCase);
1048        assert_eq!(
1049            SerdeCase::from_str("SCREAMING_SNAKE_CASE"),
1050            SerdeCase::ScreamingSnakeCase
1051        );
1052        assert_eq!(SerdeCase::from_str("kebab-case"), SerdeCase::KebabCase);
1053        assert_eq!(SerdeCase::from_str("snake_case"), SerdeCase::SnakeCase);
1054        assert_eq!(SerdeCase::from_str("unknown"), SerdeCase::None);
1055    }
1056
1057    #[test]
1058    fn test_parse_serde_rename_all_value() {
1059        let tokens = r#"rename_all = "camelCase""#;
1060        assert_eq!(
1061            parse_serde_rename_all_value(tokens),
1062            Some("camelCase".to_string())
1063        );
1064
1065        let tokens = r#"derive(Serialize), rename_all = "PascalCase""#;
1066        assert_eq!(
1067            parse_serde_rename_all_value(tokens),
1068            Some("PascalCase".to_string())
1069        );
1070
1071        let tokens = r#"skip_serializing"#;
1072        assert_eq!(parse_serde_rename_all_value(tokens), None);
1073    }
1074
1075    #[test]
1076    fn test_parse_serde_rename_value() {
1077        let tokens = r#"rename = "customName""#;
1078        assert_eq!(
1079            parse_serde_rename_value(tokens),
1080            Some("customName".to_string())
1081        );
1082
1083        // Should not match rename_all
1084        let tokens = r#"rename_all = "camelCase""#;
1085        assert_eq!(parse_serde_rename_value(tokens), None);
1086
1087        // Should find rename after rename_all
1088        let tokens = r#"rename_all = "camelCase", rename = "custom""#;
1089        assert_eq!(parse_serde_rename_value(tokens), Some("custom".to_string()));
1090    }
1091
1092    #[test]
1093    fn test_apply_field_rename() {
1094        let field = StructField {
1095            name: "created_at".to_string(),
1096            ty: RustType::String,
1097            serde_rename: None,
1098        };
1099
1100        // With camelCase rename_all
1101        assert_eq!(
1102            apply_field_rename(&field, SerdeCase::CamelCase),
1103            "createdAt"
1104        );
1105
1106        // With explicit rename override
1107        let field_with_rename = StructField {
1108            name: "created_at".to_string(),
1109            ty: RustType::String,
1110            serde_rename: Some("customField".to_string()),
1111        };
1112        assert_eq!(
1113            apply_field_rename(&field_with_rename, SerdeCase::CamelCase),
1114            "customField"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_generate_typescript_with_serde() {
1120        let structs = vec![InertiaPropsStruct {
1121            name: "TestProps".to_string(),
1122            fields: vec![
1123                StructField {
1124                    name: "user_id".to_string(),
1125                    ty: RustType::Number,
1126                    serde_rename: None,
1127                },
1128                StructField {
1129                    name: "created_at".to_string(),
1130                    ty: RustType::String,
1131                    serde_rename: None,
1132                },
1133                StructField {
1134                    name: "special_field".to_string(),
1135                    ty: RustType::String,
1136                    serde_rename: Some("overridden".to_string()),
1137                },
1138            ],
1139            rename_all: SerdeCase::CamelCase,
1140            module_path: String::new(),
1141        }];
1142
1143        let typescript = generate_typescript(&structs);
1144
1145        assert!(typescript.contains("userId: number;"));
1146        assert!(typescript.contains("createdAt: string;"));
1147        assert!(typescript.contains("overridden: string;"));
1148        // Should NOT contain snake_case versions
1149        assert!(!typescript.contains("user_id:"));
1150        assert!(!typescript.contains("created_at:"));
1151        assert!(!typescript.contains("special_field:"));
1152    }
1153
1154    #[test]
1155    fn test_serde_json_value_maps_to_json_value() {
1156        // serde_json::Value should map to 'JsonValue' in TypeScript
1157        let structs = vec![InertiaPropsStruct {
1158            name: "FormProps".to_string(),
1159            fields: vec![
1160                StructField {
1161                    name: "errors".to_string(),
1162                    ty: RustType::Option(Box::new(RustType::JsonValue)),
1163                    serde_rename: None,
1164                },
1165                StructField {
1166                    name: "data".to_string(),
1167                    ty: RustType::JsonValue,
1168                    serde_rename: None,
1169                },
1170            ],
1171            rename_all: SerdeCase::None,
1172            module_path: String::new(),
1173        }];
1174
1175        let typescript = generate_typescript(&structs);
1176
1177        // Value should be mapped to 'JsonValue'
1178        assert!(typescript.contains("errors: JsonValue | null;"));
1179        assert!(typescript.contains("data: JsonValue;"));
1180        // Should NOT contain 'Value' as a type
1181        assert!(!typescript.contains(": Value"));
1182    }
1183
1184    #[test]
1185    fn test_collect_referenced_types() {
1186        let structs = vec![InertiaPropsStruct {
1187            name: "TestProps".to_string(),
1188            fields: vec![
1189                StructField {
1190                    name: "animal".to_string(),
1191                    ty: RustType::Custom("Animal".to_string()),
1192                    serde_rename: None,
1193                },
1194                StructField {
1195                    name: "user".to_string(),
1196                    ty: RustType::Option(Box::new(RustType::Custom("UserProfile".to_string()))),
1197                    serde_rename: None,
1198                },
1199                StructField {
1200                    name: "items".to_string(),
1201                    ty: RustType::Vec(Box::new(RustType::Custom("DiscoverAnimal".to_string()))),
1202                    serde_rename: None,
1203                },
1204                StructField {
1205                    name: "name".to_string(),
1206                    ty: RustType::String,
1207                    serde_rename: None,
1208                },
1209            ],
1210            rename_all: SerdeCase::None,
1211            module_path: String::new(),
1212        }];
1213
1214        let types = collect_referenced_types(&structs);
1215
1216        assert!(types.contains("Animal"));
1217        assert!(types.contains("UserProfile"));
1218        assert!(types.contains("DiscoverAnimal"));
1219        // Should not contain primitive type indicators
1220        assert!(!types.contains("String"));
1221    }
1222
1223    #[test]
1224    fn test_generate_typescript_is_self_contained() {
1225        // Generated output should never contain import/re-export from shared.ts
1226        let structs = vec![InertiaPropsStruct {
1227            name: "DiscoverProps".to_string(),
1228            fields: vec![
1229                StructField {
1230                    name: "animals".to_string(),
1231                    ty: RustType::Vec(Box::new(RustType::Custom("DiscoverAnimal".to_string()))),
1232                    serde_rename: None,
1233                },
1234                StructField {
1235                    name: "user".to_string(),
1236                    ty: RustType::Option(Box::new(RustType::Custom("UserProfile".to_string()))),
1237                    serde_rename: None,
1238                },
1239            ],
1240            rename_all: SerdeCase::None,
1241            module_path: String::new(),
1242        }];
1243
1244        let typescript = generate_typescript(&structs);
1245
1246        // Must NOT contain any imports or re-exports from shared.ts
1247        assert!(!typescript.contains("import type"));
1248        assert!(!typescript.contains("from './shared'"));
1249        assert!(!typescript.contains("export type {"));
1250
1251        // Must contain inline utility types
1252        assert!(typescript.contains("export type JsonValue ="));
1253        assert!(typescript.contains("export type ValidationErrors ="));
1254    }
1255
1256    #[test]
1257    fn test_serialize_struct_visitor_finds_matching() {
1258        let code = r#"
1259            use serde::Serialize;
1260
1261            #[derive(Serialize)]
1262            pub struct MenuSummary {
1263                pub id: String,
1264                pub name: String,
1265            }
1266
1267            #[derive(Serialize, Clone)]
1268            pub struct UserInfo {
1269                pub user_id: i64,
1270            }
1271
1272            // Not a target, should be ignored
1273            #[derive(Serialize)]
1274            pub struct OtherType {
1275                pub value: String,
1276            }
1277        "#;
1278
1279        let mut target_types = HashSet::new();
1280        target_types.insert("MenuSummary".to_string());
1281        target_types.insert("UserInfo".to_string());
1282
1283        if let Ok(syntax) = syn::parse_file(code) {
1284            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
1285            syn::visit::Visit::visit_file(&mut visitor, &syntax);
1286
1287            assert_eq!(visitor.structs.len(), 2);
1288            let names: HashSet<_> = visitor.structs.iter().map(|s| s.name.as_str()).collect();
1289            assert!(names.contains("MenuSummary"));
1290            assert!(names.contains("UserInfo"));
1291            assert!(!names.contains("OtherType"));
1292        } else {
1293            panic!("Failed to parse test code");
1294        }
1295    }
1296
1297    #[test]
1298    fn test_serialize_struct_visitor_ignores_non_matching() {
1299        let code = r#"
1300            use serde::Serialize;
1301
1302            #[derive(Serialize)]
1303            pub struct Exists {
1304                pub id: String,
1305            }
1306
1307            // No Serialize derive
1308            pub struct NoDerive {
1309                pub id: String,
1310            }
1311
1312            // Different derive
1313            #[derive(Debug, Clone)]
1314            pub struct WrongDerive {
1315                pub id: String,
1316            }
1317        "#;
1318
1319        let mut target_types = HashSet::new();
1320        target_types.insert("NotExists".to_string()); // Looking for something that doesn't exist
1321        target_types.insert("NoDerive".to_string()); // Exists but no Serialize
1322        target_types.insert("WrongDerive".to_string()); // Exists but wrong derive
1323
1324        if let Ok(syntax) = syn::parse_file(code) {
1325            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
1326            syn::visit::Visit::visit_file(&mut visitor, &syntax);
1327
1328            // Should find nothing since none match both criteria
1329            assert_eq!(visitor.structs.len(), 0);
1330        } else {
1331            panic!("Failed to parse test code");
1332        }
1333    }
1334
1335    #[test]
1336    fn test_serialize_struct_visitor_parses_serde_attributes() {
1337        let code = r#"
1338            use serde::Serialize;
1339
1340            #[derive(Serialize)]
1341            #[serde(rename_all = "camelCase")]
1342            pub struct WithRenameAll {
1343                pub created_at: String,
1344                #[serde(rename = "customName")]
1345                pub some_field: String,
1346            }
1347        "#;
1348
1349        let mut target_types = HashSet::new();
1350        target_types.insert("WithRenameAll".to_string());
1351
1352        if let Ok(syntax) = syn::parse_file(code) {
1353            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
1354            syn::visit::Visit::visit_file(&mut visitor, &syntax);
1355
1356            assert_eq!(visitor.structs.len(), 1);
1357            let s = &visitor.structs[0];
1358            assert_eq!(s.rename_all, SerdeCase::CamelCase);
1359            assert_eq!(s.fields.len(), 2);
1360
1361            // Check field-level rename
1362            let some_field = s.fields.iter().find(|f| f.name == "some_field").unwrap();
1363            assert_eq!(some_field.serde_rename, Some("customName".to_string()));
1364        } else {
1365            panic!("Failed to parse test code");
1366        }
1367    }
1368
1369    #[test]
1370    fn test_parse_serde_json_value_type() {
1371        // When parsing serde_json::Value, it should map to JsonValue
1372        let code = r#"
1373            use serde::Serialize;
1374            use serde_json::Value;
1375
1376            #[derive(Serialize)]
1377            pub struct FormProps {
1378                pub errors: Option<Value>,
1379                pub data: Value,
1380            }
1381        "#;
1382
1383        let mut target_types = HashSet::new();
1384        target_types.insert("FormProps".to_string());
1385
1386        if let Ok(syntax) = syn::parse_file(code) {
1387            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
1388            syn::visit::Visit::visit_file(&mut visitor, &syntax);
1389
1390            assert_eq!(visitor.structs.len(), 1);
1391            let s = &visitor.structs[0];
1392
1393            // errors: Option<Value> should parse to Option(JsonValue)
1394            let errors_field = s.fields.iter().find(|f| f.name == "errors").unwrap();
1395            assert!(matches!(
1396                &errors_field.ty,
1397                RustType::Option(inner) if matches!(inner.as_ref(), RustType::JsonValue)
1398            ));
1399
1400            // data: Value should parse to JsonValue
1401            let data_field = s.fields.iter().find(|f| f.name == "data").unwrap();
1402            assert!(matches!(&data_field.ty, RustType::JsonValue));
1403
1404            // Generate TypeScript and verify output
1405            let typescript = generate_typescript(&visitor.structs);
1406            assert!(typescript.contains("errors: JsonValue | null;"));
1407            assert!(typescript.contains("data: JsonValue;"));
1408            assert!(!typescript.contains(": Value"));
1409        } else {
1410            panic!("Failed to parse test code");
1411        }
1412    }
1413
1414    #[test]
1415    fn test_resolve_nested_types_single_level() {
1416        // Test that resolve_nested_types finds types referenced by initial structs
1417        let initial = vec![InertiaPropsStruct {
1418            name: "ListProps".to_string(),
1419            fields: vec![
1420                StructField {
1421                    name: "items".to_string(),
1422                    ty: RustType::Vec(Box::new(RustType::Custom("ItemSummary".to_string()))),
1423                    serde_rename: None,
1424                },
1425                StructField {
1426                    name: "user".to_string(),
1427                    ty: RustType::Custom("UserInfo".to_string()),
1428                    serde_rename: None,
1429                },
1430            ],
1431            rename_all: SerdeCase::None,
1432            module_path: String::new(),
1433        }];
1434
1435        // resolve_nested_types requires a project path with actual files
1436        // For unit testing, we verify collect_referenced_types works correctly
1437        let referenced = collect_referenced_types(&initial);
1438        assert!(referenced.contains("ItemSummary"));
1439        assert!(referenced.contains("UserInfo"));
1440    }
1441
1442    #[test]
1443    fn test_resolve_nested_types_skips_shared() {
1444        // Test that shared types are not included in referenced types
1445        let initial = vec![InertiaPropsStruct {
1446            name: "TestProps".to_string(),
1447            fields: vec![
1448                StructField {
1449                    name: "animal".to_string(),
1450                    ty: RustType::Custom("Animal".to_string()),
1451                    serde_rename: None,
1452                },
1453                StructField {
1454                    name: "user".to_string(),
1455                    ty: RustType::Custom("SharedUser".to_string()),
1456                    serde_rename: None,
1457                },
1458            ],
1459            rename_all: SerdeCase::None,
1460            module_path: String::new(),
1461        }];
1462
1463        let mut shared_types = HashSet::new();
1464        shared_types.insert("SharedUser".to_string());
1465
1466        // Simulate what resolve_nested_types does with filtering
1467        let mut types_to_find = collect_referenced_types(&initial);
1468        let initial_names: HashSet<String> = initial.iter().map(|s| s.name.clone()).collect();
1469        types_to_find.retain(|t| !initial_names.contains(t) && !shared_types.contains(t));
1470
1471        // SharedUser should be filtered out, Animal should remain
1472        assert!(types_to_find.contains("Animal"));
1473        assert!(!types_to_find.contains("SharedUser"));
1474    }
1475
1476    #[test]
1477    fn test_resolve_nested_types_recursive() {
1478        // Test that deeply nested types are collected
1479        // Level 1: PageProps -> Level1Type
1480        // Level 2: Level1Type -> Level2Type
1481        let level1 = InertiaPropsStruct {
1482            name: "Level1Type".to_string(),
1483            fields: vec![StructField {
1484                name: "nested".to_string(),
1485                ty: RustType::Custom("Level2Type".to_string()),
1486                serde_rename: None,
1487            }],
1488            rename_all: SerdeCase::None,
1489            module_path: String::new(),
1490        };
1491
1492        // Check that Level1Type references Level2Type
1493        let level1_refs = collect_referenced_types(&[level1]);
1494        assert!(level1_refs.contains("Level2Type"));
1495    }
1496
1497    #[test]
1498    fn test_parse_type_validation_errors() {
1499        // Test parsing ValidationErrors type from Rust code
1500        let code = r#"
1501            use ferro::ValidationErrors;
1502
1503            #[derive(Serialize)]
1504            pub struct FormProps {
1505                pub errors: Option<ValidationErrors>,
1506                pub all_errors: ValidationErrors,
1507            }
1508        "#;
1509
1510        let mut target_types = HashSet::new();
1511        target_types.insert("FormProps".to_string());
1512
1513        if let Ok(syntax) = syn::parse_file(code) {
1514            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
1515            syn::visit::Visit::visit_file(&mut visitor, &syntax);
1516
1517            assert_eq!(visitor.structs.len(), 1);
1518            let s = &visitor.structs[0];
1519
1520            // errors: Option<ValidationErrors> should parse to Option(ValidationErrors)
1521            let errors_field = s.fields.iter().find(|f| f.name == "errors").unwrap();
1522            assert!(matches!(
1523                &errors_field.ty,
1524                RustType::Option(inner) if matches!(inner.as_ref(), RustType::ValidationErrors)
1525            ));
1526
1527            // all_errors: ValidationErrors should parse to ValidationErrors
1528            let all_errors_field = s.fields.iter().find(|f| f.name == "all_errors").unwrap();
1529            assert!(matches!(&all_errors_field.ty, RustType::ValidationErrors));
1530        } else {
1531            panic!("Failed to parse test code");
1532        }
1533    }
1534
1535    #[test]
1536    fn test_validation_errors_to_typescript() {
1537        // Test that ValidationErrors type generates ValidationErrors in TypeScript
1538        let structs = vec![InertiaPropsStruct {
1539            name: "FormProps".to_string(),
1540            fields: vec![
1541                StructField {
1542                    name: "errors".to_string(),
1543                    ty: RustType::Option(Box::new(RustType::ValidationErrors)),
1544                    serde_rename: None,
1545                },
1546                StructField {
1547                    name: "all_errors".to_string(),
1548                    ty: RustType::ValidationErrors,
1549                    serde_rename: None,
1550                },
1551            ],
1552            rename_all: SerdeCase::None,
1553            module_path: String::new(),
1554        }];
1555
1556        let typescript = generate_typescript(&structs);
1557
1558        // ValidationErrors should use the exported ValidationErrors type
1559        assert!(typescript.contains("errors: ValidationErrors | null;"));
1560        assert!(typescript.contains("all_errors: ValidationErrors;"));
1561    }
1562
1563    #[test]
1564    fn test_hashmap_string_vec_string_to_typescript() {
1565        // Test that HashMap<String, Vec<String>> generates Record<string, string[]> in TypeScript
1566        // This is for custom HashMap types, not ValidationErrors
1567        let structs = vec![InertiaPropsStruct {
1568            name: "CustomProps".to_string(),
1569            fields: vec![StructField {
1570                name: "data".to_string(),
1571                ty: RustType::HashMap(
1572                    Box::new(RustType::String),
1573                    Box::new(RustType::Vec(Box::new(RustType::String))),
1574                ),
1575                serde_rename: None,
1576            }],
1577            rename_all: SerdeCase::None,
1578            module_path: String::new(),
1579        }];
1580
1581        let typescript = generate_typescript(&structs);
1582
1583        // HashMap<String, Vec<String>> should become Record<string, string[]>
1584        assert!(typescript.contains("data: Record<string, string[]>;"));
1585    }
1586
1587    // ===== Namespacing Tests (Phase 22.5) =====
1588
1589    #[test]
1590    fn test_compute_module_path_flat_controller() {
1591        // src/controllers/user.rs -> user
1592        let src_path = std::path::Path::new("/project/src");
1593        let file_path = std::path::Path::new("/project/src/controllers/user.rs");
1594        let result = compute_module_path(file_path, src_path);
1595        assert_eq!(result, "user");
1596    }
1597
1598    #[test]
1599    fn test_compute_module_path_nested_controller() {
1600        // src/controllers/shelter/applications.rs -> shelter::applications
1601        let src_path = std::path::Path::new("/project/src");
1602        let file_path = std::path::Path::new("/project/src/controllers/shelter/applications.rs");
1603        let result = compute_module_path(file_path, src_path);
1604        assert_eq!(result, "shelter::applications");
1605    }
1606
1607    #[test]
1608    fn test_compute_module_path_deeply_nested() {
1609        // src/controllers/admin/settings/security.rs -> admin::settings::security
1610        let src_path = std::path::Path::new("/project/src");
1611        let file_path = std::path::Path::new("/project/src/controllers/admin/settings/security.rs");
1612        let result = compute_module_path(file_path, src_path);
1613        assert_eq!(result, "admin::settings::security");
1614    }
1615
1616    #[test]
1617    fn test_compute_module_path_non_controller() {
1618        // src/models/animal.rs -> models::animal (preserves path for non-controllers)
1619        let src_path = std::path::Path::new("/project/src");
1620        let file_path = std::path::Path::new("/project/src/models/animal.rs");
1621        let result = compute_module_path(file_path, src_path);
1622        assert_eq!(result, "models::animal");
1623    }
1624
1625    #[test]
1626    fn test_compute_module_path_mod_rs() {
1627        // src/controllers/shelter/mod.rs -> shelter (removes ::mod suffix)
1628        let src_path = std::path::Path::new("/project/src");
1629        let file_path = std::path::Path::new("/project/src/controllers/shelter/mod.rs");
1630        let result = compute_module_path(file_path, src_path);
1631        assert_eq!(result, "shelter");
1632    }
1633
1634    #[test]
1635    fn test_generate_namespaced_name_empty_module_path() {
1636        // Root-level struct should not be namespaced
1637        assert_eq!(generate_namespaced_name("", "GlobalProps"), "GlobalProps");
1638    }
1639
1640    #[test]
1641    fn test_generate_namespaced_name_single_segment() {
1642        // user + ShowProps -> UserShowProps
1643        assert_eq!(
1644            generate_namespaced_name("user", "ShowProps"),
1645            "UserShowProps"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_generate_namespaced_name_nested_segments() {
1651        // shelter::applications + ShowProps -> ShelterApplicationsShowProps
1652        assert_eq!(
1653            generate_namespaced_name("shelter::applications", "ShowProps"),
1654            "ShelterApplicationsShowProps"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_generate_namespaced_name_deeply_nested() {
1660        // admin::settings::security + IndexProps -> AdminSettingsSecurityIndexProps
1661        assert_eq!(
1662            generate_namespaced_name("admin::settings::security", "IndexProps"),
1663            "AdminSettingsSecurityIndexProps"
1664        );
1665    }
1666
1667    #[test]
1668    fn test_generate_namespaced_name_snake_case_conversion() {
1669        // user_profile + EditProps -> UserProfileEditProps
1670        assert_eq!(
1671            generate_namespaced_name("user_profile", "EditProps"),
1672            "UserProfileEditProps"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_generate_namespaced_name_skips_redundant_prefix() {
1678        // menu + MenuListProps -> MenuListProps (already prefixed)
1679        assert_eq!(
1680            generate_namespaced_name("menu", "MenuListProps"),
1681            "MenuListProps"
1682        );
1683        // public::menu + PublicMenuProps -> PublicMenuProps (already prefixed)
1684        assert_eq!(
1685            generate_namespaced_name("public::menu", "PublicMenuProps"),
1686            "PublicMenuProps"
1687        );
1688        // category + CategoryDetail -> CategoryDetail (already prefixed)
1689        assert_eq!(
1690            generate_namespaced_name("category", "CategoryDetail"),
1691            "CategoryDetail"
1692        );
1693        // qrcode + QRCodeListProps -> QRCodeListProps (case-insensitive match)
1694        assert_eq!(
1695            generate_namespaced_name("qrcode", "QRCodeListProps"),
1696            "QRCodeListProps"
1697        );
1698        // auth + AuthRegisterProps -> AuthRegisterProps
1699        assert_eq!(
1700            generate_namespaced_name("auth", "AuthRegisterProps"),
1701            "AuthRegisterProps"
1702        );
1703    }
1704
1705    #[test]
1706    fn test_build_name_map_no_collisions() {
1707        let structs = vec![
1708            InertiaPropsStruct {
1709                name: "ShowProps".to_string(),
1710                fields: vec![],
1711                rename_all: SerdeCase::None,
1712                module_path: "shelter::applications".to_string(),
1713            },
1714            InertiaPropsStruct {
1715                name: "ShowProps".to_string(),
1716                fields: vec![],
1717                rename_all: SerdeCase::None,
1718                module_path: "adopter::applications".to_string(),
1719            },
1720        ];
1721
1722        let name_map = build_name_map(&structs);
1723
1724        // Both should get unique namespaced names
1725        // Note: The name_map is keyed by original name, so we need to check the values
1726        // Since both have the same name "ShowProps", the last one wins in the map
1727        // This is actually the collision we're detecting
1728        assert!(name_map.contains_key("ShowProps"));
1729    }
1730
1731    #[test]
1732    fn test_typescript_generation_with_namespaced_names() {
1733        let structs = vec![
1734            InertiaPropsStruct {
1735                name: "ShowProps".to_string(),
1736                fields: vec![StructField {
1737                    name: "id".to_string(),
1738                    ty: RustType::Number,
1739                    serde_rename: None,
1740                }],
1741                rename_all: SerdeCase::None,
1742                module_path: "shelter::applications".to_string(),
1743            },
1744            InertiaPropsStruct {
1745                name: "IndexProps".to_string(),
1746                fields: vec![StructField {
1747                    name: "count".to_string(),
1748                    ty: RustType::Number,
1749                    serde_rename: None,
1750                }],
1751                rename_all: SerdeCase::None,
1752                module_path: "user".to_string(),
1753            },
1754        ];
1755
1756        let typescript = generate_typescript(&structs);
1757
1758        // Should have namespaced interface names
1759        assert!(typescript.contains("export interface ShelterApplicationsShowProps"));
1760        assert!(typescript.contains("export interface UserIndexProps"));
1761    }
1762
1763    #[test]
1764    fn test_type_references_use_namespaced_names() {
1765        // When a field references another type, it should use the namespaced name
1766        let structs = vec![
1767            InertiaPropsStruct {
1768                name: "DetailProps".to_string(),
1769                fields: vec![],
1770                rename_all: SerdeCase::None,
1771                module_path: "shelter".to_string(),
1772            },
1773            InertiaPropsStruct {
1774                name: "ShowProps".to_string(),
1775                fields: vec![StructField {
1776                    name: "details".to_string(),
1777                    ty: RustType::Custom("DetailProps".to_string()),
1778                    serde_rename: None,
1779                }],
1780                rename_all: SerdeCase::None,
1781                module_path: "shelter".to_string(),
1782            },
1783        ];
1784
1785        let typescript = generate_typescript(&structs);
1786
1787        // The reference to DetailProps should use the namespaced name
1788        assert!(typescript.contains("details: ShelterDetailProps;"));
1789    }
1790
1791    #[test]
1792    fn test_datetime_type_mapping() {
1793        // Test that all chrono datetime types parse to RustType::DateTime
1794        use syn::parse_quote;
1795
1796        let datetime: Type = parse_quote!(DateTime<Utc>);
1797        assert!(matches!(
1798            InertiaPropsVisitor::parse_type(&datetime),
1799            RustType::DateTime
1800        ));
1801
1802        let naive_datetime: Type = parse_quote!(NaiveDateTime);
1803        assert!(matches!(
1804            InertiaPropsVisitor::parse_type(&naive_datetime),
1805            RustType::DateTime
1806        ));
1807
1808        let naive_date: Type = parse_quote!(NaiveDate);
1809        assert!(matches!(
1810            InertiaPropsVisitor::parse_type(&naive_date),
1811            RustType::DateTime
1812        ));
1813
1814        let naive_time: Type = parse_quote!(NaiveTime);
1815        assert!(matches!(
1816            InertiaPropsVisitor::parse_type(&naive_time),
1817            RustType::DateTime
1818        ));
1819
1820        let datetime_utc: Type = parse_quote!(DateTimeUtc);
1821        assert!(matches!(
1822            InertiaPropsVisitor::parse_type(&datetime_utc),
1823            RustType::DateTime
1824        ));
1825
1826        let datetime_local: Type = parse_quote!(DateTimeLocal);
1827        assert!(matches!(
1828            InertiaPropsVisitor::parse_type(&datetime_local),
1829            RustType::DateTime
1830        ));
1831
1832        let date: Type = parse_quote!(Date);
1833        assert!(matches!(
1834            InertiaPropsVisitor::parse_type(&date),
1835            RustType::DateTime
1836        ));
1837
1838        let time: Type = parse_quote!(Time);
1839        assert!(matches!(
1840            InertiaPropsVisitor::parse_type(&time),
1841            RustType::DateTime
1842        ));
1843    }
1844
1845    #[test]
1846    fn test_datetime_to_typescript() {
1847        // Verify RustType::DateTime generates "string" in TypeScript
1848        let name_map = HashMap::new();
1849        assert_eq!(
1850            rust_type_to_ts_with_mapping(&RustType::DateTime, &name_map),
1851            "string"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_option_datetime() {
1857        // Verify Option<DateTime<Utc>> generates "string | null"
1858        let name_map = HashMap::new();
1859        let option_datetime = RustType::Option(Box::new(RustType::DateTime));
1860        assert_eq!(
1861            rust_type_to_ts_with_mapping(&option_datetime, &name_map),
1862            "string | null"
1863        );
1864    }
1865
1866    #[test]
1867    fn test_vec_datetime() {
1868        // Verify Vec<NaiveDateTime> generates "string[]"
1869        let name_map = HashMap::new();
1870        let vec_datetime = RustType::Vec(Box::new(RustType::DateTime));
1871        assert_eq!(
1872            rust_type_to_ts_with_mapping(&vec_datetime, &name_map),
1873            "string[]"
1874        );
1875    }
1876
1877    #[test]
1878    fn test_generate_typescript_with_datetime() {
1879        // End-to-end test with a props struct containing datetime fields
1880        let structs = vec![InertiaPropsStruct {
1881            name: "EventProps".to_string(),
1882            fields: vec![
1883                StructField {
1884                    name: "created_at".to_string(),
1885                    ty: RustType::DateTime,
1886                    serde_rename: None,
1887                },
1888                StructField {
1889                    name: "updated_at".to_string(),
1890                    ty: RustType::Option(Box::new(RustType::DateTime)),
1891                    serde_rename: None,
1892                },
1893                StructField {
1894                    name: "scheduled_dates".to_string(),
1895                    ty: RustType::Vec(Box::new(RustType::DateTime)),
1896                    serde_rename: None,
1897                },
1898            ],
1899            rename_all: SerdeCase::CamelCase,
1900            module_path: "events".to_string(),
1901        }];
1902
1903        let typescript = generate_typescript(&structs);
1904
1905        // Should contain the interface with datetime fields mapped to string
1906        assert!(typescript.contains("export interface EventsEventProps"));
1907        assert!(typescript.contains("createdAt: string;"));
1908        assert!(typescript.contains("updatedAt: string | null;"));
1909        assert!(typescript.contains("scheduledDates: string[];"));
1910    }
1911
1912    #[test]
1913    fn test_json_value_type_mapping() {
1914        // Verify RustType::JsonValue generates "JsonValue" in TypeScript
1915        let name_map = HashMap::new();
1916        assert_eq!(
1917            rust_type_to_ts_with_mapping(&RustType::JsonValue, &name_map),
1918            "JsonValue"
1919        );
1920    }
1921
1922    #[test]
1923    fn test_validation_errors_type_mapping() {
1924        // Verify RustType::ValidationErrors generates "ValidationErrors" in TypeScript
1925        let name_map = HashMap::new();
1926        assert_eq!(
1927            rust_type_to_ts_with_mapping(&RustType::ValidationErrors, &name_map),
1928            "ValidationErrors"
1929        );
1930    }
1931
1932    #[test]
1933    fn test_option_json_value() {
1934        // Verify Option<Value> generates "JsonValue | null"
1935        let name_map = HashMap::new();
1936        let option_value = RustType::Option(Box::new(RustType::JsonValue));
1937        assert_eq!(
1938            rust_type_to_ts_with_mapping(&option_value, &name_map),
1939            "JsonValue | null"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_option_validation_errors() {
1945        // Verify Option<ValidationErrors> generates "ValidationErrors | null"
1946        let name_map = HashMap::new();
1947        let option_errors = RustType::Option(Box::new(RustType::ValidationErrors));
1948        assert_eq!(
1949            rust_type_to_ts_with_mapping(&option_errors, &name_map),
1950            "ValidationErrors | null"
1951        );
1952    }
1953
1954    #[test]
1955    fn test_generated_output_includes_utility_types() {
1956        // Verify generated output includes JsonValue and ValidationErrors type definitions
1957        let structs = vec![InertiaPropsStruct {
1958            name: "TestProps".to_string(),
1959            fields: vec![StructField {
1960                name: "data".to_string(),
1961                ty: RustType::String,
1962                serde_rename: None,
1963            }],
1964            rename_all: SerdeCase::None,
1965            module_path: String::new(),
1966        }];
1967
1968        let typescript = generate_typescript(&structs);
1969
1970        // Should contain utility type definitions
1971        assert!(typescript.contains("export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };"));
1972        assert!(typescript.contains("export type ValidationErrors = Record<string, string[]>;"));
1973    }
1974
1975    #[test]
1976    fn test_generated_output_includes_header() {
1977        // Verify generated output includes instructional header
1978        let structs = vec![InertiaPropsStruct {
1979            name: "TestProps".to_string(),
1980            fields: vec![StructField {
1981                name: "data".to_string(),
1982                ty: RustType::String,
1983                serde_rename: None,
1984            }],
1985            rename_all: SerdeCase::None,
1986            module_path: String::new(),
1987        }];
1988
1989        let typescript = generate_typescript(&structs);
1990
1991        // Should contain header with instructions
1992        assert!(typescript.contains("Auto-generated by `ferro generate-types`"));
1993        assert!(typescript.contains("Do not edit manually"));
1994        assert!(typescript.contains("ferro generate-types"));
1995        assert!(typescript.contains("frontend/src/types/"));
1996    }
1997
1998    #[test]
1999    fn test_parse_validation_errors_type() {
2000        // When parsing ValidationErrors type, it should map to RustType::ValidationErrors
2001        let code = r#"
2002            use serde::Serialize;
2003            use ferro::ValidationErrors;
2004
2005            #[derive(Serialize)]
2006            pub struct FormProps {
2007                pub errors: Option<ValidationErrors>,
2008            }
2009        "#;
2010
2011        let mut target_types = HashSet::new();
2012        target_types.insert("FormProps".to_string());
2013
2014        if let Ok(syntax) = syn::parse_file(code) {
2015            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
2016            syn::visit::Visit::visit_file(&mut visitor, &syntax);
2017
2018            assert_eq!(visitor.structs.len(), 1);
2019            let s = &visitor.structs[0];
2020
2021            // errors: Option<ValidationErrors> should parse to Option(ValidationErrors)
2022            let errors_field = s.fields.iter().find(|f| f.name == "errors").unwrap();
2023            assert!(matches!(
2024                &errors_field.ty,
2025                RustType::Option(inner) if matches!(inner.as_ref(), RustType::ValidationErrors)
2026            ));
2027
2028            // Generate TypeScript and verify output
2029            let typescript = generate_typescript(&visitor.structs);
2030            assert!(typescript.contains("errors: ValidationErrors | null;"));
2031        } else {
2032            panic!("Failed to parse test code");
2033        }
2034    }
2035
2036    #[test]
2037    fn test_parse_sea_orm_json_type() {
2038        // When parsing sea_orm::Json type, it should map to JsonValue
2039        let code = r#"
2040            use serde::Serialize;
2041            use sea_orm::entity::prelude::Json;
2042
2043            #[derive(Serialize)]
2044            pub struct ConfigProps {
2045                pub settings: Json,
2046            }
2047        "#;
2048
2049        let mut target_types = HashSet::new();
2050        target_types.insert("ConfigProps".to_string());
2051
2052        if let Ok(syntax) = syn::parse_file(code) {
2053            let mut visitor = SerializeStructVisitor::new(target_types, String::new());
2054            syn::visit::Visit::visit_file(&mut visitor, &syntax);
2055
2056            assert_eq!(visitor.structs.len(), 1);
2057            let s = &visitor.structs[0];
2058
2059            // settings: Json should parse to JsonValue
2060            let settings_field = s.fields.iter().find(|f| f.name == "settings").unwrap();
2061            assert!(matches!(&settings_field.ty, RustType::JsonValue));
2062
2063            // Generate TypeScript and verify output
2064            let typescript = generate_typescript(&visitor.structs);
2065            assert!(typescript.contains("settings: JsonValue;"));
2066        } else {
2067            panic!("Failed to parse test code");
2068        }
2069    }
2070}