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