Skip to main content

ferro_cli/commands/
generate_routes.rs

1//! Route generation for type-safe frontend integration
2//!
3//! Generates TypeScript route helpers compatible with Inertia.js v2+ UrlMethodPair interface.
4//! This allows type-safe navigation with:
5//! - `router.visit(controllers.user.show({ id: '123' }))`
6//! - `form.submit(controllers.todo.store({ title: 'Task', completed: false }))`
7//! - `<Link href={controllers.user.index()}>Users</Link>`
8
9use 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/// HTTP methods for routes
20#[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    /// Uppercase HTTP verb used by the stable JSON schema.
52    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/// Stable JSON schema for `ferro generate-routes --json` (D-10..D-12).
64///
65/// Field names and shape are a PUBLIC CONTRACT consumed by ferro-mcp and
66/// external agents. Additions are allowed; renames or removals are breaking
67/// changes and require a major version bump.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct RoutesJson {
70    pub routes: Vec<RouteJson>,
71}
72
73/// Single route entry in [`RoutesJson`].
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct RouteJson {
76    /// Uppercase HTTP verb: `"GET" | "POST" | "PUT" | "PATCH" | "DELETE"`.
77    pub method: String,
78    /// Path with `{param}` placeholders, e.g. `"/users/{id}"`.
79    pub path: String,
80    /// Fully-qualified handler: `"controllers::user::show"`.
81    pub handler: String,
82    /// Optional named route, e.g. `"users.show"`.
83    pub name: Option<String>,
84    /// Middleware names attached to this route. Always present (may be empty
85    /// in Phase 124 — middleware parsing is future work, but the field is
86    /// part of the stable contract and never omitted).
87    pub middleware: Vec<String>,
88}
89
90/// Convert scanned routes into the stable [`RoutesJson`] schema.
91pub 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
109/// Scan routes and serialize to a pretty JSON string.
110pub 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
116/// Entry point for `ferro generate-routes --json`. Prints to stdout, exits
117/// non-zero on error.
118pub 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/// A path parameter extracted from route patterns like /users/{id}
139#[derive(Debug, Clone)]
140pub struct PathParam {
141    pub name: String,
142}
143
144/// A parsed route definition from routes.rs
145#[derive(Debug, Clone)]
146pub struct RouteDefinition {
147    pub method: HttpMethod,
148    pub path: String,
149    pub handler_module: String, // e.g., "controllers::user"
150    pub handler_fn: String,     // e.g., "show"
151    pub name: Option<String>,   // e.g., "users.show"
152    pub path_params: Vec<PathParam>,
153}
154
155/// Information about a handler function
156#[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/// A form request struct definition
165#[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/// Rust type representation for TypeScript conversion
178#[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/// A complete route ready for TypeScript generation
189#[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
197/// Parse routes.rs file content and extract route definitions
198pub fn parse_routes_file(content: &str) -> Vec<RouteDefinition> {
199    let mut routes = Vec::new();
200
201    // Pattern to match route definitions like:
202    // get!("/path", controllers::module::function).name("route.name")
203    // post!("/path/{id}", controllers::module::function)
204    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    // Pattern to extract path parameters like {id}
209    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        // Parse handler path: controllers::user::show -> (controllers::user, show)
223        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        // Extract path parameters
231        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
253/// Visitor that collects handler functions with `#[handler]` attribute
254struct 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        // Get the first parameter's type
271        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                // Skip if it's Request type (not a form request)
290                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
320/// Visitor that collects `#[form_request]` structs
321struct 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            // Check for #[form_request]
335            if attr.path().is_ident("form_request") {
336                return true;
337            }
338            // Check for #[derive(FormRequest)]
339            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
429/// Scan a controller file for handler functions
430fn 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
439/// Scan all Rust files for FormRequest structs
440fn 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
463/// Resolve handler module to file path
464/// e.g., "controllers::user" -> "src/controllers/user.rs"
465fn 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    // Try as a file directly: src/controllers/user.rs
472    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    // Try as module folder: src/controllers/user/mod.rs
481    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
492/// Scan routes and handlers to build GeneratedRoute list
493pub fn scan_routes(project_path: &Path) -> Result<Vec<GeneratedRoute>, String> {
494    // Read routes.rs
495    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    // Scan all form requests
506    let form_requests = scan_form_requests(project_path);
507
508    // Process each route
509    let mut generated_routes = Vec::new();
510
511    for def in route_definitions {
512        // Try to find the handler
513        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        // Find the form request struct if the handler has one
527        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
542/// Convert RustType to TypeScript type string
543fn 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
554/// Generate TypeScript routes file
555pub 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    // RouteConfig interface
565    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    // Collect all unique form request types
573    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    // Generate request type interfaces
581    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    // Collect all path param types
594    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    // Group routes by module (first part of handler_module after "controllers::")
612    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    // Generate controllers object
619    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        // Track used function names to handle duplicates
630        let mut used_names: HashMap<String, usize> = HashMap::new();
631
632        for (j, route) in module_routes.iter().enumerate() {
633            // Generate unique function name for duplicate handlers
634            let base_fn_name = &route.definition.handler_fn;
635            let fn_name = if let Some(count) = used_names.get(base_fn_name) {
636                // Use route name or path segment to make unique
637                if let Some(name) = &route.definition.name {
638                    // Use the last part of the route name: "home" from "home", "protected" from name
639                    name.split('.')
640                        .next_back()
641                        .unwrap_or(base_fn_name)
642                        .to_string()
643                } else {
644                    // Use path to create unique name
645                    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            // Determine function signature
666            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            // Generate URL with params interpolation
687            let url = if has_params {
688                generate_url_with_params(&route.definition.path)
689            } else {
690                format!("'{}'", route.definition.path)
691            };
692
693            // Generate the function body
694            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    // Generate named routes lookup
709    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
734/// Generate params interface name from route
735fn generate_params_interface_name(route: &GeneratedRoute) -> String {
736    // Convert handler to PascalCase: user::show -> UserShowParams
737    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
746/// Convert snake_case to PascalCase
747fn 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
759/// Extract controller name from module path, preserving hierarchy after "controllers::"
760/// Examples:
761/// - "controllers::user" -> "user"
762/// - "controllers::shelter::dashboard" -> "shelter_dashboard"
763/// - "controllers::adopter::applications" -> "adopter_applications"
764fn extract_controller_name(module_path: &str) -> String {
765    // Strip "controllers::" prefix
766    let after_controllers = module_path
767        .strip_prefix("controllers::")
768        .unwrap_or(module_path);
769
770    // Join remaining segments with underscore
771    let segments: Vec<&str> = after_controllers.split("::").collect();
772
773    if segments.is_empty() {
774        return "unknown".to_string();
775    }
776
777    // For single-level controllers (e.g., "user"), return as-is
778    // For nested controllers (e.g., "shelter::dashboard"), join with underscore
779    segments.join("_")
780}
781
782/// Generate URL template string with params interpolation
783fn generate_url_with_params(path: &str) -> String {
784    // Manually replace {param} with ${params.param} for JS template literals
785    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
797/// Generate routes and write to the output file
798pub 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    // Ensure output directory exists
806    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
818/// Main entry point for route generation (standalone use)
819pub fn run(output: Option<String>) {
820    let project_path = Path::new(".");
821
822    // Validate Ferro project
823    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    // ===== Controller Name Extraction Tests (Phase 22.5) =====
861
862    #[test]
863    fn test_extract_controller_name_flat() {
864        // controllers::user -> user
865        assert_eq!(extract_controller_name("controllers::user"), "user");
866    }
867
868    #[test]
869    fn test_extract_controller_name_nested_single() {
870        // controllers::shelter::dashboard -> shelter_dashboard
871        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        // controllers::admin::settings::security -> admin_settings_security
880        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        // user (no controllers:: prefix) -> user
889        assert_eq!(extract_controller_name("user"), "user");
890    }
891
892    #[test]
893    fn test_extract_controller_name_empty_after_prefix() {
894        // controllers:: -> empty string (edge case)
895        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        // First route has 1 param
939        assert_eq!(routes[0].path_params.len(), 1);
940        assert_eq!(routes[0].path_params[0].name, "id");
941
942        // Second route has 2 params
943        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        // /users/{id} -> `${params.id}`
977        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        // /users/{id}/posts/{post_id} -> `/users/${params.id}/posts/${params.post_id}`
984        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    // ===== JSON schema tests (Phase 124-02, D-10..D-12) =====
1031
1032    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}