Skip to main content

hypen_engine/portable/
route.rs

1//! URL path matcher.
2//!
3//! Given a pattern (`"/users/:id"`, `"/api/*"`, `"/dashboard"`) and a
4//! path (`"/users/42"`), return `Some(RouteMatch { params })` on a
5//! successful match, `None` otherwise.
6
7use std::collections::BTreeMap;
8
9/// Result of a successful [`match_path`] call.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct RouteMatch {
12    /// Named parameters extracted from `:name` segments, in insertion
13    /// order. `BTreeMap` gives deterministic iteration for tests and
14    /// fixture comparison.
15    pub params: BTreeMap<String, String>,
16}
17
18/// Match `pattern` against `path`.
19///
20/// # Pattern syntax
21///
22/// * Exact: `/dashboard` matches only `/dashboard`.
23/// * Parameter: `/users/:id` matches `/users/42` with `params["id"] = "42"`.
24/// * Trailing wildcard: `/api/*` matches `/api`, `/api/users`, and
25///   `/api/users/42`. The wildcard must be the final segment.
26///
27/// Leading and trailing slashes are normalised by splitting on `/` and
28/// discarding empty segments, so `"/users/42"` and `"users/42/"` behave
29/// identically.
30pub fn match_path(pattern: &str, path: &str) -> Option<RouteMatch> {
31    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
32    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
33
34    // Trailing wildcard: /prefix/* matches /prefix and anything under it.
35    if pattern_parts.last() == Some(&"*") {
36        let prefix = &pattern_parts[..pattern_parts.len() - 1];
37        if path_parts.len() < prefix.len() {
38            return None;
39        }
40        let mut params = BTreeMap::new();
41        for (pp, rp) in prefix.iter().zip(path_parts.iter()) {
42            if let Some(name) = pp.strip_prefix(':') {
43                params.insert(name.to_string(), rp.to_string());
44            } else if pp != rp {
45                return None;
46            }
47        }
48        return Some(RouteMatch { params });
49    }
50
51    if pattern_parts.len() != path_parts.len() {
52        return None;
53    }
54
55    let mut params = BTreeMap::new();
56    for (pp, rp) in pattern_parts.iter().zip(path_parts.iter()) {
57        if let Some(name) = pp.strip_prefix(':') {
58            params.insert(name.to_string(), rp.to_string());
59        } else if pp != rp {
60            return None;
61        }
62    }
63    Some(RouteMatch { params })
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn m(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
71        match_path(pattern, path).map(|m| m.params.into_iter().collect())
72    }
73
74    #[test]
75    fn exact_match() {
76        assert_eq!(m("/dashboard", "/dashboard"), Some(vec![]));
77        assert_eq!(m("/dashboard", "/other"), None);
78    }
79
80    #[test]
81    fn single_param() {
82        assert_eq!(
83            m("/users/:id", "/users/42"),
84            Some(vec![("id".into(), "42".into())])
85        );
86    }
87
88    #[test]
89    fn multiple_params() {
90        assert_eq!(
91            m("/users/:id/posts/:postId", "/users/42/posts/99"),
92            Some(vec![
93                ("id".into(), "42".into()),
94                ("postId".into(), "99".into()),
95            ])
96        );
97    }
98
99    #[test]
100    fn wildcard_matches_prefix_and_descendants() {
101        assert!(match_path("/api/*", "/api").is_some());
102        assert!(match_path("/api/*", "/api/users").is_some());
103        assert!(match_path("/api/*", "/api/users/42").is_some());
104        assert!(match_path("/api/*", "/other").is_none());
105    }
106
107    #[test]
108    fn wildcard_preserves_leading_params() {
109        // A pattern can combine params and a trailing wildcard.
110        let matched = match_path("/users/:id/*", "/users/42/posts/99").unwrap();
111        assert_eq!(matched.params.get("id"), Some(&"42".to_string()));
112    }
113
114    #[test]
115    fn rejects_length_mismatch() {
116        assert!(match_path("/users/:id", "/users").is_none());
117        assert!(match_path("/users/:id", "/users/42/extra").is_none());
118    }
119
120    #[test]
121    fn trailing_slash_is_ignored() {
122        assert!(match_path("/users/:id", "/users/42/").is_some());
123        assert!(match_path("/users/:id/", "/users/42").is_some());
124    }
125
126    #[test]
127    fn root_matches() {
128        // Both `""` and `"/"` collapse to zero segments after splitting.
129        assert!(match_path("/", "/").is_some());
130        assert!(match_path("", "/").is_some());
131    }
132}