aprender_present_lib/browser/
router.rs1use presentar_core::Router;
15use std::sync::Mutex;
16
17#[derive(Debug)]
22pub struct BrowserRouter {
23 #[cfg(not(target_arch = "wasm32"))]
25 state: Mutex<BrowserRouterState>,
26}
27
28impl Default for BrowserRouter {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34#[cfg(not(target_arch = "wasm32"))]
35#[derive(Debug)]
36struct BrowserRouterState {
37 current: String,
38 history: Vec<String>,
39 history_index: usize,
40}
41
42impl BrowserRouter {
43 #[must_use]
45 pub fn new() -> Self {
46 #[cfg(target_arch = "wasm32")]
47 {
48 Self {}
49 }
50 #[cfg(not(target_arch = "wasm32"))]
51 {
52 Self {
53 state: Mutex::new(BrowserRouterState {
54 current: "/".to_string(),
55 history: vec!["/".to_string()],
56 history_index: 0,
57 }),
58 }
59 }
60 }
61
62 #[must_use]
64 pub fn pathname(&self) -> String {
65 #[cfg(target_arch = "wasm32")]
66 {
67 self.pathname_wasm()
68 }
69 #[cfg(not(target_arch = "wasm32"))]
70 {
71 self.state
72 .lock()
73 .map(|s| s.current.clone())
74 .unwrap_or_else(|_| "/".to_string())
75 }
76 }
77
78 #[must_use]
80 pub fn search(&self) -> String {
81 #[cfg(target_arch = "wasm32")]
82 {
83 self.search_wasm()
84 }
85 #[cfg(not(target_arch = "wasm32"))]
86 {
87 let current = self.pathname();
89 current
90 .find('?')
91 .map(|i| current[i..].to_string())
92 .unwrap_or_default()
93 }
94 }
95
96 #[must_use]
98 pub fn hash(&self) -> String {
99 #[cfg(target_arch = "wasm32")]
100 {
101 self.hash_wasm()
102 }
103 #[cfg(not(target_arch = "wasm32"))]
104 {
105 let current = self.pathname();
107 current
108 .find('#')
109 .map(|i| current[i..].to_string())
110 .unwrap_or_default()
111 }
112 }
113
114 pub fn push(&self, path: &str) {
116 #[cfg(target_arch = "wasm32")]
117 {
118 self.push_wasm(path);
119 }
120 #[cfg(not(target_arch = "wasm32"))]
121 {
122 if let Ok(mut state) = self.state.lock() {
123 let idx = state.history_index;
125 if idx < state.history.len().saturating_sub(1) {
126 state.history.truncate(idx + 1);
127 }
128 state.current = path.to_string();
129 state.history.push(path.to_string());
130 state.history_index = state.history.len() - 1;
131 }
132 }
133 }
134
135 pub fn replace(&self, path: &str) {
137 #[cfg(target_arch = "wasm32")]
138 {
139 self.replace_wasm(path);
140 }
141 #[cfg(not(target_arch = "wasm32"))]
142 {
143 if let Ok(mut state) = self.state.lock() {
144 state.current = path.to_string();
145 let idx = state.history_index;
146 if let Some(entry) = state.history.get_mut(idx) {
147 *entry = path.to_string();
148 }
149 }
150 }
151 }
152
153 pub fn back(&self) {
155 #[cfg(target_arch = "wasm32")]
156 {
157 self.back_wasm();
158 }
159 #[cfg(not(target_arch = "wasm32"))]
160 {
161 if let Ok(mut state) = self.state.lock() {
162 if state.history_index > 0 {
163 state.history_index -= 1;
164 state.current = state.history[state.history_index].clone();
165 }
166 }
167 }
168 }
169
170 pub fn forward(&self) {
172 #[cfg(target_arch = "wasm32")]
173 {
174 self.forward_wasm();
175 }
176 #[cfg(not(target_arch = "wasm32"))]
177 {
178 if let Ok(mut state) = self.state.lock() {
179 if state.history_index < state.history.len().saturating_sub(1) {
180 state.history_index += 1;
181 state.current = state.history[state.history_index].clone();
182 }
183 }
184 }
185 }
186
187 pub fn go(&self, delta: i32) {
189 #[cfg(target_arch = "wasm32")]
190 {
191 self.go_wasm(delta);
192 }
193 #[cfg(not(target_arch = "wasm32"))]
194 {
195 if let Ok(mut state) = self.state.lock() {
196 let new_index = if delta >= 0 {
197 state.history_index.saturating_add(delta as usize)
198 } else {
199 state.history_index.saturating_sub((-delta) as usize)
200 };
201 if new_index < state.history.len() {
202 state.history_index = new_index;
203 state.current = state.history[new_index].clone();
204 }
205 }
206 }
207 }
208
209 #[must_use]
211 pub fn history_len(&self) -> usize {
212 #[cfg(target_arch = "wasm32")]
213 {
214 self.history_len_wasm()
215 }
216 #[cfg(not(target_arch = "wasm32"))]
217 {
218 self.state.lock().map(|s| s.history.len()).unwrap_or(0)
219 }
220 }
221
222 #[must_use]
224 pub fn can_go_back(&self) -> bool {
225 #[cfg(target_arch = "wasm32")]
226 {
227 self.history_len() > 1
228 }
229 #[cfg(not(target_arch = "wasm32"))]
230 {
231 self.state
232 .lock()
233 .map(|s| s.history_index > 0)
234 .unwrap_or(false)
235 }
236 }
237
238 #[must_use]
240 pub fn can_go_forward(&self) -> bool {
241 #[cfg(not(target_arch = "wasm32"))]
242 {
243 self.state
244 .lock()
245 .map(|s| s.history_index < s.history.len().saturating_sub(1))
246 .unwrap_or(false)
247 }
248 #[cfg(target_arch = "wasm32")]
249 {
250 false }
252 }
253
254 #[cfg(target_arch = "wasm32")]
256 fn pathname_wasm(&self) -> String {
257 web_sys::window()
258 .and_then(|w| w.location().pathname().ok())
259 .unwrap_or_else(|| "/".to_string())
260 }
261
262 #[cfg(target_arch = "wasm32")]
263 fn search_wasm(&self) -> String {
264 web_sys::window()
265 .and_then(|w| w.location().search().ok())
266 .unwrap_or_default()
267 }
268
269 #[cfg(target_arch = "wasm32")]
270 fn hash_wasm(&self) -> String {
271 web_sys::window()
272 .and_then(|w| w.location().hash().ok())
273 .unwrap_or_default()
274 }
275
276 #[cfg(target_arch = "wasm32")]
277 fn push_wasm(&self, path: &str) {
278 if let Some(window) = web_sys::window() {
279 if let Ok(history) = window.history() {
280 let _ = history.push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(path));
281 }
282 }
283 }
284
285 #[cfg(target_arch = "wasm32")]
286 fn replace_wasm(&self, path: &str) {
287 if let Some(window) = web_sys::window() {
288 if let Ok(history) = window.history() {
289 let _ =
290 history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(path));
291 }
292 }
293 }
294
295 #[cfg(target_arch = "wasm32")]
296 fn back_wasm(&self) {
297 if let Some(window) = web_sys::window() {
298 if let Ok(history) = window.history() {
299 let _ = history.back();
300 }
301 }
302 }
303
304 #[cfg(target_arch = "wasm32")]
305 fn forward_wasm(&self) {
306 if let Some(window) = web_sys::window() {
307 if let Ok(history) = window.history() {
308 let _ = history.forward();
309 }
310 }
311 }
312
313 #[cfg(target_arch = "wasm32")]
314 fn go_wasm(&self, delta: i32) {
315 if let Some(window) = web_sys::window() {
316 if let Ok(history) = window.history() {
317 let _ = history.go_with_delta(delta);
318 }
319 }
320 }
321
322 #[cfg(target_arch = "wasm32")]
323 fn history_len_wasm(&self) -> usize {
324 web_sys::window()
325 .and_then(|w| w.history().ok())
326 .and_then(|h| h.length().ok())
327 .unwrap_or(0) as usize
328 }
329}
330
331impl Router for BrowserRouter {
332 fn navigate(&self, route: &str) {
333 self.push(route);
334 }
335
336 fn current_route(&self) -> String {
337 self.pathname()
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct RouteMatch {
344 pub pattern: String,
346 pub params: std::collections::HashMap<String, String>,
348}
349
350impl RouteMatch {
351 #[must_use]
353 pub fn new(pattern: impl Into<String>) -> Self {
354 Self {
355 pattern: pattern.into(),
356 params: std::collections::HashMap::new(),
357 }
358 }
359
360 #[must_use]
362 pub fn param(&self, name: &str) -> Option<&str> {
363 self.params.get(name).map(String::as_str)
364 }
365}
366
367#[derive(Debug, Clone)]
369pub struct RouteMatcher {
370 routes: Vec<RoutePattern>,
371}
372
373#[derive(Debug, Clone)]
374struct RoutePattern {
375 pattern: String,
376 segments: Vec<Segment>,
377}
378
379#[derive(Debug, Clone)]
380enum Segment {
381 Static(String),
382 Param(String),
383 Wildcard,
384}
385
386impl RouteMatcher {
387 #[must_use]
389 pub fn new() -> Self {
390 Self { routes: Vec::new() }
391 }
392
393 pub fn add(&mut self, pattern: &str) -> &mut Self {
400 let segments = pattern
401 .split('/')
402 .filter(|s| !s.is_empty())
403 .map(|s| {
404 if s == "*" {
405 Segment::Wildcard
406 } else if let Some(name) = s.strip_prefix(':') {
407 Segment::Param(name.to_string())
408 } else {
409 Segment::Static(s.to_string())
410 }
411 })
412 .collect();
413
414 self.routes.push(RoutePattern {
415 pattern: pattern.to_string(),
416 segments,
417 });
418 self
419 }
420
421 #[must_use]
423 pub fn match_path(&self, path: &str) -> Option<RouteMatch> {
424 let path = path.split('?').next().unwrap_or(path);
426 let path = path.split('#').next().unwrap_or(path);
427
428 let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
429
430 for route in &self.routes {
431 if let Some(params) = self.try_match(&route.segments, &path_segments) {
432 return Some(RouteMatch {
433 pattern: route.pattern.clone(),
434 params,
435 });
436 }
437 }
438
439 None
440 }
441
442 fn try_match(
443 &self,
444 pattern: &[Segment],
445 path: &[&str],
446 ) -> Option<std::collections::HashMap<String, String>> {
447 let mut params = std::collections::HashMap::new();
448 let mut path_iter = path.iter();
449
450 for segment in pattern {
451 match segment {
452 Segment::Static(expected) => {
453 let actual = path_iter.next()?;
454 if *actual != expected {
455 return None;
456 }
457 }
458 Segment::Param(name) => {
459 let value = path_iter.next()?;
460 params.insert(name.clone(), (*value).to_string());
461 }
462 Segment::Wildcard => {
463 let rest: Vec<&str> = path_iter.copied().collect();
465 params.insert("*".to_string(), rest.join("/"));
466 return Some(params);
467 }
468 }
469 }
470
471 if path_iter.next().is_some() {
473 return None;
474 }
475
476 Some(params)
477 }
478}
479
480impl Default for RouteMatcher {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
495 fn test_router_new() {
496 let router = BrowserRouter::new();
497 assert_eq!(router.pathname(), "/");
498 }
499
500 #[test]
501 fn test_router_push() {
502 let router = BrowserRouter::new();
503 router.push("/dashboard");
504 assert_eq!(router.pathname(), "/dashboard");
505 }
506
507 #[test]
508 fn test_router_multiple_push() {
509 let router = BrowserRouter::new();
510 router.push("/page1");
511 router.push("/page2");
512 router.push("/page3");
513 assert_eq!(router.pathname(), "/page3");
514 assert_eq!(router.history_len(), 4); }
516
517 #[test]
518 fn test_router_replace() {
519 let router = BrowserRouter::new();
520 router.push("/original");
521 router.replace("/replaced");
522 assert_eq!(router.pathname(), "/replaced");
523 assert_eq!(router.history_len(), 2); }
525
526 #[test]
527 fn test_router_back() {
528 let router = BrowserRouter::new();
529 router.push("/page1");
530 router.push("/page2");
531 router.back();
532 assert_eq!(router.pathname(), "/page1");
533 }
534
535 #[test]
536 fn test_router_forward() {
537 let router = BrowserRouter::new();
538 router.push("/page1");
539 router.push("/page2");
540 router.back();
541 router.forward();
542 assert_eq!(router.pathname(), "/page2");
543 }
544
545 #[test]
546 fn test_router_go_positive() {
547 let router = BrowserRouter::new();
548 router.push("/page1");
549 router.push("/page2");
550 router.push("/page3");
551 router.go(-2);
552 assert_eq!(router.pathname(), "/page1");
553 router.go(1);
554 assert_eq!(router.pathname(), "/page2");
555 }
556
557 #[test]
558 fn test_router_go_negative() {
559 let router = BrowserRouter::new();
560 router.push("/page1");
561 router.push("/page2");
562 router.go(-1);
563 assert_eq!(router.pathname(), "/page1");
564 }
565
566 #[test]
567 fn test_router_can_go_back() {
568 let router = BrowserRouter::new();
569 assert!(!router.can_go_back());
570 router.push("/page1");
571 assert!(router.can_go_back());
572 }
573
574 #[test]
575 fn test_router_can_go_forward() {
576 let router = BrowserRouter::new();
577 router.push("/page1");
578 router.push("/page2");
579 assert!(!router.can_go_forward());
580 router.back();
581 assert!(router.can_go_forward());
582 }
583
584 #[test]
585 fn test_router_trait_navigate() {
586 let router = BrowserRouter::new();
587 router.navigate("/test");
588 assert_eq!(router.current_route(), "/test");
589 }
590
591 #[test]
592 fn test_router_back_at_start() {
593 let router = BrowserRouter::new();
594 router.back(); assert_eq!(router.pathname(), "/");
596 }
597
598 #[test]
599 fn test_router_forward_at_end() {
600 let router = BrowserRouter::new();
601 router.push("/page1");
602 router.forward(); assert_eq!(router.pathname(), "/page1");
604 }
605
606 #[test]
607 fn test_router_history_truncation() {
608 let router = BrowserRouter::new();
609 router.push("/page1");
610 router.push("/page2");
611 router.push("/page3");
612 router.back();
613 router.back(); router.push("/new"); assert_eq!(router.pathname(), "/new");
616 router.forward(); assert_eq!(router.pathname(), "/new");
618 }
619
620 #[test]
625 fn test_route_match_new() {
626 let m = RouteMatch::new("/users/:id");
627 assert_eq!(m.pattern, "/users/:id");
628 assert!(m.params.is_empty());
629 }
630
631 #[test]
632 fn test_route_match_param() {
633 let mut m = RouteMatch::new("/users/:id");
634 m.params.insert("id".to_string(), "123".to_string());
635 assert_eq!(m.param("id"), Some("123"));
636 assert_eq!(m.param("other"), None);
637 }
638
639 #[test]
644 fn test_matcher_static_route() {
645 let mut matcher = RouteMatcher::new();
646 matcher.add("/users/list");
647
648 let result = matcher.match_path("/users/list");
649 assert!(result.is_some());
650 assert_eq!(result.unwrap().pattern, "/users/list");
651
652 assert!(matcher.match_path("/users").is_none());
653 assert!(matcher.match_path("/users/list/extra").is_none());
654 }
655
656 #[test]
657 fn test_matcher_param_route() {
658 let mut matcher = RouteMatcher::new();
659 matcher.add("/users/:id");
660
661 let result = matcher.match_path("/users/123");
662 assert!(result.is_some());
663 let m = result.unwrap();
664 assert_eq!(m.pattern, "/users/:id");
665 assert_eq!(m.param("id"), Some("123"));
666 }
667
668 #[test]
669 fn test_matcher_multiple_params() {
670 let mut matcher = RouteMatcher::new();
671 matcher.add("/users/:userId/posts/:postId");
672
673 let result = matcher.match_path("/users/42/posts/99");
674 assert!(result.is_some());
675 let m = result.unwrap();
676 assert_eq!(m.param("userId"), Some("42"));
677 assert_eq!(m.param("postId"), Some("99"));
678 }
679
680 #[test]
681 fn test_matcher_wildcard() {
682 let mut matcher = RouteMatcher::new();
683 matcher.add("/files/*");
684
685 let result = matcher.match_path("/files/path/to/file.txt");
686 assert!(result.is_some());
687 let m = result.unwrap();
688 assert_eq!(m.param("*"), Some("path/to/file.txt"));
689 }
690
691 #[test]
692 fn test_matcher_priority() {
693 let mut matcher = RouteMatcher::new();
694 matcher.add("/users/me");
695 matcher.add("/users/:id");
696
697 let result = matcher.match_path("/users/me");
699 assert_eq!(result.unwrap().pattern, "/users/me");
700
701 let result = matcher.match_path("/users/123");
702 assert_eq!(result.unwrap().pattern, "/users/:id");
703 }
704
705 #[test]
706 fn test_matcher_with_query_string() {
707 let mut matcher = RouteMatcher::new();
708 matcher.add("/search");
709
710 let result = matcher.match_path("/search?q=test");
711 assert!(result.is_some());
712 }
713
714 #[test]
715 fn test_matcher_with_hash() {
716 let mut matcher = RouteMatcher::new();
717 matcher.add("/page");
718
719 let result = matcher.match_path("/page#section");
720 assert!(result.is_some());
721 }
722
723 #[test]
724 fn test_matcher_root() {
725 let mut matcher = RouteMatcher::new();
726 matcher.add("/");
727
728 assert!(matcher.match_path("/").is_some());
730 }
731
732 #[test]
733 fn test_matcher_no_match() {
734 let mut matcher = RouteMatcher::new();
735 matcher.add("/users");
736 matcher.add("/posts");
737
738 assert!(matcher.match_path("/comments").is_none());
739 }
740
741 #[test]
742 fn test_matcher_empty() {
743 let matcher = RouteMatcher::new();
744 assert!(matcher.match_path("/anything").is_none());
745 }
746
747 #[test]
748 fn test_matcher_default() {
749 let matcher = RouteMatcher::default();
750 assert!(matcher.match_path("/anything").is_none());
751 }
752
753 #[test]
754 fn test_matcher_complex_route() {
755 let mut matcher = RouteMatcher::new();
756 matcher.add("/api/v1/users/:id/profile");
757
758 let result = matcher.match_path("/api/v1/users/456/profile");
759 assert!(result.is_some());
760 assert_eq!(result.unwrap().param("id"), Some("456"));
761 }
762
763 #[test]
764 fn test_router_default() {
765 let router = BrowserRouter::default();
766 assert_eq!(router.pathname(), "/");
767 }
768}