1use console::style;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fs;
14use std::path::Path;
15use syn::visit::Visit;
16use syn::{Attribute, Fields, FnArg, ItemFn, ItemStruct, Type};
17use walkdir::WalkDir;
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum HttpMethod {
22 Get,
23 Post,
24 Put,
25 Patch,
26 Delete,
27}
28
29impl HttpMethod {
30 fn from_str(s: &str) -> Option<Self> {
31 match s.to_lowercase().as_str() {
32 "get" => Some(HttpMethod::Get),
33 "post" => Some(HttpMethod::Post),
34 "put" => Some(HttpMethod::Put),
35 "patch" => Some(HttpMethod::Patch),
36 "delete" => Some(HttpMethod::Delete),
37 _ => None,
38 }
39 }
40
41 fn to_ts_method(&self) -> &'static str {
42 match self {
43 HttpMethod::Get => "get",
44 HttpMethod::Post => "post",
45 HttpMethod::Put => "put",
46 HttpMethod::Patch => "patch",
47 HttpMethod::Delete => "delete",
48 }
49 }
50
51 pub fn as_str_upper(&self) -> &'static str {
53 match self {
54 HttpMethod::Get => "GET",
55 HttpMethod::Post => "POST",
56 HttpMethod::Put => "PUT",
57 HttpMethod::Patch => "PATCH",
58 HttpMethod::Delete => "DELETE",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct RoutesJson {
70 pub routes: Vec<RouteJson>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct RouteJson {
76 pub method: String,
78 pub path: String,
80 pub handler: String,
82 pub name: Option<String>,
84 pub middleware: Vec<String>,
88}
89
90pub fn routes_to_json(routes: &[GeneratedRoute]) -> RoutesJson {
92 RoutesJson {
93 routes: routes
94 .iter()
95 .map(|r| RouteJson {
96 method: r.definition.method.as_str_upper().to_string(),
97 path: r.definition.path.clone(),
98 handler: format!(
99 "{}::{}",
100 r.definition.handler_module, r.definition.handler_fn
101 ),
102 name: r.definition.name.clone(),
103 middleware: Vec::new(),
104 })
105 .collect(),
106 }
107}
108
109pub fn generate_json_string(project_path: &Path) -> Result<String, String> {
111 let routes = scan_routes(project_path)?;
112 let json = routes_to_json(&routes);
113 serde_json::to_string_pretty(&json).map_err(|e| format!("JSON serialize: {e}"))
114}
115
116pub fn run_json() {
119 let project_path = Path::new(".");
120 if !project_path.join("Cargo.toml").exists() {
121 eprintln!(
122 "{} Not a Ferro project (no Cargo.toml found)",
123 style("Error:").red().bold()
124 );
125 std::process::exit(1);
126 }
127 match generate_json_string(project_path) {
128 Ok(json) => {
129 println!("{json}");
130 }
131 Err(e) => {
132 eprintln!("{} {}", style("Error:").red().bold(), e);
133 std::process::exit(1);
134 }
135 }
136}
137
138#[derive(Debug, Clone)]
140pub struct PathParam {
141 pub name: String,
142}
143
144#[derive(Debug, Clone)]
146pub struct RouteDefinition {
147 pub method: HttpMethod,
148 pub path: String,
149 pub handler_module: String, pub handler_fn: String, pub name: Option<String>, pub path_params: Vec<PathParam>,
153}
154
155#[derive(Debug, Clone)]
157#[allow(dead_code)]
158pub struct HandlerInfo {
159 pub name: String,
160 pub has_handler_attr: bool,
161 pub request_type: Option<String>,
162}
163
164#[derive(Debug, Clone)]
166pub struct FormRequestStruct {
167 pub name: String,
168 pub fields: Vec<FormRequestField>,
169}
170
171#[derive(Debug, Clone)]
172pub struct FormRequestField {
173 pub name: String,
174 pub ty: RustType,
175}
176
177#[derive(Debug, Clone)]
179pub enum RustType {
180 String,
181 Number,
182 Bool,
183 Option(Box<RustType>),
184 Vec(Box<RustType>),
185 Custom(String),
186}
187
188#[derive(Debug, Clone)]
190#[allow(dead_code)]
191pub struct GeneratedRoute {
192 pub definition: RouteDefinition,
193 pub handler_info: Option<HandlerInfo>,
194 pub request_struct: Option<FormRequestStruct>,
195}
196
197pub fn parse_routes_file(content: &str) -> Vec<RouteDefinition> {
199 let mut routes = Vec::new();
200
201 let route_pattern = Regex::new(
205 r#"(get|post|put|patch|delete)!\s*\(\s*"([^"]+)"\s*,\s*([a-zA-Z_][a-zA-Z0-9_:]*)\s*\)(?:\s*\.name\s*\(\s*"([^"]+)"\s*\))?"#
206 ).unwrap();
207
208 let param_pattern = Regex::new(r#"\{(\w+)\}"#).unwrap();
210
211 for cap in route_pattern.captures_iter(content) {
212 let method_str = cap.get(1).map(|m| m.as_str()).unwrap_or("");
213 let path = cap.get(2).map(|m| m.as_str()).unwrap_or("");
214 let handler_path = cap.get(3).map(|m| m.as_str()).unwrap_or("");
215 let name = cap.get(4).map(|m| m.as_str().to_string());
216
217 let method = match HttpMethod::from_str(method_str) {
218 Some(m) => m,
219 None => continue,
220 };
221
222 let parts: Vec<&str> = handler_path.rsplitn(2, "::").collect();
224 let (handler_fn, handler_module) = if parts.len() == 2 {
225 (parts[0].to_string(), parts[1].to_string())
226 } else {
227 continue;
228 };
229
230 let path_params: Vec<PathParam> = param_pattern
232 .captures_iter(path)
233 .filter_map(|cap| {
234 cap.get(1).map(|m| PathParam {
235 name: m.as_str().to_string(),
236 })
237 })
238 .collect();
239
240 routes.push(RouteDefinition {
241 method,
242 path: path.to_string(),
243 handler_module,
244 handler_fn,
245 name,
246 path_params,
247 });
248 }
249
250 routes
251}
252
253struct HandlerVisitor {
255 handlers: Vec<HandlerInfo>,
256}
257
258impl HandlerVisitor {
259 fn new() -> Self {
260 Self {
261 handlers: Vec::new(),
262 }
263 }
264
265 fn has_handler_attr(&self, attrs: &[Attribute]) -> bool {
266 attrs.iter().any(|attr| attr.path().is_ident("handler"))
267 }
268
269 fn extract_request_type(&self, func: &ItemFn) -> Option<String> {
270 if let Some(FnArg::Typed(pat_type)) = func.sig.inputs.first() {
272 return self.type_to_string(&pat_type.ty);
273 }
274 None
275 }
276
277 fn type_to_string(&self, ty: &Type) -> Option<String> {
278 match ty {
279 Type::Path(type_path) => {
280 let segments: Vec<String> = type_path
281 .path
282 .segments
283 .iter()
284 .map(|s| s.ident.to_string())
285 .collect();
286
287 let type_name = segments.last()?.clone();
288
289 if type_name == "Request" {
291 return None;
292 }
293
294 Some(type_name)
295 }
296 _ => None,
297 }
298 }
299}
300
301impl<'ast> Visit<'ast> for HandlerVisitor {
302 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
303 let has_handler = self.has_handler_attr(&node.attrs);
304 let request_type = if has_handler {
305 self.extract_request_type(node)
306 } else {
307 None
308 };
309
310 self.handlers.push(HandlerInfo {
311 name: node.sig.ident.to_string(),
312 has_handler_attr: has_handler,
313 request_type,
314 });
315
316 syn::visit::visit_item_fn(self, node);
317 }
318}
319
320struct FormRequestVisitor {
322 structs: Vec<FormRequestStruct>,
323}
324
325impl FormRequestVisitor {
326 fn new() -> Self {
327 Self {
328 structs: Vec::new(),
329 }
330 }
331
332 fn has_form_request_attr(&self, attrs: &[Attribute]) -> bool {
333 for attr in attrs {
334 if attr.path().is_ident("form_request") {
336 return true;
337 }
338 if attr.path().is_ident("derive") {
340 if let Ok(nested) = attr.parse_args_with(
341 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
342 ) {
343 for path in nested {
344 if path.is_ident("FormRequest") {
345 return true;
346 }
347 }
348 }
349 }
350 }
351 false
352 }
353
354 fn parse_type(ty: &Type) -> RustType {
355 match ty {
356 Type::Path(type_path) => {
357 let segment = type_path.path.segments.last().unwrap();
358 let ident = segment.ident.to_string();
359
360 match ident.as_str() {
361 "String" | "str" => RustType::String,
362 "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32"
363 | "u64" | "u128" | "usize" | "f32" | "f64" => RustType::Number,
364 "bool" => RustType::Bool,
365 "Option" => {
366 if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
367 if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
368 return RustType::Option(Box::new(Self::parse_type(inner_ty)));
369 }
370 }
371 RustType::Option(Box::new(RustType::Custom("unknown".to_string())))
372 }
373 "Vec" => {
374 if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
375 if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
376 return RustType::Vec(Box::new(Self::parse_type(inner_ty)));
377 }
378 }
379 RustType::Vec(Box::new(RustType::Custom("unknown".to_string())))
380 }
381 other => RustType::Custom(other.to_string()),
382 }
383 }
384 Type::Reference(type_ref) => {
385 if let Type::Path(inner) = &*type_ref.elem {
386 if inner
387 .path
388 .segments
389 .last()
390 .map(|s| s.ident == "str")
391 .unwrap_or(false)
392 {
393 return RustType::String;
394 }
395 }
396 Self::parse_type(&type_ref.elem)
397 }
398 _ => RustType::Custom("unknown".to_string()),
399 }
400 }
401}
402
403impl<'ast> Visit<'ast> for FormRequestVisitor {
404 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
405 if self.has_form_request_attr(&node.attrs) {
406 let name = node.ident.to_string();
407
408 let fields = match &node.fields {
409 Fields::Named(named) => named
410 .named
411 .iter()
412 .filter_map(|f| {
413 f.ident.as_ref().map(|ident| FormRequestField {
414 name: ident.to_string(),
415 ty: Self::parse_type(&f.ty),
416 })
417 })
418 .collect(),
419 _ => Vec::new(),
420 };
421
422 self.structs.push(FormRequestStruct { name, fields });
423 }
424
425 syn::visit::visit_item_struct(self, node);
426 }
427}
428
429fn scan_controller_handlers(content: &str) -> Vec<HandlerInfo> {
431 if let Ok(syntax) = syn::parse_file(content) {
432 let mut visitor = HandlerVisitor::new();
433 visitor.visit_file(&syntax);
434 return visitor.handlers;
435 }
436 Vec::new()
437}
438
439fn scan_form_requests(project_path: &Path) -> HashMap<String, FormRequestStruct> {
441 let src_path = project_path.join("src");
442 let mut form_requests = HashMap::new();
443
444 for entry in WalkDir::new(&src_path)
445 .into_iter()
446 .filter_map(|e| e.ok())
447 .filter(|e| e.path().extension().map(|ext| ext == "rs").unwrap_or(false))
448 {
449 if let Ok(content) = fs::read_to_string(entry.path()) {
450 if let Ok(syntax) = syn::parse_file(&content) {
451 let mut visitor = FormRequestVisitor::new();
452 visitor.visit_file(&syntax);
453 for s in visitor.structs {
454 form_requests.insert(s.name.clone(), s);
455 }
456 }
457 }
458 }
459
460 form_requests
461}
462
463fn resolve_module_to_file(project_path: &Path, module_path: &str) -> Option<std::path::PathBuf> {
466 let parts: Vec<&str> = module_path.split("::").collect();
467 if parts.is_empty() {
468 return None;
469 }
470
471 let file_path = project_path
473 .join("src")
474 .join(parts.join("/"))
475 .with_extension("rs");
476 if file_path.exists() {
477 return Some(file_path);
478 }
479
480 let mod_path = project_path
482 .join("src")
483 .join(parts.join("/"))
484 .join("mod.rs");
485 if mod_path.exists() {
486 return Some(mod_path);
487 }
488
489 None
490}
491
492pub fn scan_routes(project_path: &Path) -> Result<Vec<GeneratedRoute>, String> {
494 let routes_file = project_path.join("src/routes.rs");
496 if !routes_file.exists() {
497 return Err("src/routes.rs not found".to_string());
498 }
499
500 let routes_content =
501 fs::read_to_string(&routes_file).map_err(|e| format!("Failed to read routes.rs: {e}"))?;
502
503 let route_definitions = parse_routes_file(&routes_content);
504
505 let form_requests = scan_form_requests(project_path);
507
508 let mut generated_routes = Vec::new();
510
511 for def in route_definitions {
512 let handler_info = if let Some(controller_file) =
514 resolve_module_to_file(project_path, &def.handler_module)
515 {
516 if let Ok(content) = fs::read_to_string(&controller_file) {
517 let handlers = scan_controller_handlers(&content);
518 handlers.into_iter().find(|h| h.name == def.handler_fn)
519 } else {
520 None
521 }
522 } else {
523 None
524 };
525
526 let request_struct = handler_info
528 .as_ref()
529 .and_then(|h| h.request_type.as_ref())
530 .and_then(|type_name| form_requests.get(type_name).cloned());
531
532 generated_routes.push(GeneratedRoute {
533 definition: def,
534 handler_info,
535 request_struct,
536 });
537 }
538
539 Ok(generated_routes)
540}
541
542fn rust_type_to_ts(ty: &RustType) -> String {
544 match ty {
545 RustType::String => "string".to_string(),
546 RustType::Number => "number".to_string(),
547 RustType::Bool => "boolean".to_string(),
548 RustType::Option(inner) => format!("{} | null", rust_type_to_ts(inner)),
549 RustType::Vec(inner) => format!("{}[]", rust_type_to_ts(inner)),
550 RustType::Custom(name) => name.clone(),
551 }
552}
553
554pub fn generate_typescript(routes: &[GeneratedRoute]) -> String {
556 let mut output = String::new();
557
558 output.push_str("// This file is auto-generated by Ferro. Do not edit manually.\n");
559 output.push_str("// Run `ferro generate-types` to regenerate.\n");
560 output.push_str("// Compatible with Inertia.js v2+ UrlMethodPair interface\n\n");
561
562 output.push_str("import type { Method } from '@inertiajs/core';\n\n");
563
564 output.push_str("// Route configuration - compatible with Inertia's UrlMethodPair\n");
566 output.push_str("export interface RouteConfig<TData = void> {\n");
567 output.push_str(" url: string;\n");
568 output.push_str(" method: Method; // 'get' | 'post' | 'put' | 'patch' | 'delete'\n");
569 output.push_str(" data?: TData;\n");
570 output.push_str("}\n\n");
571
572 let mut form_request_types: Vec<&FormRequestStruct> = routes
574 .iter()
575 .filter_map(|r| r.request_struct.as_ref())
576 .collect();
577 form_request_types.sort_by(|a, b| a.name.cmp(&b.name));
578 form_request_types.dedup_by(|a, b| a.name == b.name);
579
580 if !form_request_types.is_empty() {
582 output.push_str("// Request types (from #[form_request] structs)\n");
583 for form_req in &form_request_types {
584 output.push_str(&format!("export interface {} {{\n", form_req.name));
585 for field in &form_req.fields {
586 let ts_type = rust_type_to_ts(&field.ty);
587 output.push_str(&format!(" {}: {};\n", field.name, ts_type));
588 }
589 output.push_str("}\n\n");
590 }
591 }
592
593 let routes_with_params: Vec<&GeneratedRoute> = routes
595 .iter()
596 .filter(|r| !r.definition.path_params.is_empty())
597 .collect();
598
599 if !routes_with_params.is_empty() {
600 output.push_str("// Path parameter types\n");
601 for route in &routes_with_params {
602 let interface_name = generate_params_interface_name(route);
603 output.push_str(&format!("export interface {interface_name} {{\n"));
604 for param in &route.definition.path_params {
605 output.push_str(&format!(" {}: string;\n", param.name));
606 }
607 output.push_str("}\n\n");
608 }
609 }
610
611 let mut modules: HashMap<String, Vec<&GeneratedRoute>> = HashMap::new();
613 for route in routes {
614 let module_name = extract_controller_name(&route.definition.handler_module);
615 modules.entry(module_name).or_default().push(route);
616 }
617
618 output.push_str("// Controller namespace - mirrors backend structure\n");
620 output.push_str("export const controllers = {\n");
621
622 let mut module_names: Vec<&String> = modules.keys().collect();
623 module_names.sort();
624
625 for (i, module_name) in module_names.iter().enumerate() {
626 let module_routes = modules.get(*module_name).unwrap();
627 output.push_str(&format!(" {module_name}: {{\n"));
628
629 let mut used_names: HashMap<String, usize> = HashMap::new();
631
632 for (j, route) in module_routes.iter().enumerate() {
633 let base_fn_name = &route.definition.handler_fn;
635 let fn_name = if let Some(count) = used_names.get(base_fn_name) {
636 if let Some(name) = &route.definition.name {
638 name.split('.')
640 .next_back()
641 .unwrap_or(base_fn_name)
642 .to_string()
643 } else {
644 let path_name = route
646 .definition
647 .path
648 .trim_start_matches('/')
649 .replace(['/', '{', '}', '-'], "_");
650 if path_name.is_empty() {
651 format!("{}_{}", base_fn_name, count + 1)
652 } else {
653 path_name
654 }
655 }
656 } else {
657 base_fn_name.clone()
658 };
659 *used_names.entry(base_fn_name.clone()).or_insert(0) += 1;
660
661 let method = route.definition.method.to_ts_method();
662 let has_params = !route.definition.path_params.is_empty();
663 let has_data = route.request_struct.is_some();
664
665 let (params_signature, return_type) = if has_params && has_data {
667 let params_type = generate_params_interface_name(route);
668 let data_type = route.request_struct.as_ref().unwrap().name.clone();
669 (
670 format!("params: {params_type}, data: {data_type}"),
671 format!("RouteConfig<{data_type}>"),
672 )
673 } else if has_params {
674 let params_type = generate_params_interface_name(route);
675 (format!("params: {params_type}"), "RouteConfig".to_string())
676 } else if has_data {
677 let data_type = route.request_struct.as_ref().unwrap().name.clone();
678 (
679 format!("data: {data_type}"),
680 format!("RouteConfig<{data_type}>"),
681 )
682 } else {
683 (String::new(), "RouteConfig".to_string())
684 };
685
686 let url = if has_params {
688 generate_url_with_params(&route.definition.path)
689 } else {
690 format!("'{}'", route.definition.path)
691 };
692
693 let data_prop = if has_data { ", data" } else { "" };
695
696 let comma = if j < module_routes.len() - 1 { "," } else { "" };
697 output.push_str(&format!(
698 " {fn_name}: ({params_signature}): {return_type} => ({{ url: {url}, method: '{method}'{data_prop} }}){comma}\n"
699 ));
700 }
701
702 let comma = if i < module_names.len() - 1 { "," } else { "" };
703 output.push_str(&format!(" }}{comma}\n"));
704 }
705
706 output.push_str("} as const;\n\n");
707
708 let named_routes: Vec<&GeneratedRoute> = routes
710 .iter()
711 .filter(|r| r.definition.name.is_some())
712 .collect();
713
714 if !named_routes.is_empty() {
715 output.push_str("// Named routes lookup\n");
716 output.push_str("export const routes = {\n");
717
718 for (i, route) in named_routes.iter().enumerate() {
719 let name = route.definition.name.as_ref().unwrap();
720 let module = extract_controller_name(&route.definition.handler_module);
721 let fn_name = &route.definition.handler_fn;
722 let comma = if i < named_routes.len() - 1 { "," } else { "" };
723 output.push_str(&format!(
724 " '{name}': controllers.{module}.{fn_name}{comma}\n"
725 ));
726 }
727
728 output.push_str("} as const;\n");
729 }
730
731 output
732}
733
734fn generate_params_interface_name(route: &GeneratedRoute) -> String {
736 let module = extract_controller_name(&route.definition.handler_module);
738 let fn_name = &route.definition.handler_fn;
739 format!(
740 "{}{}Params",
741 to_pascal_case(&module),
742 to_pascal_case(fn_name)
743 )
744}
745
746fn to_pascal_case(s: &str) -> String {
748 s.split('_')
749 .map(|word| {
750 let mut chars = word.chars();
751 match chars.next() {
752 None => String::new(),
753 Some(first) => first.to_uppercase().chain(chars).collect(),
754 }
755 })
756 .collect()
757}
758
759fn extract_controller_name(module_path: &str) -> String {
765 let after_controllers = module_path
767 .strip_prefix("controllers::")
768 .unwrap_or(module_path);
769
770 let segments: Vec<&str> = after_controllers.split("::").collect();
772
773 if segments.is_empty() {
774 return "unknown".to_string();
775 }
776
777 segments.join("_")
780}
781
782fn generate_url_with_params(path: &str) -> String {
784 let param_pattern = Regex::new(r#"\{(\w+)\}"#).unwrap();
786 let mut result = path.to_string();
787
788 for cap in param_pattern.captures_iter(path) {
789 let full_match = cap.get(0).unwrap().as_str();
790 let param_name = cap.get(1).unwrap().as_str();
791 result = result.replace(full_match, &format!("${{params.{param_name}}}"));
792 }
793
794 format!("`{result}`")
795}
796
797pub fn generate_routes_to_file(project_path: &Path, output_path: &Path) -> Result<usize, String> {
799 let routes = scan_routes(project_path)?;
800
801 if routes.is_empty() {
802 return Ok(0);
803 }
804
805 if let Some(parent) = output_path.parent() {
807 fs::create_dir_all(parent)
808 .map_err(|e| format!("Failed to create output directory: {e}"))?;
809 }
810
811 let typescript = generate_typescript(&routes);
812 fs::write(output_path, typescript)
813 .map_err(|e| format!("Failed to write TypeScript file: {e}"))?;
814
815 Ok(routes.len())
816}
817
818pub fn run(output: Option<String>) {
820 let project_path = Path::new(".");
821
822 let cargo_toml = project_path.join("Cargo.toml");
824 if !cargo_toml.exists() {
825 eprintln!(
826 "{} Not a Ferro project (no Cargo.toml found)",
827 style("Error:").red().bold()
828 );
829 std::process::exit(1);
830 }
831
832 let output_path = output
833 .map(std::path::PathBuf::from)
834 .unwrap_or_else(|| project_path.join("frontend/src/types/routes.ts"));
835
836 println!(
837 "{}",
838 style("Scanning routes for type-safe generation...").cyan()
839 );
840
841 match generate_routes_to_file(project_path, &output_path) {
842 Ok(0) => {
843 println!("{}", style("No routes found in src/routes.rs").yellow());
844 }
845 Ok(count) => {
846 println!("{} Found {} route(s)", style("->").green(), count);
847 println!("{} Generated {}", style("✓").green(), output_path.display());
848 }
849 Err(e) => {
850 eprintln!("{} {}", style("Error:").red().bold(), e);
851 std::process::exit(1);
852 }
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
863 fn test_extract_controller_name_flat() {
864 assert_eq!(extract_controller_name("controllers::user"), "user");
866 }
867
868 #[test]
869 fn test_extract_controller_name_nested_single() {
870 assert_eq!(
872 extract_controller_name("controllers::shelter::dashboard"),
873 "shelter_dashboard"
874 );
875 }
876
877 #[test]
878 fn test_extract_controller_name_nested_deep() {
879 assert_eq!(
881 extract_controller_name("controllers::admin::settings::security"),
882 "admin_settings_security"
883 );
884 }
885
886 #[test]
887 fn test_extract_controller_name_no_prefix() {
888 assert_eq!(extract_controller_name("user"), "user");
890 }
891
892 #[test]
893 fn test_extract_controller_name_empty_after_prefix() {
894 assert_eq!(extract_controller_name("controllers::"), "");
896 }
897
898 #[test]
899 fn test_to_pascal_case_single_word() {
900 assert_eq!(to_pascal_case("user"), "User");
901 }
902
903 #[test]
904 fn test_to_pascal_case_snake_case() {
905 assert_eq!(to_pascal_case("user_profile"), "UserProfile");
906 }
907
908 #[test]
909 fn test_to_pascal_case_multi_segment() {
910 assert_eq!(to_pascal_case("admin_user_settings"), "AdminUserSettings");
911 }
912
913 #[test]
914 fn test_parse_routes_file_simple() {
915 let content = r#"
916 get!("/users", controllers::user::index);
917 post!("/users", controllers::user::store);
918 "#;
919
920 let routes = parse_routes_file(content);
921 assert_eq!(routes.len(), 2);
922 assert_eq!(routes[0].method, HttpMethod::Get);
923 assert_eq!(routes[0].path, "/users");
924 assert_eq!(routes[0].handler_module, "controllers::user");
925 assert_eq!(routes[0].handler_fn, "index");
926 }
927
928 #[test]
929 fn test_parse_routes_file_with_params() {
930 let content = r#"
931 get!("/users/{id}", controllers::user::show);
932 delete!("/users/{id}/posts/{post_id}", controllers::post::destroy);
933 "#;
934
935 let routes = parse_routes_file(content);
936 assert_eq!(routes.len(), 2);
937
938 assert_eq!(routes[0].path_params.len(), 1);
940 assert_eq!(routes[0].path_params[0].name, "id");
941
942 assert_eq!(routes[1].path_params.len(), 2);
944 assert_eq!(routes[1].path_params[0].name, "id");
945 assert_eq!(routes[1].path_params[1].name, "post_id");
946 }
947
948 #[test]
949 fn test_parse_routes_file_with_name() {
950 let content = r#"
951 get!("/", controllers::home::index).name("home");
952 get!("/dashboard", controllers::dashboard::index).name("dashboard.index");
953 "#;
954
955 let routes = parse_routes_file(content);
956 assert_eq!(routes.len(), 2);
957 assert_eq!(routes[0].name, Some("home".to_string()));
958 assert_eq!(routes[1].name, Some("dashboard.index".to_string()));
959 }
960
961 #[test]
962 fn test_parse_routes_file_nested_controller() {
963 let content = r#"
964 get!("/shelter/dashboard", controllers::shelter::dashboard::index);
965 get!("/adopter/dashboard", controllers::adopter::dashboard::index);
966 "#;
967
968 let routes = parse_routes_file(content);
969 assert_eq!(routes.len(), 2);
970 assert_eq!(routes[0].handler_module, "controllers::shelter::dashboard");
971 assert_eq!(routes[1].handler_module, "controllers::adopter::dashboard");
972 }
973
974 #[test]
975 fn test_generate_url_with_params() {
976 let result = generate_url_with_params("/users/{id}");
978 assert_eq!(result, "`/users/${params.id}`");
979 }
980
981 #[test]
982 fn test_generate_url_with_multiple_params() {
983 let result = generate_url_with_params("/users/{id}/posts/{post_id}");
985 assert_eq!(result, "`/users/${params.id}/posts/${params.post_id}`");
986 }
987
988 #[test]
989 fn test_generate_params_interface_name() {
990 let route = GeneratedRoute {
991 definition: RouteDefinition {
992 method: HttpMethod::Get,
993 path: "/users/{id}".to_string(),
994 handler_module: "controllers::user".to_string(),
995 handler_fn: "show".to_string(),
996 name: None,
997 path_params: vec![PathParam {
998 name: "id".to_string(),
999 }],
1000 },
1001 handler_info: None,
1002 request_struct: None,
1003 };
1004
1005 let name = generate_params_interface_name(&route);
1006 assert_eq!(name, "UserShowParams");
1007 }
1008
1009 #[test]
1010 fn test_generate_params_interface_name_nested_controller() {
1011 let route = GeneratedRoute {
1012 definition: RouteDefinition {
1013 method: HttpMethod::Get,
1014 path: "/shelter/applications/{id}".to_string(),
1015 handler_module: "controllers::shelter::applications".to_string(),
1016 handler_fn: "show".to_string(),
1017 name: None,
1018 path_params: vec![PathParam {
1019 name: "id".to_string(),
1020 }],
1021 },
1022 handler_info: None,
1023 request_struct: None,
1024 };
1025
1026 let name = generate_params_interface_name(&route);
1027 assert_eq!(name, "ShelterApplicationsShowParams");
1028 }
1029
1030 fn make_route(
1033 method: HttpMethod,
1034 path: &str,
1035 handler_module: &str,
1036 handler_fn: &str,
1037 name: Option<&str>,
1038 ) -> GeneratedRoute {
1039 GeneratedRoute {
1040 definition: RouteDefinition {
1041 method,
1042 path: path.to_string(),
1043 handler_module: handler_module.to_string(),
1044 handler_fn: handler_fn.to_string(),
1045 name: name.map(String::from),
1046 path_params: Vec::new(),
1047 },
1048 handler_info: None,
1049 request_struct: None,
1050 }
1051 }
1052
1053 #[test]
1054 fn json_single_get_route_serializes_to_stable_shape() {
1055 let routes = vec![make_route(
1056 HttpMethod::Get,
1057 "/users",
1058 "controllers::user",
1059 "index",
1060 None,
1061 )];
1062 let json = routes_to_json(&routes);
1063 let s = serde_json::to_string(&json).unwrap();
1064 assert_eq!(
1065 s,
1066 r#"{"routes":[{"method":"GET","path":"/users","handler":"controllers::user::index","name":null,"middleware":[]}]}"#
1067 );
1068 }
1069
1070 #[test]
1071 fn json_named_route_round_trips() {
1072 let routes = vec![make_route(
1073 HttpMethod::Get,
1074 "/users/{id}",
1075 "controllers::user",
1076 "show",
1077 Some("users.show"),
1078 )];
1079 let json = routes_to_json(&routes);
1080 let s = serde_json::to_string(&json).unwrap();
1081 let parsed: RoutesJson = serde_json::from_str(&s).unwrap();
1082 assert_eq!(parsed, json);
1083 assert_eq!(parsed.routes[0].name.as_deref(), Some("users.show"));
1084 }
1085
1086 #[test]
1087 fn json_patch_method_is_uppercase() {
1088 let routes = vec![make_route(
1089 HttpMethod::Patch,
1090 "/users/{id}",
1091 "controllers::user",
1092 "update",
1093 None,
1094 )];
1095 let json = routes_to_json(&routes);
1096 assert_eq!(json.routes[0].method, "PATCH");
1097 }
1098
1099 #[test]
1100 fn json_handler_combines_module_and_fn() {
1101 let routes = vec![make_route(
1102 HttpMethod::Post,
1103 "/posts",
1104 "controllers::blog::post",
1105 "store",
1106 None,
1107 )];
1108 let json = routes_to_json(&routes);
1109 assert_eq!(json.routes[0].handler, "controllers::blog::post::store");
1110 }
1111
1112 #[test]
1113 fn json_omits_path_params_field() {
1114 let mut r = make_route(
1115 HttpMethod::Get,
1116 "/users/{id}",
1117 "controllers::user",
1118 "show",
1119 None,
1120 );
1121 r.definition.path_params = vec![PathParam {
1122 name: "id".to_string(),
1123 }];
1124 let json = routes_to_json(&[r]);
1125 let s = serde_json::to_string(&json).unwrap();
1126 assert!(!s.contains("path_params"));
1127 assert!(!s.contains("\"params\""));
1128 }
1129
1130 #[test]
1131 fn json_middleware_always_present_even_when_empty() {
1132 let routes = vec![make_route(
1133 HttpMethod::Delete,
1134 "/users/{id}",
1135 "controllers::user",
1136 "destroy",
1137 None,
1138 )];
1139 let json = routes_to_json(&routes);
1140 let s = serde_json::to_string(&json).unwrap();
1141 assert!(s.contains("\"middleware\":[]"));
1142 assert_eq!(json.routes[0].middleware, Vec::<String>::new());
1143 }
1144
1145 #[test]
1146 fn json_three_route_fixture_round_trips() {
1147 let routes = vec![
1148 make_route(
1149 HttpMethod::Get,
1150 "/users",
1151 "controllers::user",
1152 "index",
1153 Some("users.index"),
1154 ),
1155 make_route(
1156 HttpMethod::Post,
1157 "/users",
1158 "controllers::user",
1159 "store",
1160 Some("users.store"),
1161 ),
1162 make_route(
1163 HttpMethod::Delete,
1164 "/users/{id}",
1165 "controllers::user",
1166 "destroy",
1167 Some("users.destroy"),
1168 ),
1169 ];
1170 let json = routes_to_json(&routes);
1171 let s = serde_json::to_string_pretty(&json).unwrap();
1172 let parsed: RoutesJson = serde_json::from_str(&s).unwrap();
1173 assert_eq!(parsed, json);
1174 assert_eq!(parsed.routes.len(), 3);
1175 assert_eq!(parsed.routes[0].method, "GET");
1176 assert_eq!(parsed.routes[1].method, "POST");
1177 assert_eq!(parsed.routes[2].method, "DELETE");
1178 }
1179}