Skip to main content

ferro_rs/routing/
macros.rs

1//! Route definition macros and helpers for Laravel-like routing syntax
2//!
3//! This module provides a clean, declarative way to define routes:
4//!
5//! ```rust,ignore
6//! use ferro_rs::{routes, get, post, put, delete, group};
7//!
8//! routes! {
9//!     get!("/", controllers::home::index).name("home"),
10//!     get!("/users", controllers::user::index).name("users.index"),
11//!     post!("/users", controllers::user::store).name("users.store"),
12//!     get!("/protected", controllers::home::index).middleware(AuthMiddleware),
13//!
14//!     // Route groups with prefix and middleware
15//!     group!("/api", {
16//!         get!("/users", controllers::api::user::index).name("api.users.index"),
17//!         post!("/users", controllers::api::user::store).name("api.users.store"),
18//!     }).middleware(AuthMiddleware),
19//! }
20//! ```
21
22use crate::http::{Request, Response};
23
24/// Const function to validate route paths start with '/'
25///
26/// This provides compile-time validation that all route paths begin with '/'.
27/// If a path doesn't start with '/', compilation will fail with a clear error.
28///
29/// # Panics
30///
31/// Panics at compile time if the path is empty or doesn't start with '/'.
32pub const fn validate_route_path(path: &'static str) -> &'static str {
33    let bytes = path.as_bytes();
34    if bytes.is_empty() || bytes[0] != b'/' {
35        panic!("Route path must start with '/'")
36    }
37    path
38}
39use super::path::combine_group_path;
40use super::router::update_route_mcp;
41use crate::middleware::{into_boxed, BoxedMiddleware, Middleware};
42use crate::routing::router::{register_route_name, BoxedHandler, Router};
43use std::future::Future;
44use std::sync::Arc;
45
46/// Convert Express-style `:param` route parameters to matchit-style `{param}`
47///
48/// This allows developers to use either syntax:
49/// - `/:id` (Express/Rails style)
50/// - `/{id}` (matchit native style)
51///
52/// # Examples
53///
54/// - `/users/:id` → `/users/{id}`
55/// - `/posts/:post_id/comments/:id` → `/posts/{post_id}/comments/{id}`
56/// - `/users/{id}` → `/users/{id}` (already correct syntax, unchanged)
57fn convert_route_params(path: &str) -> String {
58    let mut result = String::with_capacity(path.len() + 4); // Extra space for braces
59    let mut chars = path.chars().peekable();
60
61    while let Some(ch) = chars.next() {
62        if ch == ':' {
63            // Start of parameter - collect until '/' or end
64            result.push('{');
65            while let Some(&next) = chars.peek() {
66                if next == '/' {
67                    break;
68                }
69                result.push(chars.next().unwrap());
70            }
71            result.push('}');
72        } else {
73            result.push(ch);
74        }
75    }
76    result
77}
78
79/// HTTP method for route definitions
80#[derive(Clone, Copy)]
81pub enum HttpMethod {
82    /// HTTP GET method.
83    Get,
84    /// HTTP POST method.
85    Post,
86    /// HTTP PUT method.
87    Put,
88    /// HTTP PATCH method.
89    Patch,
90    /// HTTP DELETE method.
91    Delete,
92}
93
94/// Builder for route definitions that supports `.name()` and `.middleware()` chaining
95pub struct RouteDefBuilder<H> {
96    method: HttpMethod,
97    path: &'static str,
98    handler: H,
99    name: Option<&'static str>,
100    middlewares: Vec<BoxedMiddleware>,
101    mcp_tool_name: Option<String>,
102    mcp_description: Option<String>,
103    mcp_hint: Option<String>,
104    mcp_hidden: bool,
105}
106
107impl<H, Fut> RouteDefBuilder<H>
108where
109    H: Fn(Request) -> Fut + Send + Sync + 'static,
110    Fut: Future<Output = Response> + Send + 'static,
111{
112    /// Create a new route definition builder
113    pub fn new(method: HttpMethod, path: &'static str, handler: H) -> Self {
114        Self {
115            method,
116            path,
117            handler,
118            name: None,
119            middlewares: Vec::new(),
120            mcp_tool_name: None,
121            mcp_description: None,
122            mcp_hint: None,
123            mcp_hidden: false,
124        }
125    }
126
127    /// Name this route for URL generation
128    pub fn name(mut self, name: &'static str) -> Self {
129        self.name = Some(name);
130        self
131    }
132
133    /// Add middleware to this route
134    pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
135        self.middlewares.push(into_boxed(middleware));
136        self
137    }
138
139    /// Override the auto-generated MCP tool name for this route
140    pub fn mcp_tool_name(mut self, name: &str) -> Self {
141        self.mcp_tool_name = Some(name.to_string());
142        self
143    }
144
145    /// Override the auto-generated MCP description for this route
146    pub fn mcp_description(mut self, desc: &str) -> Self {
147        self.mcp_description = Some(desc.to_string());
148        self
149    }
150
151    /// Add a hint that is appended to the MCP description for AI guidance
152    pub fn mcp_hint(mut self, hint: &str) -> Self {
153        self.mcp_hint = Some(hint.to_string());
154        self
155    }
156
157    /// Hide this route from MCP tool discovery
158    pub fn mcp_hidden(mut self) -> Self {
159        self.mcp_hidden = true;
160        self
161    }
162
163    /// Register this route definition with a router
164    pub fn register(self, router: Router) -> Router {
165        // Convert :param to {param} for matchit compatibility
166        let converted_path = convert_route_params(self.path);
167
168        // Capture MCP fields before moving self
169        let has_mcp = self.mcp_tool_name.is_some()
170            || self.mcp_description.is_some()
171            || self.mcp_hint.is_some()
172            || self.mcp_hidden;
173        let mcp_tool_name = self.mcp_tool_name;
174        let mcp_description = self.mcp_description;
175        let mcp_hint = self.mcp_hint;
176        let mcp_hidden = self.mcp_hidden;
177
178        // First, register the route based on method
179        let builder = match self.method {
180            HttpMethod::Get => router.get(&converted_path, self.handler),
181            HttpMethod::Post => router.post(&converted_path, self.handler),
182            HttpMethod::Put => router.put(&converted_path, self.handler),
183            HttpMethod::Patch => router.patch(&converted_path, self.handler),
184            HttpMethod::Delete => router.delete(&converted_path, self.handler),
185        };
186
187        // Apply any middleware
188        let builder = self
189            .middlewares
190            .into_iter()
191            .fold(builder, |b, m| b.middleware_boxed(m));
192
193        // Apply MCP metadata if any fields are set
194        if has_mcp {
195            update_route_mcp(
196                &converted_path,
197                mcp_tool_name,
198                mcp_description,
199                mcp_hint,
200                mcp_hidden,
201            );
202        }
203
204        // Apply name if present, otherwise convert to Router
205        if let Some(name) = self.name {
206            builder.name(name)
207        } else {
208            builder.into()
209        }
210    }
211}
212
213/// Create a GET route definition with compile-time path validation
214///
215/// # Example
216/// ```rust,ignore
217/// get!("/users", controllers::user::index).name("users.index")
218/// ```
219///
220/// # Compile Error
221///
222/// Fails to compile if path doesn't start with '/'.
223#[macro_export]
224macro_rules! get {
225    ($path:expr, $handler:expr) => {{
226        const _: &str = $crate::validate_route_path($path);
227        $crate::__get_impl($path, $handler)
228    }};
229}
230
231/// Internal implementation for GET routes (used by the get! macro)
232#[doc(hidden)]
233pub fn __get_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
234where
235    H: Fn(Request) -> Fut + Send + Sync + 'static,
236    Fut: Future<Output = Response> + Send + 'static,
237{
238    RouteDefBuilder::new(HttpMethod::Get, path, handler)
239}
240
241/// Create a POST route definition with compile-time path validation
242///
243/// # Example
244/// ```rust,ignore
245/// post!("/users", controllers::user::store).name("users.store")
246/// ```
247///
248/// # Compile Error
249///
250/// Fails to compile if path doesn't start with '/'.
251#[macro_export]
252macro_rules! post {
253    ($path:expr, $handler:expr) => {{
254        const _: &str = $crate::validate_route_path($path);
255        $crate::__post_impl($path, $handler)
256    }};
257}
258
259/// Internal implementation for POST routes (used by the post! macro)
260#[doc(hidden)]
261pub fn __post_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
262where
263    H: Fn(Request) -> Fut + Send + Sync + 'static,
264    Fut: Future<Output = Response> + Send + 'static,
265{
266    RouteDefBuilder::new(HttpMethod::Post, path, handler)
267}
268
269/// Create a PUT route definition with compile-time path validation
270///
271/// # Example
272/// ```rust,ignore
273/// put!("/users/{id}", controllers::user::update).name("users.update")
274/// ```
275///
276/// # Compile Error
277///
278/// Fails to compile if path doesn't start with '/'.
279#[macro_export]
280macro_rules! put {
281    ($path:expr, $handler:expr) => {{
282        const _: &str = $crate::validate_route_path($path);
283        $crate::__put_impl($path, $handler)
284    }};
285}
286
287/// Internal implementation for PUT routes (used by the put! macro)
288#[doc(hidden)]
289pub fn __put_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
290where
291    H: Fn(Request) -> Fut + Send + Sync + 'static,
292    Fut: Future<Output = Response> + Send + 'static,
293{
294    RouteDefBuilder::new(HttpMethod::Put, path, handler)
295}
296
297/// Create a PATCH route definition with compile-time path validation
298///
299/// # Example
300/// ```rust,ignore
301/// patch!("/users/{id}", controllers::user::patch).name("users.patch")
302/// ```
303///
304/// # Compile Error
305///
306/// Fails to compile if path doesn't start with '/'.
307#[macro_export]
308macro_rules! patch {
309    ($path:expr, $handler:expr) => {{
310        const _: &str = $crate::validate_route_path($path);
311        $crate::__patch_impl($path, $handler)
312    }};
313}
314
315/// Internal implementation for PATCH routes (used by the patch! macro)
316#[doc(hidden)]
317pub fn __patch_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
318where
319    H: Fn(Request) -> Fut + Send + Sync + 'static,
320    Fut: Future<Output = Response> + Send + 'static,
321{
322    RouteDefBuilder::new(HttpMethod::Patch, path, handler)
323}
324
325/// Create a DELETE route definition with compile-time path validation
326///
327/// # Example
328/// ```rust,ignore
329/// delete!("/users/{id}", controllers::user::destroy).name("users.destroy")
330/// ```
331///
332/// # Compile Error
333///
334/// Fails to compile if path doesn't start with '/'.
335#[macro_export]
336macro_rules! delete {
337    ($path:expr, $handler:expr) => {{
338        const _: &str = $crate::validate_route_path($path);
339        $crate::__delete_impl($path, $handler)
340    }};
341}
342
343/// Internal implementation for DELETE routes (used by the delete! macro)
344#[doc(hidden)]
345pub fn __delete_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
346where
347    H: Fn(Request) -> Fut + Send + Sync + 'static,
348    Fut: Future<Output = Response> + Send + 'static,
349{
350    RouteDefBuilder::new(HttpMethod::Delete, path, handler)
351}
352
353// ============================================================================
354// Fallback Route Support
355// ============================================================================
356
357/// Builder for fallback route definitions that supports `.middleware()` chaining
358///
359/// The fallback route is invoked when no other routes match, allowing custom
360/// handling of 404 scenarios.
361pub struct FallbackDefBuilder<H> {
362    handler: H,
363    middlewares: Vec<BoxedMiddleware>,
364}
365
366impl<H, Fut> FallbackDefBuilder<H>
367where
368    H: Fn(Request) -> Fut + Send + Sync + 'static,
369    Fut: Future<Output = Response> + Send + 'static,
370{
371    /// Create a new fallback definition builder
372    pub fn new(handler: H) -> Self {
373        Self {
374            handler,
375            middlewares: Vec::new(),
376        }
377    }
378
379    /// Add middleware to this fallback route
380    pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
381        self.middlewares.push(into_boxed(middleware));
382        self
383    }
384
385    /// Register this fallback definition with a router
386    pub fn register(self, mut router: Router) -> Router {
387        let handler = self.handler;
388        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
389        router.set_fallback(Arc::new(boxed));
390
391        // Apply middleware
392        for mw in self.middlewares {
393            router.add_fallback_middleware(mw);
394        }
395
396        router
397    }
398}
399
400/// Create a fallback route definition
401///
402/// The fallback handler is called when no other routes match the request,
403/// allowing you to override the default 404 behavior.
404///
405/// # Example
406/// ```rust,ignore
407/// routes! {
408///     get!("/", controllers::home::index),
409///     get!("/users", controllers::user::index),
410///
411///     // Custom 404 handler
412///     fallback!(controllers::fallback::invoke),
413/// }
414/// ```
415///
416/// With middleware:
417/// ```rust,ignore
418/// routes! {
419///     get!("/", controllers::home::index),
420///     fallback!(controllers::fallback::invoke).middleware(LoggingMiddleware),
421/// }
422/// ```
423#[macro_export]
424macro_rules! fallback {
425    ($handler:expr) => {{
426        $crate::__fallback_impl($handler)
427    }};
428}
429
430/// Internal implementation for fallback routes (used by the fallback! macro)
431#[doc(hidden)]
432pub fn __fallback_impl<H, Fut>(handler: H) -> FallbackDefBuilder<H>
433where
434    H: Fn(Request) -> Fut + Send + Sync + 'static,
435    Fut: Future<Output = Response> + Send + 'static,
436{
437    FallbackDefBuilder::new(handler)
438}
439
440// ============================================================================
441// Route Grouping Support
442// ============================================================================
443
444/// A route stored within a group (type-erased handler)
445pub struct GroupRoute {
446    method: HttpMethod,
447    path: &'static str,
448    handler: Arc<BoxedHandler>,
449    name: Option<&'static str>,
450    middlewares: Vec<BoxedMiddleware>,
451    mcp_tool_name: Option<String>,
452    mcp_description: Option<String>,
453    mcp_hint: Option<String>,
454    mcp_hidden: bool,
455}
456
457/// An item that can be added to a route group - either a route or a nested group
458pub enum GroupItem {
459    /// A single route
460    Route(GroupRoute),
461    /// A nested group with its own prefix and middleware
462    NestedGroup(Box<GroupDef>),
463}
464
465/// Trait for types that can be converted into a GroupItem
466pub trait IntoGroupItem {
467    /// Convert this value into a group item for the route builder.
468    fn into_group_item(self) -> GroupItem;
469}
470
471/// MCP defaults inherited from parent groups to child routes
472#[derive(Default, Clone)]
473struct McpDefaults {
474    tool_name: Option<String>,
475    description: Option<String>,
476    hint: Option<String>,
477    hidden: bool,
478}
479
480/// Group definition that collects routes and applies prefix/middleware
481///
482/// Supports nested groups for arbitrary route organization:
483///
484/// ```rust,ignore
485/// routes! {
486///     group!("/api", {
487///         get!("/users", controllers::user::index).name("api.users"),
488///         post!("/users", controllers::user::store),
489///         // Nested groups are supported
490///         group!("/admin", {
491///             get!("/dashboard", controllers::admin::dashboard),
492///         }),
493///     }).middleware(AuthMiddleware),
494/// }
495/// ```
496pub struct GroupDef {
497    prefix: &'static str,
498    items: Vec<GroupItem>,
499    group_middlewares: Vec<BoxedMiddleware>,
500    mcp_tool_name: Option<String>,
501    mcp_description: Option<String>,
502    mcp_hint: Option<String>,
503    mcp_hidden: bool,
504}
505
506impl GroupDef {
507    /// Create a new route group with the given prefix (internal use)
508    ///
509    /// Use the `group!` macro instead for compile-time validation.
510    #[doc(hidden)]
511    pub fn __new_unchecked(prefix: &'static str) -> Self {
512        Self {
513            prefix,
514            items: Vec::new(),
515            group_middlewares: Vec::new(),
516            mcp_tool_name: None,
517            mcp_description: None,
518            mcp_hint: None,
519            mcp_hidden: false,
520        }
521    }
522
523    /// Add an item (route or nested group) to this group
524    ///
525    /// This is the primary method for adding items to a group. It accepts
526    /// anything that implements `IntoGroupItem`, including routes created
527    /// with `get!`, `post!`, etc., and nested groups created with `group!`.
528    #[allow(clippy::should_implement_trait)]
529    pub fn add<I: IntoGroupItem>(mut self, item: I) -> Self {
530        self.items.push(item.into_group_item());
531        self
532    }
533
534    /// Add a route to this group (backward compatibility)
535    ///
536    /// Prefer using `.add()` which accepts both routes and nested groups.
537    pub fn route<H, Fut>(self, route: RouteDefBuilder<H>) -> Self
538    where
539        H: Fn(Request) -> Fut + Send + Sync + 'static,
540        Fut: Future<Output = Response> + Send + 'static,
541    {
542        self.add(route)
543    }
544
545    /// Add middleware to all routes in this group
546    ///
547    /// Middleware is applied in the order it's added.
548    ///
549    /// # Example
550    ///
551    /// ```rust,ignore
552    /// group!("/api", {
553    ///     get!("/users", handler),
554    /// }).middleware(AuthMiddleware).middleware(RateLimitMiddleware)
555    /// ```
556    pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
557        self.group_middlewares.push(into_boxed(middleware));
558        self
559    }
560
561    /// Set a default MCP tool name for all routes in this group
562    ///
563    /// Route-level overrides take precedence.
564    pub fn mcp_tool_name(mut self, name: &str) -> Self {
565        self.mcp_tool_name = Some(name.to_string());
566        self
567    }
568
569    /// Set a default MCP description for all routes in this group
570    ///
571    /// Route-level overrides take precedence.
572    pub fn mcp_description(mut self, desc: &str) -> Self {
573        self.mcp_description = Some(desc.to_string());
574        self
575    }
576
577    /// Set a default MCP hint for all routes in this group
578    ///
579    /// Route-level overrides take precedence.
580    pub fn mcp_hint(mut self, hint: &str) -> Self {
581        self.mcp_hint = Some(hint.to_string());
582        self
583    }
584
585    /// Hide all routes in this group from MCP tool discovery
586    ///
587    /// Route-level overrides take precedence.
588    pub fn mcp_hidden(mut self) -> Self {
589        self.mcp_hidden = true;
590        self
591    }
592
593    /// Register all routes in this group with the router
594    ///
595    /// This prepends the group prefix to each route path and applies
596    /// group middleware to all routes. Nested groups are flattened recursively,
597    /// with prefixes concatenated and middleware inherited from parent groups.
598    ///
599    /// # Path Combination
600    ///
601    /// - If the route path is `"/"` and the accumulated group prefix is
602    ///   non-empty, the handler is registered under both `/prefix` and
603    ///   `/prefix/` so it is reachable at either URL. Route introspection
604    ///   and named-route lookup still report one canonical entry at
605    ///   `/prefix` (no trailing slash).
606    /// - A single trailing `/` on the group prefix is stripped before
607    ///   concatenation, so `group!("/api/", { get!("/x", ...) })` produces
608    ///   `/api/x` rather than `/api//x`.
609    /// - A root prefix `"/"` combined with a root route `"/"` stays `"/"`
610    ///   (no `"//"` is ever registered).
611    ///
612    /// # Middleware Inheritance
613    ///
614    /// Parent group middleware is applied before child group middleware,
615    /// which is applied before route-specific middleware.
616    pub fn register(self, mut router: Router) -> Router {
617        let mcp_defaults = McpDefaults::default();
618        self.register_with_inherited(&mut router, "", &[], &mcp_defaults);
619        router
620    }
621
622    /// Internal recursive registration with inherited prefix and middleware
623    fn register_with_inherited(
624        self,
625        router: &mut Router,
626        parent_prefix: &str,
627        inherited_middleware: &[BoxedMiddleware],
628        inherited_mcp: &McpDefaults,
629    ) {
630        // Build the full prefix for this group. Strip a trailing `/` from the
631        // parent prefix before concatenation so nested groups like
632        // `group!("/a/", { group!("/b", ...) })` accumulate to `/a/b` instead
633        // of `/a//b` (D-06, RESEARCH §Pitfall 3).
634        let full_prefix = if parent_prefix.is_empty() {
635            self.prefix.to_string()
636        } else {
637            let stripped_parent = parent_prefix.strip_suffix('/').unwrap_or(parent_prefix);
638            format!("{}{}", stripped_parent, self.prefix)
639        };
640
641        // Combine inherited middleware with this group's middleware
642        // Parent middleware runs first (outer), then this group's middleware
643        let combined_middleware: Vec<BoxedMiddleware> = inherited_middleware
644            .iter()
645            .cloned()
646            .chain(self.group_middlewares.iter().cloned())
647            .collect();
648
649        // Merge MCP defaults: this group's settings override inherited
650        let combined_mcp = McpDefaults {
651            tool_name: self.mcp_tool_name.or(inherited_mcp.tool_name.clone()),
652            description: self.mcp_description.or(inherited_mcp.description.clone()),
653            hint: self.mcp_hint.or(inherited_mcp.hint.clone()),
654            hidden: self.mcp_hidden || inherited_mcp.hidden,
655        };
656
657        for item in self.items {
658            match item {
659                GroupItem::Route(route) => {
660                    // Convert :param to {param} for matchit compatibility
661                    let converted_route_path = convert_route_params(route.path);
662
663                    // Combine the full prefix with the route path using the
664                    // shared helper. May emit a second matchit leaf so
665                    // `get!("/", ...)` inside a non-root group reaches its
666                    // handler at both `/prefix` and `/prefix/` (D-01 through
667                    // D-04, D-06).
668                    let (canonical, alternate) =
669                        combine_group_path(&full_prefix, &converted_route_path);
670                    let canonical_path: &'static str = Box::leak(canonical.into_boxed_str());
671                    let alternate_path: Option<&'static str> =
672                        alternate.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
673
674                    // Register the canonical route first (touches
675                    // `register_route`, which appends to `REGISTERED_ROUTES`
676                    // so `update_route_name` / `update_route_mcp` /
677                    // `update_route_middleware` later in this block resolve
678                    // via `.rev().find(path == canonical)`). Then register
679                    // the alternate as a matchit-only alias carrying the
680                    // canonical pattern string so middleware lookup resolves
681                    // under the canonical key (Strategy A).
682                    match route.method {
683                        HttpMethod::Get => {
684                            router.insert_get(canonical_path, route.handler.clone());
685                            if let Some(alt) = alternate_path {
686                                router.insert_get_alias(alt, route.handler, canonical_path);
687                            }
688                        }
689                        HttpMethod::Post => {
690                            router.insert_post(canonical_path, route.handler.clone());
691                            if let Some(alt) = alternate_path {
692                                router.insert_post_alias(alt, route.handler, canonical_path);
693                            }
694                        }
695                        HttpMethod::Put => {
696                            router.insert_put(canonical_path, route.handler.clone());
697                            if let Some(alt) = alternate_path {
698                                router.insert_put_alias(alt, route.handler, canonical_path);
699                            }
700                        }
701                        HttpMethod::Patch => {
702                            router.insert_patch(canonical_path, route.handler.clone());
703                            if let Some(alt) = alternate_path {
704                                router.insert_patch_alias(alt, route.handler, canonical_path);
705                            }
706                        }
707                        HttpMethod::Delete => {
708                            router.insert_delete(canonical_path, route.handler.clone());
709                            if let Some(alt) = alternate_path {
710                                router.insert_delete_alias(alt, route.handler, canonical_path);
711                            }
712                        }
713                    }
714
715                    // Register route name if present
716                    if let Some(name) = route.name {
717                        register_route_name(name, canonical_path);
718                    }
719
720                    // Apply MCP metadata: route-level overrides group defaults
721                    let mcp_tool_name = route.mcp_tool_name.or(combined_mcp.tool_name.clone());
722                    let mcp_description =
723                        route.mcp_description.or(combined_mcp.description.clone());
724                    let mcp_hint = route.mcp_hint.or(combined_mcp.hint.clone());
725                    let mcp_hidden = route.mcp_hidden || combined_mcp.hidden;
726
727                    if mcp_tool_name.is_some()
728                        || mcp_description.is_some()
729                        || mcp_hint.is_some()
730                        || mcp_hidden
731                    {
732                        update_route_mcp(
733                            canonical_path,
734                            mcp_tool_name,
735                            mcp_description,
736                            mcp_hint,
737                            mcp_hidden,
738                        );
739                    }
740
741                    // Apply combined middleware (inherited + group), then route-specific.
742                    // The alias leaf stores the canonical pattern so middleware registered
743                    // here under canonical_path is reached for both URL variants (Strategy A).
744                    for mw in &combined_middleware {
745                        router.add_middleware(canonical_path, mw.clone());
746                    }
747                    for mw in route.middlewares {
748                        router.add_middleware(canonical_path, mw);
749                    }
750                }
751                GroupItem::NestedGroup(nested) => {
752                    // Recursively register the nested group with accumulated prefix and middleware
753                    nested.register_with_inherited(
754                        router,
755                        &full_prefix,
756                        &combined_middleware,
757                        &combined_mcp,
758                    );
759                }
760            }
761        }
762    }
763}
764
765impl<H, Fut> RouteDefBuilder<H>
766where
767    H: Fn(Request) -> Fut + Send + Sync + 'static,
768    Fut: Future<Output = Response> + Send + 'static,
769{
770    /// Convert this route definition to a type-erased GroupRoute
771    ///
772    /// This is used internally when adding routes to a group.
773    pub fn into_group_route(self) -> GroupRoute {
774        let handler = self.handler;
775        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
776        GroupRoute {
777            method: self.method,
778            path: self.path,
779            handler: Arc::new(boxed),
780            name: self.name,
781            middlewares: self.middlewares,
782            mcp_tool_name: self.mcp_tool_name,
783            mcp_description: self.mcp_description,
784            mcp_hint: self.mcp_hint,
785            mcp_hidden: self.mcp_hidden,
786        }
787    }
788}
789
790// ============================================================================
791// IntoGroupItem implementations
792// ============================================================================
793
794impl<H, Fut> IntoGroupItem for RouteDefBuilder<H>
795where
796    H: Fn(Request) -> Fut + Send + Sync + 'static,
797    Fut: Future<Output = Response> + Send + 'static,
798{
799    fn into_group_item(self) -> GroupItem {
800        GroupItem::Route(self.into_group_route())
801    }
802}
803
804impl IntoGroupItem for GroupDef {
805    fn into_group_item(self) -> GroupItem {
806        GroupItem::NestedGroup(Box::new(self))
807    }
808}
809
810/// Define a route group with a shared prefix
811///
812/// Routes within a group will have the prefix prepended to their paths.
813/// Middleware can be applied to the entire group using `.middleware()`.
814/// Groups can be nested arbitrarily deep.
815///
816/// # Example
817///
818/// ```rust,ignore
819/// use ferro_rs::{routes, get, post, group};
820///
821/// routes! {
822///     get!("/", controllers::home::index),
823///
824///     // All routes in this group start with /api
825///     group!("/api", {
826///         get!("/users", controllers::user::index),      // -> GET /api/users
827///         post!("/users", controllers::user::store),     // -> POST /api/users
828///
829///         // Nested groups are supported
830///         group!("/admin", {
831///             get!("/dashboard", controllers::admin::dashboard), // -> GET /api/admin/dashboard
832///         }),
833///     }).middleware(AuthMiddleware),  // Applies to ALL routes including nested
834/// }
835/// ```
836///
837/// # Middleware Inheritance
838///
839/// Middleware applied to a parent group is automatically inherited by all nested groups.
840/// The execution order is: parent middleware -> child middleware -> route middleware.
841///
842/// # Compile Error
843///
844/// Fails to compile if prefix doesn't start with '/'.
845#[macro_export]
846macro_rules! group {
847    ($prefix:expr, { $( $item:expr ),* $(,)? }) => {{
848        const _: &str = $crate::validate_route_path($prefix);
849        let mut group = $crate::GroupDef::__new_unchecked($prefix);
850        $(
851            group = group.add($item);
852        )*
853        group
854    }};
855}
856
857/// Define routes with a clean, Laravel-like syntax
858///
859/// This macro generates a `pub fn register() -> Router` function automatically.
860/// Place it at the top level of your `routes.rs` file.
861///
862/// # Example
863/// ```rust,ignore
864/// use ferro_rs::{routes, get, post, put, delete};
865/// use ferro_rs::controllers;
866/// use ferro_rs::middleware::AuthMiddleware;
867///
868/// routes! {
869///     get!("/", controllers::home::index).name("home"),
870///     get!("/users", controllers::user::index).name("users.index"),
871///     get!("/users/{id}", controllers::user::show).name("users.show"),
872///     post!("/users", controllers::user::store).name("users.store"),
873///     put!("/users/{id}", controllers::user::update).name("users.update"),
874///     delete!("/users/{id}", controllers::user::destroy).name("users.destroy"),
875///     get!("/protected", controllers::home::index).middleware(AuthMiddleware),
876/// }
877/// ```
878#[macro_export]
879macro_rules! routes {
880    ( $( $route:expr ),* $(,)? ) => {
881        pub fn register() -> $crate::Router {
882            let mut router = $crate::Router::new();
883            $(
884                router = $route.register(router);
885            )*
886            router
887        }
888    };
889}
890
891// ============================================================================
892// RESTful Resource Routing Support
893// ============================================================================
894
895/// Actions available for resource routing
896#[derive(Clone, Copy, PartialEq, Eq, Debug)]
897pub enum ResourceAction {
898    /// List all resources (GET /resources).
899    Index,
900    /// Show creation form (GET /resources/create).
901    Create,
902    /// Persist a new resource (POST /resources).
903    Store,
904    /// Display a single resource (GET /resources/{id}).
905    Show,
906    /// Show edit form (GET /resources/{id}/edit).
907    Edit,
908    /// Update an existing resource (PUT /resources/{id}).
909    Update,
910    /// Delete a resource (DELETE /resources/{id}).
911    Destroy,
912}
913
914impl ResourceAction {
915    /// Get all available resource actions
916    pub const fn all() -> &'static [ResourceAction] {
917        &[
918            ResourceAction::Index,
919            ResourceAction::Create,
920            ResourceAction::Store,
921            ResourceAction::Show,
922            ResourceAction::Edit,
923            ResourceAction::Update,
924            ResourceAction::Destroy,
925        ]
926    }
927
928    /// Get the HTTP method for this action
929    pub const fn method(&self) -> HttpMethod {
930        match self {
931            ResourceAction::Index => HttpMethod::Get,
932            ResourceAction::Create => HttpMethod::Get,
933            ResourceAction::Store => HttpMethod::Post,
934            ResourceAction::Show => HttpMethod::Get,
935            ResourceAction::Edit => HttpMethod::Get,
936            ResourceAction::Update => HttpMethod::Put,
937            ResourceAction::Destroy => HttpMethod::Delete,
938        }
939    }
940
941    /// Get the path suffix for this action (relative to resource path)
942    pub const fn path_suffix(&self) -> &'static str {
943        match self {
944            ResourceAction::Index => "/",
945            ResourceAction::Create => "/create",
946            ResourceAction::Store => "/",
947            ResourceAction::Show => "/{id}",
948            ResourceAction::Edit => "/{id}/edit",
949            ResourceAction::Update => "/{id}",
950            ResourceAction::Destroy => "/{id}",
951        }
952    }
953
954    /// Get the route name suffix for this action
955    pub const fn name_suffix(&self) -> &'static str {
956        match self {
957            ResourceAction::Index => "index",
958            ResourceAction::Create => "create",
959            ResourceAction::Store => "store",
960            ResourceAction::Show => "show",
961            ResourceAction::Edit => "edit",
962            ResourceAction::Update => "update",
963            ResourceAction::Destroy => "destroy",
964        }
965    }
966}
967
968/// A resource route stored within a ResourceDef (type-erased handler)
969pub struct ResourceRoute {
970    action: ResourceAction,
971    handler: Arc<BoxedHandler>,
972}
973
974/// Resource definition that generates RESTful routes from a controller module
975///
976/// Generates 7 standard routes following Rails/Laravel conventions:
977///
978/// - GET    /users          -> index   (list all)
979/// - GET    /users/create   -> create  (show create form)
980/// - POST   /users          -> store   (create new)
981/// - GET    /users/{id}     -> show    (show one)
982/// - GET    /users/{id}/edit -> edit   (show edit form)
983/// - PUT    /users/{id}     -> update  (update one)
984/// - DELETE /users/{id}     -> destroy (delete one)
985///
986/// Route names are auto-generated: users.index, users.create, etc.
987///
988/// # Example
989///
990/// ```rust,ignore
991/// routes! {
992///     resource!("/users", controllers::user),
993///     resource!("/posts", controllers::post).middleware(AuthMiddleware),
994///     resource!("/comments", controllers::comment, only: [index, show]),
995/// }
996/// ```
997pub struct ResourceDef {
998    prefix: &'static str,
999    routes: Vec<ResourceRoute>,
1000    middlewares: Vec<BoxedMiddleware>,
1001}
1002
1003impl ResourceDef {
1004    /// Create a new resource definition with no routes (internal use)
1005    #[doc(hidden)]
1006    pub fn __new_unchecked(prefix: &'static str) -> Self {
1007        Self {
1008            prefix,
1009            routes: Vec::new(),
1010            middlewares: Vec::new(),
1011        }
1012    }
1013
1014    /// Add a route for a specific action
1015    #[doc(hidden)]
1016    pub fn __add_route(mut self, action: ResourceAction, handler: Arc<BoxedHandler>) -> Self {
1017        self.routes.push(ResourceRoute { action, handler });
1018        self
1019    }
1020
1021    /// Add middleware to all routes in this resource
1022    ///
1023    /// # Example
1024    ///
1025    /// ```rust,ignore
1026    /// resource!("/admin", controllers::admin, only: [index, show])
1027    ///     .middleware(AuthMiddleware)
1028    ///     .middleware(AdminMiddleware)
1029    /// ```
1030    pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
1031        self.middlewares.push(into_boxed(middleware));
1032        self
1033    }
1034
1035    /// Register all resource routes with the router
1036    pub fn register(self, mut router: Router) -> Router {
1037        // Derive route name prefix from path: "/users" -> "users", "/api/users" -> "api.users"
1038        let name_prefix = self.prefix.trim_start_matches('/').replace('/', ".");
1039
1040        for route in self.routes {
1041            let action = route.action;
1042            let path_suffix = action.path_suffix();
1043
1044            // Build full path
1045            let full_path = if path_suffix == "/" {
1046                self.prefix.to_string()
1047            } else {
1048                format!("{}{}", self.prefix, path_suffix)
1049            };
1050            let full_path: &'static str = Box::leak(full_path.into_boxed_str());
1051
1052            // Build route name
1053            let route_name = format!("{}.{}", name_prefix, action.name_suffix());
1054            let route_name: &'static str = Box::leak(route_name.into_boxed_str());
1055
1056            // Register the route
1057            match action.method() {
1058                HttpMethod::Get => {
1059                    router.insert_get(full_path, route.handler);
1060                }
1061                HttpMethod::Post => {
1062                    router.insert_post(full_path, route.handler);
1063                }
1064                HttpMethod::Put => {
1065                    router.insert_put(full_path, route.handler);
1066                }
1067                HttpMethod::Patch => {
1068                    router.insert_patch(full_path, route.handler);
1069                }
1070                HttpMethod::Delete => {
1071                    router.insert_delete(full_path, route.handler);
1072                }
1073            }
1074
1075            // Register route name
1076            register_route_name(route_name, full_path);
1077
1078            // Apply middleware
1079            for mw in &self.middlewares {
1080                router.add_middleware(full_path, mw.clone());
1081            }
1082        }
1083
1084        router
1085    }
1086}
1087
1088/// Helper function to create a boxed handler from a handler function
1089#[doc(hidden)]
1090pub fn __box_handler<H, Fut>(handler: H) -> Arc<BoxedHandler>
1091where
1092    H: Fn(Request) -> Fut + Send + Sync + 'static,
1093    Fut: Future<Output = Response> + Send + 'static,
1094{
1095    let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
1096    Arc::new(boxed)
1097}
1098
1099/// Define RESTful resource routes with convention-over-configuration
1100///
1101/// Generates 7 standard routes following Rails/Laravel conventions from a
1102/// controller module reference.
1103///
1104/// # Convention Mapping
1105///
1106/// | Method | Path            | Action  | Route Name    |
1107/// |--------|-----------------|---------|---------------|
1108/// | GET    | /users          | index   | users.index   |
1109/// | GET    | /users/create   | create  | users.create  |
1110/// | POST   | /users          | store   | users.store   |
1111/// | GET    | /users/{id}     | show    | users.show    |
1112/// | GET    | /users/{id}/edit| edit    | users.edit    |
1113/// | PUT    | /users/{id}     | update  | users.update  |
1114/// | DELETE | /users/{id}     | destroy | users.destroy |
1115///
1116/// # Basic Usage
1117///
1118/// ```rust,ignore
1119/// routes! {
1120///     resource!("/users", controllers::user),
1121/// }
1122/// ```
1123///
1124/// # With Middleware
1125///
1126/// ```rust,ignore
1127/// routes! {
1128///     resource!("/admin", controllers::admin).middleware(AuthMiddleware),
1129/// }
1130/// ```
1131///
1132/// # Subset of Actions
1133///
1134/// Use `only:` to generate only specific routes:
1135///
1136/// ```rust,ignore
1137/// routes! {
1138///     // Only index, show, and store - no create/edit forms, update, or destroy
1139///     resource!("/posts", controllers::post, only: [index, show, store]),
1140/// }
1141/// ```
1142///
1143/// # Path Naming
1144///
1145/// Route names are derived from the path:
1146/// - `/users` → `users.index`, `users.show`, etc.
1147/// - `/api/users` → `api.users.index`, `api.users.show`, etc.
1148///
1149/// # Compile Error
1150///
1151/// Fails to compile if path doesn't start with '/'.
1152#[macro_export]
1153macro_rules! resource {
1154    // Full resource (all 7 routes)
1155    // Note: The module path is followed by path segments to each handler
1156    ($path:expr, $($controller:ident)::+) => {{
1157        const _: &str = $crate::validate_route_path($path);
1158        $crate::ResourceDef::__new_unchecked($path)
1159            .__add_route($crate::ResourceAction::Index, $crate::__box_handler($($controller)::+::index))
1160            .__add_route($crate::ResourceAction::Create, $crate::__box_handler($($controller)::+::create))
1161            .__add_route($crate::ResourceAction::Store, $crate::__box_handler($($controller)::+::store))
1162            .__add_route($crate::ResourceAction::Show, $crate::__box_handler($($controller)::+::show))
1163            .__add_route($crate::ResourceAction::Edit, $crate::__box_handler($($controller)::+::edit))
1164            .__add_route($crate::ResourceAction::Update, $crate::__box_handler($($controller)::+::update))
1165            .__add_route($crate::ResourceAction::Destroy, $crate::__box_handler($($controller)::+::destroy))
1166    }};
1167
1168    // Subset of routes with `only:` parameter
1169    ($path:expr, $($controller:ident)::+, only: [$($action:ident),* $(,)?]) => {{
1170        const _: &str = $crate::validate_route_path($path);
1171        let mut resource = $crate::ResourceDef::__new_unchecked($path);
1172        $(
1173            resource = resource.__add_route(
1174                $crate::__resource_action!($action),
1175                $crate::__box_handler($($controller)::+::$action)
1176            );
1177        )*
1178        resource
1179    }};
1180}
1181
1182/// Internal macro to convert action identifier to ResourceAction enum
1183#[doc(hidden)]
1184#[macro_export]
1185macro_rules! __resource_action {
1186    (index) => {
1187        $crate::ResourceAction::Index
1188    };
1189    (create) => {
1190        $crate::ResourceAction::Create
1191    };
1192    (store) => {
1193        $crate::ResourceAction::Store
1194    };
1195    (show) => {
1196        $crate::ResourceAction::Show
1197    };
1198    (edit) => {
1199        $crate::ResourceAction::Edit
1200    };
1201    (update) => {
1202        $crate::ResourceAction::Update
1203    };
1204    (destroy) => {
1205        $crate::ResourceAction::Destroy
1206    };
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212
1213    #[test]
1214    fn test_convert_route_params() {
1215        // Basic parameter conversion
1216        assert_eq!(convert_route_params("/users/:id"), "/users/{id}");
1217
1218        // Multiple parameters
1219        assert_eq!(
1220            convert_route_params("/posts/:post_id/comments/:id"),
1221            "/posts/{post_id}/comments/{id}"
1222        );
1223
1224        // Already uses matchit syntax - should be unchanged
1225        assert_eq!(convert_route_params("/users/{id}"), "/users/{id}");
1226
1227        // No parameters - should be unchanged
1228        assert_eq!(convert_route_params("/users"), "/users");
1229        assert_eq!(convert_route_params("/"), "/");
1230
1231        // Mixed syntax (edge case)
1232        assert_eq!(
1233            convert_route_params("/users/:user_id/posts/{post_id}"),
1234            "/users/{user_id}/posts/{post_id}"
1235        );
1236
1237        // Parameter at the end
1238        assert_eq!(
1239            convert_route_params("/api/v1/:version"),
1240            "/api/v1/{version}"
1241        );
1242    }
1243
1244    // Helper for creating test handlers
1245    async fn test_handler(_req: Request) -> Response {
1246        crate::http::text("ok")
1247    }
1248
1249    #[test]
1250    fn test_group_item_route() {
1251        // Test that RouteDefBuilder can be converted to GroupItem
1252        let route_builder = RouteDefBuilder::new(HttpMethod::Get, "/test", test_handler);
1253        let item = route_builder.into_group_item();
1254        matches!(item, GroupItem::Route(_));
1255    }
1256
1257    #[test]
1258    fn test_group_item_nested_group() {
1259        // Test that GroupDef can be converted to GroupItem
1260        let group_def = GroupDef::__new_unchecked("/nested");
1261        let item = group_def.into_group_item();
1262        matches!(item, GroupItem::NestedGroup(_));
1263    }
1264
1265    #[test]
1266    fn test_group_add_route() {
1267        // Test adding a route to a group
1268        let group = GroupDef::__new_unchecked("/api").add(RouteDefBuilder::new(
1269            HttpMethod::Get,
1270            "/users",
1271            test_handler,
1272        ));
1273
1274        assert_eq!(group.items.len(), 1);
1275        matches!(&group.items[0], GroupItem::Route(_));
1276    }
1277
1278    #[test]
1279    fn test_group_add_nested_group() {
1280        // Test adding a nested group to a group
1281        let nested = GroupDef::__new_unchecked("/users");
1282        let group = GroupDef::__new_unchecked("/api").add(nested);
1283
1284        assert_eq!(group.items.len(), 1);
1285        matches!(&group.items[0], GroupItem::NestedGroup(_));
1286    }
1287
1288    #[test]
1289    fn test_group_mixed_items() {
1290        // Test adding both routes and nested groups
1291        let nested = GroupDef::__new_unchecked("/admin");
1292        let group = GroupDef::__new_unchecked("/api")
1293            .add(RouteDefBuilder::new(
1294                HttpMethod::Get,
1295                "/users",
1296                test_handler,
1297            ))
1298            .add(nested)
1299            .add(RouteDefBuilder::new(
1300                HttpMethod::Post,
1301                "/users",
1302                test_handler,
1303            ));
1304
1305        assert_eq!(group.items.len(), 3);
1306        matches!(&group.items[0], GroupItem::Route(_));
1307        matches!(&group.items[1], GroupItem::NestedGroup(_));
1308        matches!(&group.items[2], GroupItem::Route(_));
1309    }
1310
1311    #[test]
1312    fn test_deep_nesting() {
1313        // Test deeply nested groups (3 levels)
1314        let level3 = GroupDef::__new_unchecked("/level3").add(RouteDefBuilder::new(
1315            HttpMethod::Get,
1316            "/",
1317            test_handler,
1318        ));
1319
1320        let level2 = GroupDef::__new_unchecked("/level2").add(level3);
1321
1322        let level1 = GroupDef::__new_unchecked("/level1").add(level2);
1323
1324        assert_eq!(level1.items.len(), 1);
1325        if let GroupItem::NestedGroup(l2) = &level1.items[0] {
1326            assert_eq!(l2.items.len(), 1);
1327            if let GroupItem::NestedGroup(l3) = &l2.items[0] {
1328                assert_eq!(l3.items.len(), 1);
1329            } else {
1330                panic!("Expected nested group at level 2");
1331            }
1332        } else {
1333            panic!("Expected nested group at level 1");
1334        }
1335    }
1336
1337    #[test]
1338    fn test_backward_compatibility_route_method() {
1339        // Test that the old .route() method still works
1340        let group = GroupDef::__new_unchecked("/api").route(RouteDefBuilder::new(
1341            HttpMethod::Get,
1342            "/users",
1343            test_handler,
1344        ));
1345
1346        assert_eq!(group.items.len(), 1);
1347        matches!(&group.items[0], GroupItem::Route(_));
1348    }
1349
1350    // -------------------------------------------------------------------------
1351    // D-01 through D-08 full-router dispatch tests
1352    // -------------------------------------------------------------------------
1353
1354    #[test]
1355    fn group_root_handler_matches_both_variants() {
1356        // D-01: group!("/api", { get!("/", h) }) reaches h at both /api and /api/
1357        use crate::routing::get_registered_routes;
1358        use hyper::Method;
1359
1360        let group = GroupDef::__new_unchecked("/api-d01").add(RouteDefBuilder::new(
1361            HttpMethod::Get,
1362            "/",
1363            test_handler,
1364        ));
1365        let router = group.register(Router::new());
1366
1367        // D-07: exactly one RouteInfo entry for /api-d01 (no duplicate for the alias)
1368        let routes = get_registered_routes();
1369        let count = routes.iter().filter(|r| r.path == "/api-d01").count();
1370        assert_eq!(
1371            count, 1,
1372            "expected exactly 1 RouteInfo entry for /api-d01, got {count}"
1373        );
1374
1375        // Both URL variants reach the handler with the canonical pattern
1376        let hit_canonical = router.match_route(&Method::GET, "/api-d01");
1377        assert!(hit_canonical.is_some(), "canonical /api-d01 did not match");
1378        assert_eq!(hit_canonical.unwrap().2, "/api-d01");
1379
1380        let hit_alternate = router.match_route(&Method::GET, "/api-d01/");
1381        assert!(hit_alternate.is_some(), "alternate /api-d01/ did not match");
1382        assert_eq!(
1383            hit_alternate.unwrap().2,
1384            "/api-d01",
1385            "alternate leaf must carry canonical pattern for middleware lookup"
1386        );
1387    }
1388
1389    #[test]
1390    fn root_prefix_root_handler_is_single_slash() {
1391        // D-02: group!("/", { get!("/", h) }) registers exactly one / route.
1392        // Uses a unique prefix to avoid collision with other tests that also register "/".
1393        use crate::routing::get_registered_routes;
1394        use hyper::Method;
1395
1396        let group = GroupDef::__new_unchecked("/").add(RouteDefBuilder::new(
1397            HttpMethod::Get,
1398            "/",
1399            test_handler,
1400        ));
1401        let router = group.register(Router::new());
1402
1403        // D-07 / D-02: no duplicate RouteInfo for "/" — check via matchit (only one
1404        // canonical leaf exists; a duplicate would cause insert to silently overwrite,
1405        // which matchit handles idempotently, so the behaviour test below is the proof).
1406        let hit = router.match_route(&Method::GET, "/");
1407        assert!(hit.is_some(), "/ did not match");
1408        assert_eq!(hit.unwrap().2, "/");
1409
1410        // No spurious "//" alternate registered
1411        let double = router.match_route(&Method::GET, "//");
1412        assert!(
1413            double.is_none(),
1414            "// must not be registered for root-in-root group"
1415        );
1416
1417        // Single RouteInfo for "/"  — filter by exact path in the global registry
1418        let routes = get_registered_routes();
1419        let slash_count = routes
1420            .iter()
1421            .filter(|r| r.path == "/" && r.method == "GET")
1422            .count();
1423        // At least 1 (this test registered one); must be exactly 1 more than before.
1424        // We cannot assert == 1 globally (other tests may have registered "/").
1425        // Assert no duplicate within this session for this specific invocation path by
1426        // verifying the router itself only has one matchit leaf — done via the match above.
1427        assert!(slash_count >= 1, "expected at least one GET / in registry");
1428    }
1429
1430    #[test]
1431    fn trailing_slash_prefix_is_stripped() {
1432        // D-03: group!("/api/", { get!("/x", h) }) normalises to /api/x, not /api//x
1433        use hyper::Method;
1434
1435        let group = GroupDef::__new_unchecked("/api/").add(RouteDefBuilder::new(
1436            HttpMethod::Get,
1437            "/x",
1438            test_handler,
1439        ));
1440        let router = group.register(Router::new());
1441
1442        let hit = router.match_route(&Method::GET, "/api/x");
1443        assert!(
1444            hit.is_some(),
1445            "/api/x did not match after trailing-slash strip"
1446        );
1447
1448        let double = router.match_route(&Method::GET, "/api//x");
1449        assert!(
1450            double.is_none(),
1451            "/api//x should not match (double slash must not be registered)"
1452        );
1453    }
1454
1455    #[test]
1456    fn non_root_prefix_non_root_path_unchanged() {
1457        // D-04 regression: group!("/api-d04", { get!("/users", h) }) produces /api-d04/users, no alternate
1458        use crate::routing::get_registered_routes;
1459        use hyper::Method;
1460
1461        let group = GroupDef::__new_unchecked("/api-d04").add(RouteDefBuilder::new(
1462            HttpMethod::Get,
1463            "/users",
1464            test_handler,
1465        ));
1466        let router = group.register(Router::new());
1467
1468        // Canonical path must match
1469        let hit = router.match_route(&Method::GET, "/api-d04/users");
1470        assert!(hit.is_some(), "/api-d04/users did not match");
1471
1472        // No spurious trailing-slash alternate for non-root route paths (D-04)
1473        let alt = router.match_route(&Method::GET, "/api-d04/users/");
1474        assert!(
1475            alt.is_none(),
1476            "/api-d04/users/ must not be registered (no alternate for non-root path)"
1477        );
1478
1479        // Exactly one RouteInfo entry for this canonical path (D-07)
1480        let routes = get_registered_routes();
1481        let count = routes.iter().filter(|r| r.path == "/api-d04/users").count();
1482        assert_eq!(
1483            count, 1,
1484            "expected exactly 1 RouteInfo for /api-d04/users, got {count}"
1485        );
1486    }
1487
1488    #[test]
1489    fn nested_group_root_matches_both_variants() {
1490        // D-06 + Pitfall 3: nested groups with root handler reach both variants
1491        use hyper::Method;
1492
1493        // Case 1: clean prefixes
1494        let inner1 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
1495            HttpMethod::Get,
1496            "/",
1497            test_handler,
1498        ));
1499        let router1 = GroupDef::__new_unchecked("/a")
1500            .add(inner1)
1501            .register(Router::new());
1502        assert!(
1503            router1.match_route(&Method::GET, "/a/b").is_some(),
1504            "/a/b did not match"
1505        );
1506        assert!(
1507            router1.match_route(&Method::GET, "/a/b/").is_some(),
1508            "/a/b/ did not match"
1509        );
1510
1511        // Case 2: outer prefix has trailing slash (Pitfall 3)
1512        let inner2 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
1513            HttpMethod::Get,
1514            "/",
1515            test_handler,
1516        ));
1517        let router2 = GroupDef::__new_unchecked("/a/")
1518            .add(inner2)
1519            .register(Router::new());
1520        assert!(
1521            router2.match_route(&Method::GET, "/a/b").is_some(),
1522            "/a/b did not match with trailing-slash outer"
1523        );
1524        assert!(
1525            router2.match_route(&Method::GET, "/a/b/").is_some(),
1526            "/a/b/ did not match with trailing-slash outer"
1527        );
1528    }
1529
1530    #[test]
1531    fn named_route_resolves_to_canonical() {
1532        // D-08: named routes register only the canonical path
1533        use crate::routing::route;
1534
1535        let group = GroupDef::__new_unchecked("/api").add(
1536            RouteDefBuilder::new(HttpMethod::Get, "/", test_handler).name("home_canonical_test"),
1537        );
1538        let _router = group.register(Router::new());
1539
1540        let url = route("home_canonical_test", &[]);
1541        assert_eq!(
1542            url,
1543            Some("/api".to_string()),
1544            "named route must resolve to canonical /api, not /api/"
1545        );
1546    }
1547
1548    #[test]
1549    fn top_level_root_route_is_single_slash() {
1550        // Regression: top-level get!("/", h) (not inside a group) must still match /.
1551        // Confirms the fix lives only in GroupDef, not in RouteDefBuilder::register.
1552        use hyper::Method;
1553
1554        let router: Router = Router::new().get("/", test_handler).into();
1555
1556        let hit = router.match_route(&Method::GET, "/");
1557        assert!(hit.is_some(), "top-level / did not match");
1558        assert_eq!(hit.unwrap().2, "/", "top-level / pattern must be /");
1559
1560        // No spurious alternate — top-level router.get() does not emit aliases
1561        let double = router.match_route(&Method::GET, "//");
1562        assert!(
1563            double.is_none(),
1564            "// must not match from a top-level get!(\"/\") registration"
1565        );
1566    }
1567}