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#[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 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 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
46fn 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
65fn 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#[derive(Debug, Clone)]
86pub struct InertiaPropsStruct {
87 pub name: String,
88 pub fields: Vec<StructField>,
89 pub rename_all: SerdeCase,
91 #[allow(dead_code)] pub module_path: String,
95}
96
97#[derive(Debug, Clone)]
98pub struct StructField {
99 pub name: String,
100 pub ty: RustType,
101 pub serde_rename: Option<String>,
103}
104
105#[derive(Debug, Clone)]
106pub enum RustType {
107 String,
108 Number,
109 Bool,
110 DateTime,
111 JsonValue,
113 ValidationErrors,
115 Option(Box<RustType>),
116 Vec(Box<RustType>),
117 HashMap(Box<RustType>, Box<RustType>),
118 Custom(String),
119}
120
121struct InertiaPropsVisitor {
123 structs: Vec<InertiaPropsStruct>,
124 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 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 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 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 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
193fn parse_serde_rename_all_value(tokens: &str) -> Option<String> {
195 if let Some(start) = tokens.find("rename_all") {
197 let rest = &tokens[start..];
198 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
209fn parse_serde_rename_value(tokens: &str) -> Option<String> {
211 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 if rest.starts_with("rename_all") {
218 search_from = actual_pos + 10;
219 continue;
220 }
221 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 "Value" | "Json" => RustType::JsonValue,
285 "ValidationErrors" => RustType::ValidationErrors,
287 "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 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 syn::visit::visit_item_struct(self, node);
344 }
345}
346
347struct SerializeStructVisitor {
349 target_types: HashSet<String>,
351 structs: Vec<InertiaPropsStruct>,
353 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 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 if path.is_ident("Serialize") {
376 return true;
377 }
378 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 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 syn::visit::visit_item_struct(self, node);
427 }
428}
429
430fn 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 let path_str = path_str.strip_suffix("::mod").unwrap_or(&path_str);
451
452 path_str
454 .strip_prefix("controllers::")
455 .unwrap_or(path_str)
456 .to_string()
457}
458
459fn 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 let namespace: String = module_path.split("::").map(snake_to_pascal).collect();
479
480 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
492pub 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
522pub 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
545fn 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 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 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
613pub 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 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
649fn 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
662fn 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
679fn apply_field_rename(field: &StructField, rename_all: SerdeCase) -> String {
682 if let Some(ref rename) = field.serde_rename {
684 return rename.clone();
685 }
686 rename_all.apply(&field.name)
688}
689
690pub fn generate_typescript(structs: &[InertiaPropsStruct]) -> String {
692 let sorted = topological_sort(structs);
693
694 let name_map = build_name_map(structs);
697
698 let mut output = String::new();
699
700 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 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
737fn 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(); 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 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
770fn 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 name_map.get(name).cloned().unwrap_or_else(|| name.clone())
791 }
792 }
793}
794
795pub 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 types_to_find.retain(|t| !known_types.contains(t) && !shared_types.contains(t));
810
811 while !types_to_find.is_empty() {
813 let found = scan_serialize_structs(project_path, &types_to_find);
814
815 if found.is_empty() {
816 break;
819 }
820
821 for s in &found {
823 known_types.insert(s.name.clone());
824 }
825
826 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
837pub 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 let shared_types = parse_shared_types(project_path);
847
848 let nested_types = resolve_nested_types(project_path, &structs, &shared_types);
850 structs.extend(nested_types);
851
852 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 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
866pub fn run(output: Option<String>, watch: bool) {
868 let project_path = Path::new(".");
869
870 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 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
920fn 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
951fn 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 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 let tokens = r#"rename_all = "camelCase""#;
1085 assert_eq!(parse_serde_rename_value(tokens), None);
1086
1087 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 assert_eq!(
1102 apply_field_rename(&field, SerdeCase::CamelCase),
1103 "createdAt"
1104 );
1105
1106 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 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 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 assert!(typescript.contains("errors: JsonValue | null;"));
1179 assert!(typescript.contains("data: JsonValue;"));
1180 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 assert!(!types.contains("String"));
1221 }
1222
1223 #[test]
1224 fn test_generate_typescript_is_self_contained() {
1225 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 assert!(!typescript.contains("import type"));
1248 assert!(!typescript.contains("from './shared'"));
1249 assert!(!typescript.contains("export type {"));
1250
1251 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()); target_types.insert("NoDerive".to_string()); target_types.insert("WrongDerive".to_string()); 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 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 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 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 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 let data_field = s.fields.iter().find(|f| f.name == "data").unwrap();
1402 assert!(matches!(&data_field.ty, RustType::JsonValue));
1403
1404 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(typescript.contains("data: Record<string, string[]>;"));
1585 }
1586
1587 #[test]
1590 fn test_compute_module_path_flat_controller() {
1591 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 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 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 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 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 assert_eq!(generate_namespaced_name("", "GlobalProps"), "GlobalProps");
1638 }
1639
1640 #[test]
1641 fn test_generate_namespaced_name_single_segment() {
1642 assert_eq!(
1644 generate_namespaced_name("user", "ShowProps"),
1645 "UserShowProps"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_generate_namespaced_name_nested_segments() {
1651 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 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 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 assert_eq!(
1680 generate_namespaced_name("menu", "MenuListProps"),
1681 "MenuListProps"
1682 );
1683 assert_eq!(
1685 generate_namespaced_name("public::menu", "PublicMenuProps"),
1686 "PublicMenuProps"
1687 );
1688 assert_eq!(
1690 generate_namespaced_name("category", "CategoryDetail"),
1691 "CategoryDetail"
1692 );
1693 assert_eq!(
1695 generate_namespaced_name("qrcode", "QRCodeListProps"),
1696 "QRCodeListProps"
1697 );
1698 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 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 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 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 assert!(typescript.contains("details: ShelterDetailProps;"));
1789 }
1790
1791 #[test]
1792 fn test_datetime_type_mapping() {
1793 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let settings_field = s.fields.iter().find(|f| f.name == "settings").unwrap();
2061 assert!(matches!(&settings_field.ty, RustType::JsonValue));
2062
2063 let typescript = generate_typescript(&visitor.structs);
2065 assert!(typescript.contains("settings: JsonValue;"));
2066 } else {
2067 panic!("Failed to parse test code");
2068 }
2069 }
2070}