1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::events::EventEmitter;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct RouteMatch {
9 pub params: HashMap<String, String>,
11 pub query: HashMap<String, String>,
13 pub path: String,
15}
16
17#[derive(Debug, Clone)]
19pub struct RouteState {
20 pub current_path: String,
21 pub params: HashMap<String, String>,
22 pub query: HashMap<String, String>,
23 pub previous_path: Option<String>,
24}
25
26pub struct HypenRouter {
54 inner: Mutex<RouterInner>,
55 events: EventEmitter,
56}
57
58struct RouterInner {
59 current_path: String,
60 params: HashMap<String, String>,
61 query: HashMap<String, String>,
62 previous_path: Option<String>,
63 history: Vec<String>,
64}
65
66impl HypenRouter {
67 pub fn new() -> Self {
68 Self {
69 inner: Mutex::new(RouterInner {
70 current_path: "/".to_string(),
71 params: HashMap::new(),
72 query: HashMap::new(),
73 previous_path: None,
74 history: vec!["/".to_string()],
75 }),
76 events: EventEmitter::new(),
77 }
78 }
79
80 pub fn push(&self, path: &str) {
82 let (clean_path, query) = parse_path_and_query(path);
83 let mut inner = self.inner.lock().unwrap();
84 let prev = inner.current_path.clone();
85 inner.previous_path = Some(prev);
86 inner.current_path = clean_path.clone();
87 inner.query = query;
88 inner.params.clear();
89 inner.history.push(clean_path);
90 drop(inner);
91
92 self.events.emit(
93 crate::events::framework::ROUTE_CHANGED,
94 &serde_json::json!({
95 "path": path,
96 }),
97 );
98 }
99
100 pub fn replace(&self, path: &str) {
102 let (clean_path, query) = parse_path_and_query(path);
103 let mut inner = self.inner.lock().unwrap();
104 inner.current_path = clean_path.clone();
105 inner.query = query;
106 inner.params.clear();
107 if let Some(last) = inner.history.last_mut() {
108 *last = clean_path;
109 }
110 }
111
112 pub fn state(&self) -> RouteState {
114 let inner = self.inner.lock().unwrap();
115 RouteState {
116 current_path: inner.current_path.clone(),
117 params: inner.params.clone(),
118 query: inner.query.clone(),
119 previous_path: inner.previous_path.clone(),
120 }
121 }
122
123 pub fn current_path(&self) -> String {
125 self.inner.lock().unwrap().current_path.clone()
126 }
127
128 pub fn query(&self) -> HashMap<String, String> {
130 self.inner.lock().unwrap().query.clone()
131 }
132
133 pub fn match_path(&self, pattern: &str, path: &str) -> Option<RouteMatch> {
137 let (clean_path, query) = parse_path_and_query(path);
138 match_pattern(pattern, &clean_path).map(|params| RouteMatch {
139 params,
140 query,
141 path: clean_path,
142 })
143 }
144
145 pub fn is_active(&self, pattern: &str) -> bool {
147 let inner = self.inner.lock().unwrap();
148 match_pattern(pattern, &inner.current_path).is_some()
149 }
150
151 pub fn on_navigate<F>(&self, handler: F) -> crate::events::SubscriptionId
153 where
154 F: Fn(&serde_json::Value) + Send + Sync + 'static,
155 {
156 self.events
157 .on(crate::events::framework::ROUTE_CHANGED, handler)
158 }
159
160 pub fn build_url(path: &str, query: &HashMap<String, String>) -> String {
162 if query.is_empty() {
163 return path.to_string();
164 }
165 let qs: Vec<String> = query
166 .iter()
167 .map(|(k, v)| format!("{}={}", encode_uri_component(k), encode_uri_component(v)))
168 .collect();
169 format!("{path}?{}", qs.join("&"))
170 }
171}
172
173impl Default for HypenRouter {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179fn encode_uri_component(input: &str) -> String {
184 let mut out = String::with_capacity(input.len());
185 for byte in input.bytes() {
186 match byte {
187 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
188 out.push(byte as char)
189 }
190 _ => {
191 out.push('%');
192 out.push(char::from(HEX_CHARS[(byte >> 4) as usize]));
193 out.push(char::from(HEX_CHARS[(byte & 0x0F) as usize]));
194 }
195 }
196 }
197 out
198}
199
200fn decode_uri_component(input: &str) -> String {
202 let mut out = Vec::with_capacity(input.len());
203 let bytes = input.as_bytes();
204 let mut i = 0;
205 while i < bytes.len() {
206 if bytes[i] == b'%' && i + 2 < bytes.len() {
207 if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
208 out.push((hi << 4) | lo);
209 i += 3;
210 continue;
211 }
212 }
213 if bytes[i] == b'+' {
215 out.push(b' ');
216 } else {
217 out.push(bytes[i]);
218 }
219 i += 1;
220 }
221 String::from_utf8_lossy(&out).into_owned()
222}
223
224const HEX_CHARS: [u8; 16] = *b"0123456789ABCDEF";
225
226fn hex_val(b: u8) -> Option<u8> {
227 match b {
228 b'0'..=b'9' => Some(b - b'0'),
229 b'A'..=b'F' => Some(b - b'A' + 10),
230 b'a'..=b'f' => Some(b - b'a' + 10),
231 _ => None,
232 }
233}
234
235fn parse_path_and_query(full_path: &str) -> (String, HashMap<String, String>) {
240 if let Some((path, query_str)) = full_path.split_once('?') {
241 let query = query_str
242 .split('&')
243 .filter_map(|pair| {
244 let (k, v) = pair.split_once('=')?;
245 Some((decode_uri_component(k), decode_uri_component(v)))
246 })
247 .collect();
248 (path.to_string(), query)
249 } else {
250 (full_path.to_string(), HashMap::new())
251 }
252}
253
254fn match_pattern(pattern: &str, path: &str) -> Option<HashMap<String, String>> {
256 let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
257 let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
258
259 if pattern_parts.last() == Some(&"*") {
261 let prefix = &pattern_parts[..pattern_parts.len() - 1];
262 if path_parts.len() < prefix.len() {
263 return None;
264 }
265 let mut params = HashMap::new();
266 for (pp, rp) in prefix.iter().zip(path_parts.iter()) {
267 if let Some(name) = pp.strip_prefix(':') {
268 params.insert(name.to_string(), rp.to_string());
269 } else if pp != rp {
270 return None;
271 }
272 }
273 return Some(params);
274 }
275
276 if pattern_parts.len() != path_parts.len() {
277 return None;
278 }
279
280 let mut params = HashMap::new();
281 for (pp, rp) in pattern_parts.iter().zip(path_parts.iter()) {
282 if let Some(name) = pp.strip_prefix(':') {
283 params.insert(name.to_string(), rp.to_string());
284 } else if pp != rp {
285 return None;
286 }
287 }
288
289 Some(params)
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn test_push_and_state() {
298 let router = HypenRouter::new();
299 router.push("/users/42?tab=profile");
300
301 let state = router.state();
302 assert_eq!(state.current_path, "/users/42");
303 assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
304 assert_eq!(state.previous_path, Some("/".to_string()));
305 }
306
307 #[test]
308 fn test_replace() {
309 let router = HypenRouter::new();
310 router.push("/page1");
311 router.replace("/page2");
312
313 let state = router.state();
314 assert_eq!(state.current_path, "/page2");
315 assert_eq!(state.previous_path, Some("/".to_string()));
318 }
319
320 #[test]
321 fn test_match_exact() {
322 let router = HypenRouter::new();
323 let m = router.match_path("/dashboard", "/dashboard");
324 assert!(m.is_some());
325 assert!(m.unwrap().params.is_empty());
326 }
327
328 #[test]
329 fn test_match_params() {
330 let router = HypenRouter::new();
331 let m = router
332 .match_path("/users/:id/posts/:postId", "/users/42/posts/99")
333 .unwrap();
334 assert_eq!(m.params["id"], "42");
335 assert_eq!(m.params["postId"], "99");
336 }
337
338 #[test]
339 fn test_match_wildcard() {
340 let router = HypenRouter::new();
341 assert!(router.match_path("/api/*", "/api/users/list").is_some());
342 assert!(router.match_path("/api/*", "/api").is_some());
343 assert!(router.match_path("/api/*", "/other").is_none());
344 }
345
346 #[test]
347 fn test_no_match() {
348 let router = HypenRouter::new();
349 assert!(router.match_path("/users/:id", "/posts/42").is_none());
350 assert!(router.match_path("/users/:id", "/users/42/extra").is_none());
351 }
352
353 #[test]
354 fn test_is_active() {
355 let router = HypenRouter::new();
356 router.push("/users/42");
357
358 assert!(router.is_active("/users/:id"));
359 assert!(!router.is_active("/posts/:id"));
360 }
361
362 #[test]
363 fn test_query_parsing() {
364 let (path, query) = parse_path_and_query("/search?q=hello&page=2");
365 assert_eq!(path, "/search");
366 assert_eq!(query["q"], "hello");
367 assert_eq!(query["page"], "2");
368 }
369
370 #[test]
371 fn test_build_url() {
372 let mut query = HashMap::new();
373 query.insert("tab".to_string(), "profile".to_string());
374
375 let url = HypenRouter::build_url("/users/42", &query);
376 assert_eq!(url, "/users/42?tab=profile");
377 }
378
379 #[test]
380 fn test_build_url_no_query() {
381 let url = HypenRouter::build_url("/home", &HashMap::new());
382 assert_eq!(url, "/home");
383 }
384
385 #[test]
386 fn test_build_url_encodes_special_chars() {
387 let mut query = HashMap::new();
388 query.insert("msg".to_string(), "hello world".to_string());
389 query.insert("a&b".to_string(), "1=2".to_string());
390
391 let url = HypenRouter::build_url("/search", &query);
392 assert!(url.contains("msg=hello%20world"));
394 assert!(url.contains("a%26b=1%3D2"));
395 assert!(url.starts_with("/search?"));
396 }
397
398 #[test]
399 fn test_parse_decodes_encoded_query() {
400 let (path, query) = parse_path_and_query("/search?msg=hello%20world&a%26b=1%3D2");
401 assert_eq!(path, "/search");
402 assert_eq!(query.get("msg").map(String::as_str), Some("hello world"));
403 assert_eq!(query.get("a&b").map(String::as_str), Some("1=2"));
404 }
405
406 #[test]
407 fn test_encode_decode_roundtrip() {
408 let original = "hello world&foo=bar?baz#qux%plus+sign";
409 let encoded = encode_uri_component(original);
410 let decoded = decode_uri_component(&encoded);
411 assert_eq!(decoded, original);
412 assert!(!encoded.contains(' '));
414 assert!(!encoded.contains('&'));
415 assert!(!encoded.contains('='));
416 assert!(!encoded.contains('?'));
417 assert!(!encoded.contains('#'));
418 }
419
420 #[test]
421 fn test_plus_decodes_to_space() {
422 let (path, query) = parse_path_and_query("/search?q=hello+world");
423 assert_eq!(path, "/search");
424 assert_eq!(query.get("q").map(String::as_str), Some("hello world"));
425 }
426
427 #[test]
428 fn test_on_navigate() {
429 use std::sync::atomic::{AtomicI32, Ordering};
430 use std::sync::Arc;
431
432 let router = HypenRouter::new();
433 let count = Arc::new(AtomicI32::new(0));
434 let count_clone = count.clone();
435
436 router.on_navigate(move |_| {
437 count_clone.fetch_add(1, Ordering::SeqCst);
438 });
439
440 router.push("/a");
441 router.push("/b");
442
443 assert_eq!(count.load(Ordering::SeqCst), 2);
444 }
445}