hypen_engine/portable/
route.rs1use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct RouteMatch {
12 pub params: BTreeMap<String, String>,
16}
17
18pub 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 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 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 assert!(match_path("/", "/").is_some());
130 assert!(match_path("", "/").is_some());
131 }
132}