Skip to main content

fastapi_core/
routing.rs

1//! Path matching and routing utilities.
2//!
3//! This module provides path parameter extraction with type converters,
4//! similar to FastAPI's path converters: `{id:int}`, `{value:float}`,
5//! `{uuid:uuid}`, `{path:path}`.
6//!
7//! # Trailing Slash Handling
8//!
9//! This module also handles trailing slash normalization via [`TrailingSlashMode`]:
10//! - `Strict`: Exact match required (default)
11//! - `Redirect`: 308 redirect to canonical form (no trailing slash)
12//! - `RedirectWithSlash`: 308 redirect to form with trailing slash
13//! - `MatchBoth`: Accept both forms without redirect
14
15use crate::request::Method;
16
17/// Trailing slash handling mode.
18///
19/// Controls how the router handles trailing slashes in URLs.
20///
21/// # Example
22///
23/// ```ignore
24/// use fastapi_core::routing::TrailingSlashMode;
25///
26/// // Redirect /users/ to /users
27/// let mode = TrailingSlashMode::Redirect;
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum TrailingSlashMode {
31    /// Exact match required - `/users` and `/users/` are different routes.
32    #[default]
33    Strict,
34    /// Redirect trailing slash to no trailing slash (308 Permanent Redirect).
35    /// `/users/` redirects to `/users`.
36    Redirect,
37    /// Redirect no trailing slash to with trailing slash (308 Permanent Redirect).
38    /// `/users` redirects to `/users/`.
39    RedirectWithSlash,
40    /// Accept both forms without redirect.
41    /// Both `/users` and `/users/` match the route.
42    MatchBoth,
43}
44
45/// Path parameter type converter.
46///
47/// Converters validate and constrain path parameter values during matching.
48///
49/// # Supported Types
50///
51/// - `Str` (default): Any string value
52/// - `Int`: Integer values (i64)
53/// - `Float`: Floating-point values (f64)
54/// - `Uuid`: UUID format (8-4-4-4-12 hex digits)
55/// - `Path`: Captures remaining path including slashes
56///
57/// # Example Route Patterns
58///
59/// - `/users/{id}` - String parameter (default)
60/// - `/items/{id:int}` - Integer parameter
61/// - `/values/{val:float}` - Float parameter
62/// - `/objects/{id:uuid}` - UUID parameter
63/// - `/files/{path:path}` - Captures `/files/a/b/c.txt` as `path="a/b/c.txt"`
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Converter {
66    /// String (default).
67    Str,
68    /// Integer (i64).
69    Int,
70    /// Float (f64).
71    Float,
72    /// UUID format.
73    Uuid,
74    /// Path segment (can contain /).
75    Path,
76}
77
78impl Converter {
79    /// Check if a value matches this converter.
80    #[must_use]
81    pub fn matches(&self, value: &str) -> bool {
82        match self {
83            Self::Str => true,
84            Self::Int => value.parse::<i64>().is_ok(),
85            Self::Float => value.parse::<f64>().is_ok(),
86            Self::Uuid => is_uuid(value),
87            Self::Path => true,
88        }
89    }
90
91    /// Parse a converter type from a string.
92    #[must_use]
93    pub fn parse(s: &str) -> Self {
94        match s {
95            "int" => Self::Int,
96            "float" => Self::Float,
97            "uuid" => Self::Uuid,
98            "path" => Self::Path,
99            _ => Self::Str,
100        }
101    }
102}
103
104fn is_uuid(s: &str) -> bool {
105    // Simple UUID check: 8-4-4-4-12 hex digits
106    if s.len() != 36 {
107        return false;
108    }
109    let parts: Vec<_> = s.split('-').collect();
110    if parts.len() != 5 {
111        return false;
112    }
113    parts[0].len() == 8
114        && parts[1].len() == 4
115        && parts[2].len() == 4
116        && parts[3].len() == 4
117        && parts[4].len() == 12
118        && parts
119            .iter()
120            .all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
121}
122
123/// Path parameter information.
124#[derive(Debug, Clone)]
125pub struct ParamInfo {
126    /// Parameter name.
127    pub name: String,
128    /// Type converter.
129    pub converter: Converter,
130}
131
132/// A parsed path segment.
133#[derive(Debug, Clone)]
134pub enum PathSegment {
135    /// A static path segment (e.g., "users").
136    Static(String),
137    /// A parameter segment with name and converter.
138    Param(ParamInfo),
139}
140
141/// A parsed route pattern.
142#[derive(Debug, Clone)]
143pub struct RoutePattern {
144    /// The original path pattern string.
145    pub pattern: String,
146    /// Parsed segments.
147    pub segments: Vec<PathSegment>,
148    /// Whether the last segment is a path converter (captures slashes).
149    pub has_path_converter: bool,
150}
151
152impl RoutePattern {
153    /// Parse a route pattern from a string.
154    ///
155    /// # Example
156    ///
157    /// ```ignore
158    /// let pattern = RoutePattern::parse("/users/{id:int}/posts/{post_id}");
159    /// ```
160    #[must_use]
161    pub fn parse(pattern: &str) -> Self {
162        let segments = parse_path_segments(pattern);
163        let has_path_converter = matches!(
164            segments.last(),
165            Some(PathSegment::Param(ParamInfo {
166                converter: Converter::Path,
167                ..
168            }))
169        );
170
171        Self {
172            pattern: pattern.to_string(),
173            segments,
174            has_path_converter,
175        }
176    }
177
178    /// Try to match this pattern against a path, extracting parameters.
179    ///
180    /// Returns `Some(params)` if the path matches, `None` otherwise.
181    /// Parameter names are owned strings, values are borrowed from the path.
182    #[must_use]
183    pub fn match_path<'a>(&self, path: &'a str) -> Option<Vec<(String, &'a str)>> {
184        let path_ranges = segment_ranges(path);
185        let mut path_segments: Vec<&'a str> = Vec::with_capacity(path_ranges.len());
186        for (start, end) in &path_ranges {
187            path_segments.push(&path[*start..*end]);
188        }
189
190        let mut params = Vec::new();
191        let mut path_idx = 0;
192        let last_end = path_ranges.last().map_or(0, |(_, end)| *end);
193
194        for segment in &self.segments {
195            match segment {
196                PathSegment::Static(expected) => {
197                    if path_idx >= path_segments.len() || path_segments[path_idx] != expected {
198                        return None;
199                    }
200                    path_idx += 1;
201                }
202                PathSegment::Param(info) => {
203                    if path_idx >= path_segments.len() {
204                        return None;
205                    }
206
207                    if info.converter == Converter::Path {
208                        // Path converter captures everything remaining
209                        let start = path_ranges[path_idx].0;
210                        let value = &path[start..last_end];
211                        params.push((info.name.clone(), value));
212                        // Consume all remaining segments
213                        path_idx = path_segments.len();
214                    } else {
215                        let value = path_segments[path_idx];
216                        if !info.converter.matches(value) {
217                            return None;
218                        }
219                        params.push((info.name.clone(), value));
220                        path_idx += 1;
221                    }
222                }
223            }
224        }
225
226        // All path segments must be consumed (unless we had a path converter)
227        if path_idx != path_segments.len() && !self.has_path_converter {
228            return None;
229        }
230
231        Some(params)
232    }
233
234    /// Check if this pattern could potentially match the given path (ignoring method).
235    ///
236    /// Used for 405 Method Not Allowed detection.
237    #[must_use]
238    pub fn could_match(&self, path: &str) -> bool {
239        self.match_path(path).is_some()
240    }
241}
242
243fn parse_path_segments(path: &str) -> Vec<PathSegment> {
244    path.split('/')
245        .filter(|s| !s.is_empty())
246        .map(|s| {
247            if s.starts_with('{') && s.ends_with('}') {
248                let inner = &s[1..s.len() - 1];
249                let (name, converter) = if let Some(pos) = inner.find(':') {
250                    let conv = Converter::parse(&inner[pos + 1..]);
251                    (inner[..pos].to_string(), conv)
252                } else {
253                    (inner.to_string(), Converter::Str)
254                };
255                PathSegment::Param(ParamInfo { name, converter })
256            } else {
257                PathSegment::Static(s.to_string())
258            }
259        })
260        .collect()
261}
262
263fn segment_ranges(path: &str) -> Vec<(usize, usize)> {
264    let bytes = path.as_bytes();
265    let mut ranges = Vec::new();
266    let mut idx = 0;
267    while idx < bytes.len() {
268        // Skip leading slashes
269        while idx < bytes.len() && bytes[idx] == b'/' {
270            idx += 1;
271        }
272        if idx >= bytes.len() {
273            break;
274        }
275        let start = idx;
276        // Find end of segment
277        while idx < bytes.len() && bytes[idx] != b'/' {
278            idx += 1;
279        }
280        ranges.push((start, idx));
281    }
282    ranges
283}
284
285/// Result of a route lookup.
286#[derive(Debug)]
287pub enum RouteLookup<'a, T> {
288    /// A route matched.
289    Match {
290        /// The matched route data.
291        route: &'a T,
292        /// Extracted path parameters (name, value).
293        params: Vec<(String, String)>,
294    },
295    /// Path matched but method not allowed.
296    MethodNotAllowed {
297        /// Methods that are allowed for this path.
298        allowed: Vec<Method>,
299    },
300    /// Redirect to a different path (308 Permanent Redirect).
301    ///
302    /// Used for trailing slash normalization.
303    Redirect {
304        /// The path to redirect to.
305        target: String,
306    },
307    /// No route matched.
308    NotFound,
309}
310
311/// Simple route table for path matching.
312///
313/// This provides O(n) matching but with full converter support.
314/// For larger applications, consider using fastapi-router's trie.
315pub struct RouteTable<T> {
316    routes: Vec<(Method, RoutePattern, T)>,
317}
318
319impl<T> RouteTable<T> {
320    /// Create a new empty route table.
321    #[must_use]
322    pub fn new() -> Self {
323        Self { routes: Vec::new() }
324    }
325
326    /// Add a route to the table.
327    pub fn add(&mut self, method: Method, pattern: &str, data: T) {
328        let parsed = RoutePattern::parse(pattern);
329        self.routes.push((method, parsed, data));
330    }
331
332    /// Look up a route by path and method.
333    #[must_use]
334    pub fn lookup(&self, path: &str, method: Method) -> RouteLookup<'_, T> {
335        // First, try to find exact method + path match
336        for (route_method, pattern, data) in &self.routes {
337            if let Some(params) = pattern.match_path(path) {
338                // Convert params to owned strings
339                let owned_params: Vec<(String, String)> = params
340                    .into_iter()
341                    .map(|(name, value)| (name, value.to_string()))
342                    .collect();
343
344                if *route_method == method {
345                    return RouteLookup::Match {
346                        route: data,
347                        params: owned_params,
348                    };
349                }
350                // HEAD can match GET routes
351                if method == Method::Head && *route_method == Method::Get {
352                    return RouteLookup::Match {
353                        route: data,
354                        params: owned_params,
355                    };
356                }
357            }
358        }
359
360        // Check if any route matches the path (for 405)
361        let mut allowed_methods: Vec<Method> = Vec::new();
362        for (route_method, pattern, _) in &self.routes {
363            if pattern.could_match(path) && !allowed_methods.contains(route_method) {
364                allowed_methods.push(*route_method);
365            }
366        }
367
368        if !allowed_methods.is_empty() {
369            // Add HEAD if GET is allowed
370            if allowed_methods.contains(&Method::Get) && !allowed_methods.contains(&Method::Head) {
371                allowed_methods.push(Method::Head);
372            }
373            // Sort methods for consistent output
374            allowed_methods.sort_by_key(|m| method_order(*m));
375            return RouteLookup::MethodNotAllowed {
376                allowed: allowed_methods,
377            };
378        }
379
380        RouteLookup::NotFound
381    }
382
383    /// Look up a route by path and method, with trailing slash handling.
384    ///
385    /// This extends `lookup` with trailing slash normalization based on the mode:
386    /// - `Strict`: Exact match required
387    /// - `Redirect`: Redirect trailing slash to no trailing slash
388    /// - `RedirectWithSlash`: Redirect no trailing slash to with trailing slash
389    /// - `MatchBoth`: Accept both forms without redirect
390    #[must_use]
391    pub fn lookup_with_trailing_slash(
392        &self,
393        path: &str,
394        method: Method,
395        mode: TrailingSlashMode,
396    ) -> RouteLookup<'_, T> {
397        // First, try exact match
398        let result = self.lookup(path, method);
399        if !matches!(result, RouteLookup::NotFound) {
400            return result;
401        }
402
403        // If strict mode or no trailing slash handling, return the result
404        if mode == TrailingSlashMode::Strict {
405            return result;
406        }
407
408        // Try alternate path (toggle trailing slash)
409        let has_trailing_slash = path.len() > 1 && path.ends_with('/');
410        let alt_path = if has_trailing_slash {
411            // Remove trailing slash
412            &path[..path.len() - 1]
413        } else {
414            // Need to allocate for adding trailing slash
415            return self.lookup_with_trailing_slash_add(path, method, mode);
416        };
417
418        let alt_result = self.lookup(alt_path, method);
419        match (&alt_result, mode) {
420            (RouteLookup::Match { .. }, TrailingSlashMode::Redirect) => {
421                // Path has trailing slash but route matches without it - redirect
422                RouteLookup::Redirect {
423                    target: alt_path.to_string(),
424                }
425            }
426            (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
427                // Match both - return the match directly
428                RouteLookup::Match {
429                    route,
430                    params: params.clone(),
431                }
432            }
433            (RouteLookup::MethodNotAllowed { allowed: _ }, TrailingSlashMode::Redirect) => {
434                // Path has trailing slash, route exists without it - redirect
435                RouteLookup::Redirect {
436                    target: alt_path.to_string(),
437                }
438            }
439            (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
440                // Return method not allowed for the alt path
441                RouteLookup::MethodNotAllowed {
442                    allowed: allowed.clone(),
443                }
444            }
445            _ => result, // NotFound or other modes
446        }
447    }
448
449    /// Helper for lookup_with_trailing_slash when we need to add a trailing slash.
450    fn lookup_with_trailing_slash_add(
451        &self,
452        path: &str,
453        method: Method,
454        mode: TrailingSlashMode,
455    ) -> RouteLookup<'_, T> {
456        // Path doesn't have trailing slash, try with it
457        let with_slash = format!("{}/", path);
458        let alt_result = self.lookup(&with_slash, method);
459
460        match (&alt_result, mode) {
461            (RouteLookup::Match { .. }, TrailingSlashMode::RedirectWithSlash) => {
462                // Path doesn't have trailing slash but route matches with it - redirect
463                RouteLookup::Redirect { target: with_slash }
464            }
465            (RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
466                // Match both - return the match directly
467                RouteLookup::Match {
468                    route,
469                    params: params.clone(),
470                }
471            }
472            (
473                RouteLookup::MethodNotAllowed { allowed: _ },
474                TrailingSlashMode::RedirectWithSlash,
475            ) => {
476                // Route exists with trailing slash - redirect
477                RouteLookup::Redirect { target: with_slash }
478            }
479            (RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
480                // Return method not allowed for the alt path
481                RouteLookup::MethodNotAllowed {
482                    allowed: allowed.clone(),
483                }
484            }
485            _ => RouteLookup::NotFound,
486        }
487    }
488
489    /// Get the number of routes.
490    #[must_use]
491    pub fn len(&self) -> usize {
492        self.routes.len()
493    }
494
495    /// Check if the table is empty.
496    #[must_use]
497    pub fn is_empty(&self) -> bool {
498        self.routes.is_empty()
499    }
500}
501
502impl<T> Default for RouteTable<T> {
503    fn default() -> Self {
504        Self::new()
505    }
506}
507
508/// Get the sort order for an HTTP method.
509///
510/// Used to produce consistent ordering in Allow headers:
511/// GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS, TRACE
512#[must_use]
513pub fn method_order(method: Method) -> u8 {
514    match method {
515        Method::Get => 0,
516        Method::Head => 1,
517        Method::Post => 2,
518        Method::Put => 3,
519        Method::Delete => 4,
520        Method::Patch => 5,
521        Method::Options => 6,
522        Method::Trace => 7,
523    }
524}
525
526/// Format allowed methods as an HTTP Allow header value.
527#[must_use]
528pub fn format_allow_header(methods: &[Method]) -> String {
529    methods
530        .iter()
531        .map(|m| m.as_str())
532        .collect::<Vec<_>>()
533        .join(", ")
534}
535
536// =============================================================================
537// URL GENERATION AND REVERSE ROUTING
538// =============================================================================
539//
540// Generate URLs from route names and parameters.
541//
542// # Features
543// - Look up routes by name
544// - Substitute path parameters
545// - Include query parameters
546// - Respect root_path for proxied apps
547
548use std::collections::HashMap;
549
550/// Error that can occur during URL generation.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub enum UrlError {
553    /// The route name was not found in the registry.
554    RouteNotFound { name: String },
555    /// A required path parameter was missing.
556    MissingParam { name: String, param: String },
557    /// A path parameter value was invalid for its converter type.
558    InvalidParam {
559        name: String,
560        param: String,
561        value: String,
562    },
563}
564
565impl std::fmt::Display for UrlError {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        match self {
568            Self::RouteNotFound { name } => {
569                write!(f, "route '{}' not found", name)
570            }
571            Self::MissingParam { name, param } => {
572                write!(f, "route '{}' requires parameter '{}'", name, param)
573            }
574            Self::InvalidParam { name, param, value } => {
575                write!(
576                    f,
577                    "route '{}' parameter '{}': invalid value '{}'",
578                    name, param, value
579                )
580            }
581        }
582    }
583}
584
585impl std::error::Error for UrlError {}
586
587/// Registry for named routes, enabling URL generation.
588///
589/// # Example
590///
591/// ```ignore
592/// use fastapi_core::routing::UrlRegistry;
593///
594/// let mut registry = UrlRegistry::new();
595/// registry.register("get_user", "/users/{id}");
596/// registry.register("get_post", "/posts/{post_id:int}");
597///
598/// // Generate URL with path parameter
599/// let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
600/// assert_eq!(url, "/users/42");
601///
602/// // Generate URL with query parameters
603/// let url = registry.url_for("get_user", &[("id", "42")], &[("fields", "name,email")]).unwrap();
604/// assert_eq!(url, "/users/42?fields=name%2Cemail");
605/// ```
606#[derive(Debug, Clone, Default)]
607pub struct UrlRegistry {
608    /// Map from route name to route pattern.
609    routes: HashMap<String, RoutePattern>,
610    /// Root path prefix for reverse proxy support.
611    root_path: String,
612}
613
614impl UrlRegistry {
615    /// Create a new empty URL registry.
616    #[must_use]
617    pub fn new() -> Self {
618        Self {
619            routes: HashMap::new(),
620            root_path: String::new(),
621        }
622    }
623
624    /// Create a URL registry with a root path prefix.
625    ///
626    /// The root path is prepended to all generated URLs, useful for apps
627    /// running behind a reverse proxy at a sub-path.
628    ///
629    /// # Example
630    ///
631    /// ```ignore
632    /// let registry = UrlRegistry::with_root_path("/api/v1");
633    /// registry.register("get_user", "/users/{id}");
634    /// let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
635    /// assert_eq!(url, "/api/v1/users/42");
636    /// ```
637    #[must_use]
638    pub fn with_root_path(root_path: impl Into<String>) -> Self {
639        let mut path = root_path.into();
640        // Normalize: ensure no trailing slash
641        while path.ends_with('/') {
642            path.pop();
643        }
644        Self {
645            routes: HashMap::new(),
646            root_path: path,
647        }
648    }
649
650    /// Set the root path prefix.
651    pub fn set_root_path(&mut self, root_path: impl Into<String>) {
652        let mut path = root_path.into();
653        while path.ends_with('/') {
654            path.pop();
655        }
656        self.root_path = path;
657    }
658
659    /// Get the current root path.
660    #[must_use]
661    pub fn root_path(&self) -> &str {
662        &self.root_path
663    }
664
665    /// Register a named route.
666    ///
667    /// # Arguments
668    ///
669    /// * `name` - The route name (used to look up the route)
670    /// * `pattern` - The route pattern (e.g., "/users/{id}")
671    pub fn register(&mut self, name: impl Into<String>, pattern: &str) {
672        let name = name.into();
673        let parsed = RoutePattern::parse(pattern);
674        self.routes.insert(name, parsed);
675    }
676
677    /// Check if a route with the given name exists.
678    #[must_use]
679    pub fn has_route(&self, name: &str) -> bool {
680        self.routes.contains_key(name)
681    }
682
683    /// Get the pattern for a named route.
684    #[must_use]
685    pub fn get_pattern(&self, name: &str) -> Option<&str> {
686        self.routes.get(name).map(|p| p.pattern.as_str())
687    }
688
689    /// Generate a URL for a named route.
690    ///
691    /// # Arguments
692    ///
693    /// * `name` - The route name
694    /// * `params` - Path parameters as (name, value) pairs
695    /// * `query` - Query parameters as (name, value) pairs
696    ///
697    /// # Errors
698    ///
699    /// Returns an error if:
700    /// - The route name is not found
701    /// - A required path parameter is missing
702    /// - A path parameter value doesn't match its converter type
703    ///
704    /// # Example
705    ///
706    /// ```ignore
707    /// let url = registry.url_for(
708    ///     "get_user",
709    ///     &[("id", "42")],
710    ///     &[("fields", "name"), ("include", "posts")]
711    /// ).unwrap();
712    /// // Returns: "/users/42?fields=name&include=posts"
713    /// ```
714    pub fn url_for(
715        &self,
716        name: &str,
717        params: &[(&str, &str)],
718        query: &[(&str, &str)],
719    ) -> Result<String, UrlError> {
720        let pattern = self
721            .routes
722            .get(name)
723            .ok_or_else(|| UrlError::RouteNotFound {
724                name: name.to_string(),
725            })?;
726
727        // Build parameter map for fast lookup
728        let param_map: HashMap<&str, &str> = params.iter().copied().collect();
729
730        // Build the path by substituting parameters
731        let mut path = String::new();
732        if !self.root_path.is_empty() {
733            path.push_str(&self.root_path);
734        }
735
736        // Track whether we have any segments
737        let has_segments = !pattern.segments.is_empty();
738
739        for segment in &pattern.segments {
740            path.push('/');
741            match segment {
742                PathSegment::Static(s) => {
743                    path.push_str(s);
744                }
745                PathSegment::Param(info) => {
746                    let value = *param_map.get(info.name.as_str()).ok_or_else(|| {
747                        UrlError::MissingParam {
748                            name: name.to_string(),
749                            param: info.name.clone(),
750                        }
751                    })?;
752
753                    // Validate the value against the converter
754                    if !info.converter.matches(value) {
755                        return Err(UrlError::InvalidParam {
756                            name: name.to_string(),
757                            param: info.name.clone(),
758                            value: value.to_string(),
759                        });
760                    }
761
762                    // URL-encode the value (except for path converter which allows slashes)
763                    if info.converter == Converter::Path {
764                        path.push_str(value);
765                    } else {
766                        path.push_str(&url_encode_path_segment(value));
767                    }
768                }
769            }
770        }
771
772        // Handle empty path (root route) - must have at least "/"
773        // If no segments but has root_path, still need trailing "/"
774        if path.is_empty() || (!has_segments && !self.root_path.is_empty()) {
775            path.push('/');
776        }
777
778        // Add query parameters if any
779        if !query.is_empty() {
780            path.push('?');
781            for (i, (key, value)) in query.iter().enumerate() {
782                if i > 0 {
783                    path.push('&');
784                }
785                path.push_str(&url_encode(key));
786                path.push('=');
787                path.push_str(&url_encode(value));
788            }
789        }
790
791        Ok(path)
792    }
793
794    /// Get the number of registered routes.
795    #[must_use]
796    pub fn len(&self) -> usize {
797        self.routes.len()
798    }
799
800    /// Check if the registry is empty.
801    #[must_use]
802    pub fn is_empty(&self) -> bool {
803        self.routes.is_empty()
804    }
805
806    /// Get an iterator over route names.
807    pub fn route_names(&self) -> impl Iterator<Item = &str> {
808        self.routes.keys().map(String::as_str)
809    }
810}
811
812/// URL-encode a string for use in a query parameter.
813///
814/// Encodes all non-unreserved characters according to RFC 3986.
815#[must_use]
816pub fn url_encode(s: &str) -> String {
817    let mut result = String::with_capacity(s.len() * 3);
818    for byte in s.bytes() {
819        match byte {
820            // Unreserved characters (RFC 3986)
821            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
822                result.push(byte as char);
823            }
824            // Everything else gets percent-encoded
825            _ => {
826                result.push('%');
827                result.push(
828                    char::from_digit(u32::from(byte >> 4), 16)
829                        .unwrap()
830                        .to_ascii_uppercase(),
831                );
832                result.push(
833                    char::from_digit(u32::from(byte & 0xF), 16)
834                        .unwrap()
835                        .to_ascii_uppercase(),
836                );
837            }
838        }
839    }
840    result
841}
842
843/// URL-encode a path segment.
844///
845/// Similar to `url_encode` but also allows forward slashes for path converter values.
846#[must_use]
847pub fn url_encode_path_segment(s: &str) -> String {
848    let mut result = String::with_capacity(s.len() * 3);
849    for byte in s.bytes() {
850        match byte {
851            // Unreserved characters (RFC 3986)
852            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
853                result.push(byte as char);
854            }
855            // Everything else gets percent-encoded
856            _ => {
857                result.push('%');
858                result.push(
859                    char::from_digit(u32::from(byte >> 4), 16)
860                        .unwrap()
861                        .to_ascii_uppercase(),
862                );
863                result.push(
864                    char::from_digit(u32::from(byte & 0xF), 16)
865                        .unwrap()
866                        .to_ascii_uppercase(),
867                );
868            }
869        }
870    }
871    result
872}
873
874/// URL-decode a percent-encoded string.
875///
876/// # Errors
877///
878/// Returns `None` if the string contains invalid percent-encoding.
879#[must_use]
880pub fn url_decode(s: &str) -> Option<String> {
881    let mut result = Vec::with_capacity(s.len());
882    let mut bytes = s.bytes();
883
884    while let Some(byte) = bytes.next() {
885        if byte == b'%' {
886            let hi = bytes.next()?;
887            let lo = bytes.next()?;
888            let hi = char::from(hi).to_digit(16)?;
889            let lo = char::from(lo).to_digit(16)?;
890            result.push((hi * 16 + lo) as u8);
891        } else if byte == b'+' {
892            // Handle + as space (form encoding)
893            result.push(b' ');
894        } else {
895            result.push(byte);
896        }
897    }
898
899    String::from_utf8(result).ok()
900}
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    #[test]
907    fn converter_str_matches_anything() {
908        assert!(Converter::Str.matches("hello"));
909        assert!(Converter::Str.matches("123"));
910        assert!(Converter::Str.matches(""));
911    }
912
913    #[test]
914    fn converter_int_matches_integers() {
915        assert!(Converter::Int.matches("123"));
916        assert!(Converter::Int.matches("-456"));
917        assert!(Converter::Int.matches("0"));
918        assert!(!Converter::Int.matches("12.34"));
919        assert!(!Converter::Int.matches("abc"));
920        assert!(!Converter::Int.matches(""));
921    }
922
923    #[test]
924    fn converter_float_matches_floats() {
925        assert!(Converter::Float.matches("3.14"));
926        assert!(Converter::Float.matches("42"));
927        assert!(Converter::Float.matches("-1.5"));
928        assert!(Converter::Float.matches("1e10"));
929        assert!(!Converter::Float.matches("abc"));
930    }
931
932    #[test]
933    fn converter_uuid_matches_uuids() {
934        assert!(Converter::Uuid.matches("550e8400-e29b-41d4-a716-446655440000"));
935        assert!(Converter::Uuid.matches("550E8400-E29B-41D4-A716-446655440000"));
936        assert!(!Converter::Uuid.matches("not-a-uuid"));
937        assert!(!Converter::Uuid.matches("550e8400e29b41d4a716446655440000")); // No hyphens
938    }
939
940    #[test]
941    fn parse_static_path() {
942        let pattern = RoutePattern::parse("/users");
943        assert_eq!(pattern.segments.len(), 1);
944        assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
945    }
946
947    #[test]
948    fn parse_path_with_param() {
949        let pattern = RoutePattern::parse("/users/{id}");
950        assert_eq!(pattern.segments.len(), 2);
951        assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
952        assert!(
953            matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Str)
954        );
955    }
956
957    #[test]
958    fn parse_path_with_typed_param() {
959        let pattern = RoutePattern::parse("/items/{id:int}");
960        assert_eq!(pattern.segments.len(), 2);
961        assert!(
962            matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Int)
963        );
964    }
965
966    #[test]
967    fn parse_path_with_path_converter() {
968        let pattern = RoutePattern::parse("/files/{path:path}");
969        assert!(pattern.has_path_converter);
970    }
971
972    #[test]
973    fn match_static_path() {
974        let pattern = RoutePattern::parse("/users");
975        assert!(pattern.match_path("/users").is_some());
976        assert!(pattern.match_path("/items").is_none());
977    }
978
979    #[test]
980    fn match_path_extracts_params() {
981        let pattern = RoutePattern::parse("/users/{id}");
982        let params = pattern.match_path("/users/42").unwrap();
983        assert_eq!(params.len(), 1);
984        assert_eq!(params[0].0, "id");
985        assert_eq!(params[0].1, "42");
986    }
987
988    #[test]
989    fn match_path_validates_int_converter() {
990        let pattern = RoutePattern::parse("/items/{id:int}");
991        assert!(pattern.match_path("/items/123").is_some());
992        assert!(pattern.match_path("/items/abc").is_none());
993    }
994
995    #[test]
996    fn match_path_validates_uuid_converter() {
997        let pattern = RoutePattern::parse("/objects/{id:uuid}");
998        assert!(
999            pattern
1000                .match_path("/objects/550e8400-e29b-41d4-a716-446655440000")
1001                .is_some()
1002        );
1003        assert!(pattern.match_path("/objects/not-a-uuid").is_none());
1004    }
1005
1006    #[test]
1007    fn match_path_converter_captures_slashes() {
1008        let pattern = RoutePattern::parse("/files/{path:path}");
1009        let params = pattern.match_path("/files/a/b/c.txt").unwrap();
1010        assert_eq!(params[0].0, "path");
1011        assert_eq!(params[0].1, "a/b/c.txt");
1012    }
1013
1014    #[test]
1015    fn match_multiple_params() {
1016        let pattern = RoutePattern::parse("/users/{user_id}/posts/{post_id}");
1017        let params = pattern.match_path("/users/42/posts/99").unwrap();
1018        assert_eq!(params.len(), 2);
1019        assert_eq!(params[0].0, "user_id");
1020        assert_eq!(params[0].1, "42");
1021        assert_eq!(params[1].0, "post_id");
1022        assert_eq!(params[1].1, "99");
1023    }
1024
1025    #[test]
1026    fn route_table_lookup_match() {
1027        let mut table: RouteTable<&str> = RouteTable::new();
1028        table.add(Method::Get, "/users/{id}", "get_user");
1029        table.add(Method::Post, "/users", "create_user");
1030
1031        match table.lookup("/users/42", Method::Get) {
1032            RouteLookup::Match { route, params } => {
1033                assert_eq!(*route, "get_user");
1034                assert_eq!(params[0].0, "id");
1035                assert_eq!(params[0].1, "42");
1036            }
1037            _ => panic!("Expected match"),
1038        }
1039    }
1040
1041    #[test]
1042    fn route_table_lookup_method_not_allowed() {
1043        let mut table: RouteTable<&str> = RouteTable::new();
1044        table.add(Method::Get, "/users", "get_users");
1045        table.add(Method::Post, "/users", "create_user");
1046
1047        match table.lookup("/users", Method::Delete) {
1048            RouteLookup::MethodNotAllowed { allowed } => {
1049                assert!(allowed.contains(&Method::Get));
1050                assert!(allowed.contains(&Method::Head));
1051                assert!(allowed.contains(&Method::Post));
1052            }
1053            _ => panic!("Expected MethodNotAllowed"),
1054        }
1055    }
1056
1057    #[test]
1058    fn route_table_lookup_not_found() {
1059        let mut table: RouteTable<&str> = RouteTable::new();
1060        table.add(Method::Get, "/users", "get_users");
1061
1062        assert!(matches!(
1063            table.lookup("/items", Method::Get),
1064            RouteLookup::NotFound
1065        ));
1066    }
1067
1068    #[test]
1069    fn route_table_head_matches_get() {
1070        let mut table: RouteTable<&str> = RouteTable::new();
1071        table.add(Method::Get, "/users", "get_users");
1072
1073        match table.lookup("/users", Method::Head) {
1074            RouteLookup::Match { route, .. } => {
1075                assert_eq!(*route, "get_users");
1076            }
1077            _ => panic!("Expected match for HEAD on GET route"),
1078        }
1079    }
1080
1081    #[test]
1082    fn format_allow_header_formats_methods() {
1083        let methods = vec![Method::Get, Method::Head, Method::Post];
1084        assert_eq!(format_allow_header(&methods), "GET, HEAD, POST");
1085    }
1086
1087    #[test]
1088    fn options_request_returns_method_not_allowed_with_allowed_methods() {
1089        let mut table: RouteTable<&str> = RouteTable::new();
1090        table.add(Method::Get, "/users", "get_users");
1091        table.add(Method::Post, "/users", "create_user");
1092
1093        // OPTIONS should return MethodNotAllowed with the allowed methods
1094        // (The app layer handles converting this to a 204 response)
1095        match table.lookup("/users", Method::Options) {
1096            RouteLookup::MethodNotAllowed { allowed } => {
1097                assert!(allowed.contains(&Method::Get));
1098                assert!(allowed.contains(&Method::Head));
1099                assert!(allowed.contains(&Method::Post));
1100            }
1101            _ => panic!("Expected MethodNotAllowed for OPTIONS request"),
1102        }
1103    }
1104
1105    #[test]
1106    fn options_request_on_nonexistent_path_returns_not_found() {
1107        let mut table: RouteTable<&str> = RouteTable::new();
1108        table.add(Method::Get, "/users", "get_users");
1109
1110        match table.lookup("/items", Method::Options) {
1111            RouteLookup::NotFound => {}
1112            _ => panic!("Expected NotFound for OPTIONS on non-existent path"),
1113        }
1114    }
1115
1116    #[test]
1117    fn explicit_options_handler_matches() {
1118        let mut table: RouteTable<&str> = RouteTable::new();
1119        table.add(Method::Get, "/api/resource", "get_resource");
1120        table.add(Method::Options, "/api/resource", "options_resource");
1121
1122        match table.lookup("/api/resource", Method::Options) {
1123            RouteLookup::Match { route, .. } => {
1124                assert_eq!(*route, "options_resource");
1125            }
1126            _ => panic!("Expected match for explicit OPTIONS handler"),
1127        }
1128    }
1129
1130    #[test]
1131    fn method_order_returns_expected_ordering() {
1132        assert!(method_order(Method::Get) < method_order(Method::Post));
1133        assert!(method_order(Method::Head) < method_order(Method::Post));
1134        assert!(method_order(Method::Options) < method_order(Method::Trace));
1135        assert!(method_order(Method::Delete) < method_order(Method::Options));
1136    }
1137
1138    // =========================================================================
1139    // URL GENERATION TESTS
1140    // =========================================================================
1141
1142    #[test]
1143    fn url_registry_new() {
1144        let registry = UrlRegistry::new();
1145        assert!(registry.is_empty());
1146        assert_eq!(registry.len(), 0);
1147        assert_eq!(registry.root_path(), "");
1148    }
1149
1150    #[test]
1151    fn url_registry_with_root_path() {
1152        let registry = UrlRegistry::with_root_path("/api/v1");
1153        assert_eq!(registry.root_path(), "/api/v1");
1154    }
1155
1156    #[test]
1157    fn url_registry_with_root_path_normalizes_trailing_slash() {
1158        let registry = UrlRegistry::with_root_path("/api/v1/");
1159        assert_eq!(registry.root_path(), "/api/v1");
1160
1161        let registry2 = UrlRegistry::with_root_path("/api///");
1162        assert_eq!(registry2.root_path(), "/api");
1163    }
1164
1165    #[test]
1166    fn url_registry_register_and_lookup() {
1167        let mut registry = UrlRegistry::new();
1168        registry.register("get_user", "/users/{id}");
1169
1170        assert!(registry.has_route("get_user"));
1171        assert!(!registry.has_route("nonexistent"));
1172        assert_eq!(registry.get_pattern("get_user"), Some("/users/{id}"));
1173        assert_eq!(registry.len(), 1);
1174    }
1175
1176    #[test]
1177    fn url_for_static_route() {
1178        let mut registry = UrlRegistry::new();
1179        registry.register("home", "/");
1180        registry.register("about", "/about");
1181
1182        let url = registry.url_for("home", &[], &[]).unwrap();
1183        assert_eq!(url, "/");
1184
1185        let url = registry.url_for("about", &[], &[]).unwrap();
1186        assert_eq!(url, "/about");
1187    }
1188
1189    #[test]
1190    fn url_for_with_path_param() {
1191        let mut registry = UrlRegistry::new();
1192        registry.register("get_user", "/users/{id}");
1193
1194        let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1195        assert_eq!(url, "/users/42");
1196    }
1197
1198    #[test]
1199    fn url_for_with_multiple_params() {
1200        let mut registry = UrlRegistry::new();
1201        registry.register("get_post", "/users/{user_id}/posts/{post_id}");
1202
1203        let url = registry
1204            .url_for("get_post", &[("user_id", "42"), ("post_id", "99")], &[])
1205            .unwrap();
1206        assert_eq!(url, "/users/42/posts/99");
1207    }
1208
1209    #[test]
1210    fn url_for_with_typed_param() {
1211        let mut registry = UrlRegistry::new();
1212        registry.register("get_item", "/items/{id:int}");
1213
1214        // Valid integer
1215        let url = registry.url_for("get_item", &[("id", "123")], &[]).unwrap();
1216        assert_eq!(url, "/items/123");
1217
1218        // Invalid integer
1219        let result = registry.url_for("get_item", &[("id", "abc")], &[]);
1220        assert!(matches!(result, Err(UrlError::InvalidParam { .. })));
1221    }
1222
1223    #[test]
1224    fn url_for_with_uuid_param() {
1225        let mut registry = UrlRegistry::new();
1226        registry.register("get_object", "/objects/{id:uuid}");
1227
1228        let url = registry
1229            .url_for(
1230                "get_object",
1231                &[("id", "550e8400-e29b-41d4-a716-446655440000")],
1232                &[],
1233            )
1234            .unwrap();
1235        assert_eq!(url, "/objects/550e8400-e29b-41d4-a716-446655440000");
1236    }
1237
1238    #[test]
1239    fn url_for_with_query_params() {
1240        let mut registry = UrlRegistry::new();
1241        registry.register("search", "/search");
1242
1243        let url = registry
1244            .url_for("search", &[], &[("q", "hello"), ("page", "1")])
1245            .unwrap();
1246        assert_eq!(url, "/search?q=hello&page=1");
1247    }
1248
1249    #[test]
1250    fn url_for_encodes_query_params() {
1251        let mut registry = UrlRegistry::new();
1252        registry.register("search", "/search");
1253
1254        let url = registry
1255            .url_for("search", &[], &[("q", "hello world"), ("filter", "a&b=c")])
1256            .unwrap();
1257        assert_eq!(url, "/search?q=hello%20world&filter=a%26b%3Dc");
1258    }
1259
1260    #[test]
1261    fn url_for_encodes_path_params() {
1262        let mut registry = UrlRegistry::new();
1263        registry.register("get_file", "/files/{name}");
1264
1265        let url = registry
1266            .url_for("get_file", &[("name", "my file.txt")], &[])
1267            .unwrap();
1268        assert_eq!(url, "/files/my%20file.txt");
1269    }
1270
1271    #[test]
1272    fn url_for_with_root_path() {
1273        let mut registry = UrlRegistry::with_root_path("/api/v1");
1274        registry.register("get_user", "/users/{id}");
1275
1276        let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
1277        assert_eq!(url, "/api/v1/users/42");
1278    }
1279
1280    #[test]
1281    fn url_for_route_not_found() {
1282        let registry = UrlRegistry::new();
1283        let result = registry.url_for("nonexistent", &[], &[]);
1284        assert!(matches!(result, Err(UrlError::RouteNotFound { name }) if name == "nonexistent"));
1285    }
1286
1287    #[test]
1288    fn url_for_missing_param() {
1289        let mut registry = UrlRegistry::new();
1290        registry.register("get_user", "/users/{id}");
1291
1292        let result = registry.url_for("get_user", &[], &[]);
1293        assert!(matches!(
1294            result,
1295            Err(UrlError::MissingParam { name, param }) if name == "get_user" && param == "id"
1296        ));
1297    }
1298
1299    #[test]
1300    fn url_for_with_path_converter() {
1301        let mut registry = UrlRegistry::new();
1302        registry.register("get_file", "/files/{path:path}");
1303
1304        let url = registry
1305            .url_for("get_file", &[("path", "docs/images/logo.png")], &[])
1306            .unwrap();
1307        // Path converter preserves slashes
1308        assert_eq!(url, "/files/docs/images/logo.png");
1309    }
1310
1311    #[test]
1312    fn url_encode_basic() {
1313        assert_eq!(url_encode("hello"), "hello");
1314        assert_eq!(url_encode("hello world"), "hello%20world");
1315        assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
1316        assert_eq!(url_encode("100%"), "100%25");
1317    }
1318
1319    #[test]
1320    fn url_encode_unicode() {
1321        assert_eq!(url_encode("日本"), "%E6%97%A5%E6%9C%AC");
1322        assert_eq!(url_encode("café"), "caf%C3%A9");
1323    }
1324
1325    #[test]
1326    fn url_decode_basic() {
1327        assert_eq!(url_decode("hello"), Some("hello".to_string()));
1328        assert_eq!(url_decode("hello%20world"), Some("hello world".to_string()));
1329        assert_eq!(url_decode("a%26b%3Dc"), Some("a&b=c".to_string()));
1330    }
1331
1332    #[test]
1333    fn url_decode_plus_as_space() {
1334        assert_eq!(url_decode("hello+world"), Some("hello world".to_string()));
1335    }
1336
1337    #[test]
1338    fn url_decode_invalid() {
1339        // Incomplete percent encoding
1340        assert_eq!(url_decode("hello%2"), None);
1341        assert_eq!(url_decode("hello%"), None);
1342        // Invalid hex
1343        assert_eq!(url_decode("hello%GG"), None);
1344    }
1345
1346    #[test]
1347    fn url_error_display() {
1348        let err = UrlError::RouteNotFound {
1349            name: "test".to_string(),
1350        };
1351        assert_eq!(format!("{}", err), "route 'test' not found");
1352
1353        let err = UrlError::MissingParam {
1354            name: "get_user".to_string(),
1355            param: "id".to_string(),
1356        };
1357        assert_eq!(
1358            format!("{}", err),
1359            "route 'get_user' requires parameter 'id'"
1360        );
1361
1362        let err = UrlError::InvalidParam {
1363            name: "get_item".to_string(),
1364            param: "id".to_string(),
1365            value: "abc".to_string(),
1366        };
1367        assert_eq!(
1368            format!("{}", err),
1369            "route 'get_item' parameter 'id': invalid value 'abc'"
1370        );
1371    }
1372
1373    #[test]
1374    fn url_registry_route_names_iterator() {
1375        let mut registry = UrlRegistry::new();
1376        registry.register("a", "/a");
1377        registry.register("b", "/b");
1378        registry.register("c", "/c");
1379
1380        let names: Vec<_> = registry.route_names().collect();
1381        assert_eq!(names.len(), 3);
1382        assert!(names.contains(&"a"));
1383        assert!(names.contains(&"b"));
1384        assert!(names.contains(&"c"));
1385    }
1386
1387    #[test]
1388    fn url_registry_set_root_path() {
1389        let mut registry = UrlRegistry::new();
1390        registry.register("home", "/");
1391
1392        let url1 = registry.url_for("home", &[], &[]).unwrap();
1393        assert_eq!(url1, "/");
1394
1395        registry.set_root_path("/api");
1396        let url2 = registry.url_for("home", &[], &[]).unwrap();
1397        assert_eq!(url2, "/api/");
1398    }
1399
1400    // ========================================================================
1401    // Trailing slash normalization tests
1402    // ========================================================================
1403
1404    #[test]
1405    fn trailing_slash_strict_mode_matches_both_due_to_segment_parsing() {
1406        // Note: segment_ranges normalizes trailing slashes, so /users/ and /users
1407        // both resolve to the same segments ["users"]. In strict mode, the lookup
1408        // still finds the match because exact matching happens at the segment level.
1409        let mut table = RouteTable::new();
1410        table.add(Method::Get, "/users", "users");
1411
1412        assert!(matches!(
1413            table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Strict),
1414            RouteLookup::Match {
1415                route: &"users",
1416                ..
1417            }
1418        ));
1419
1420        // Trailing slash still matches because segment parsing normalizes it
1421        assert!(matches!(
1422            table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Strict),
1423            RouteLookup::Match {
1424                route: &"users",
1425                ..
1426            }
1427        ));
1428    }
1429
1430    #[test]
1431    fn trailing_slash_redirect_mode_exact_match_no_redirect() {
1432        let mut table = RouteTable::new();
1433        table.add(Method::Get, "/users", "users");
1434
1435        // Exact match: no redirect needed
1436        assert!(matches!(
1437            table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Redirect),
1438            RouteLookup::Match {
1439                route: &"users",
1440                ..
1441            }
1442        ));
1443
1444        // With trailing slash: exact match found first (segment parsing normalizes)
1445        // so no redirect is triggered
1446        assert!(matches!(
1447            table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Redirect),
1448            RouteLookup::Match {
1449                route: &"users",
1450                ..
1451            }
1452        ));
1453    }
1454
1455    #[test]
1456    fn trailing_slash_match_both_mode() {
1457        let mut table = RouteTable::new();
1458        table.add(Method::Get, "/users", "users");
1459
1460        // Both forms match
1461        assert!(matches!(
1462            table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::MatchBoth),
1463            RouteLookup::Match {
1464                route: &"users",
1465                ..
1466            }
1467        ));
1468        assert!(matches!(
1469            table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::MatchBoth),
1470            RouteLookup::Match {
1471                route: &"users",
1472                ..
1473            }
1474        ));
1475    }
1476
1477    #[test]
1478    fn trailing_slash_root_path_not_redirected() {
1479        let mut table = RouteTable::new();
1480        table.add(Method::Get, "/", "root");
1481
1482        assert!(matches!(
1483            table.lookup_with_trailing_slash("/", Method::Get, TrailingSlashMode::Redirect),
1484            RouteLookup::Match { route: &"root", .. }
1485        ));
1486    }
1487
1488    #[test]
1489    fn trailing_slash_with_path_params() {
1490        let mut table = RouteTable::new();
1491        table.add(Method::Get, "/users/{id}", "get_user");
1492
1493        // Segment parsing normalizes trailing slash, so /users/42/ matches /users/{id}
1494        match table.lookup_with_trailing_slash(
1495            "/users/42/",
1496            Method::Get,
1497            TrailingSlashMode::MatchBoth,
1498        ) {
1499            RouteLookup::Match { params, .. } => {
1500                assert_eq!(params.len(), 1);
1501                assert_eq!(params[0], ("id".to_string(), "42".to_string()));
1502            }
1503            other => panic!("expected Match, got {:?}", other),
1504        }
1505    }
1506
1507    #[test]
1508    fn trailing_slash_not_found_stays_not_found() {
1509        let mut table = RouteTable::new();
1510        table.add(Method::Get, "/users", "users");
1511
1512        // Nonexistent path stays NotFound regardless of mode
1513        assert!(matches!(
1514            table.lookup_with_trailing_slash(
1515                "/nonexistent",
1516                Method::Get,
1517                TrailingSlashMode::Redirect
1518            ),
1519            RouteLookup::NotFound
1520        ));
1521        assert!(matches!(
1522            table.lookup_with_trailing_slash(
1523                "/nonexistent/",
1524                Method::Get,
1525                TrailingSlashMode::Redirect
1526            ),
1527            RouteLookup::NotFound
1528        ));
1529    }
1530
1531    #[test]
1532    fn trailing_slash_mode_default_is_strict() {
1533        assert_eq!(TrailingSlashMode::default(), TrailingSlashMode::Strict);
1534    }
1535
1536    #[test]
1537    fn app_config_trailing_slash_mode() {
1538        use crate::app::AppConfig;
1539
1540        let config = AppConfig::new();
1541        assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Strict);
1542
1543        let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::Redirect);
1544        assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Redirect);
1545
1546        let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::MatchBoth);
1547        assert_eq!(config.trailing_slash_mode, TrailingSlashMode::MatchBoth);
1548    }
1549}