Skip to main content

aprender_present_lib/browser/
router.rs

1//! Browser router with History API integration.
2//!
3//! Provides navigation and URL management for single-page applications.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use presentar::browser::router::BrowserRouter;
9//!
10//! let router = BrowserRouter::new();
11//! router.navigate("/dashboard");
12//! ```
13
14use presentar_core::Router;
15use std::sync::Mutex;
16
17/// Browser router that uses the History API.
18///
19/// In WASM, this interfaces with the browser's history.pushState/replaceState.
20/// In non-WASM (tests), this uses an in-memory implementation.
21#[derive(Debug)]
22pub struct BrowserRouter {
23    /// In-memory state for non-WASM environments
24    #[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    /// Create a new browser router.
44    #[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    /// Get the current pathname.
63    #[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    /// Get the current search query string.
79    #[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            // Parse query from current URL
88            let current = self.pathname();
89            current
90                .find('?')
91                .map(|i| current[i..].to_string())
92                .unwrap_or_default()
93        }
94    }
95
96    /// Get the current hash.
97    #[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            // Parse hash from current URL
106            let current = self.pathname();
107            current
108                .find('#')
109                .map(|i| current[i..].to_string())
110                .unwrap_or_default()
111        }
112    }
113
114    /// Navigate to a new route, adding to history.
115    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                // Truncate forward history if we're not at the end
124                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    /// Replace the current route without adding to history.
136    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    /// Go back in history.
154    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    /// Go forward in history.
171    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    /// Go to a specific point in history (positive = forward, negative = back).
188    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    /// Get the history length.
210    #[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    /// Check if we can go back.
223    #[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    /// Check if we can go forward.
239    #[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 // Can't reliably detect in browser
251        }
252    }
253
254    // WASM implementations
255    #[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/// Route matching result.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct RouteMatch {
344    /// The matched route pattern.
345    pub pattern: String,
346    /// Extracted path parameters.
347    pub params: std::collections::HashMap<String, String>,
348}
349
350impl RouteMatch {
351    /// Create a new route match.
352    #[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    /// Get a parameter value.
361    #[must_use]
362    pub fn param(&self, name: &str) -> Option<&str> {
363        self.params.get(name).map(String::as_str)
364    }
365}
366
367/// Pattern-based route matcher.
368#[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    /// Create a new route matcher.
388    #[must_use]
389    pub fn new() -> Self {
390        Self { routes: Vec::new() }
391    }
392
393    /// Add a route pattern.
394    ///
395    /// Patterns support:
396    /// - Static segments: `/users/list`
397    /// - Path parameters: `/users/:id`
398    /// - Wildcards: `/files/*`
399    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    /// Match a path against registered routes.
422    #[must_use]
423    pub fn match_path(&self, path: &str) -> Option<RouteMatch> {
424        // Remove query string and hash
425        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                    // Wildcard matches rest of path
464                    let rest: Vec<&str> = path_iter.copied().collect();
465                    params.insert("*".to_string(), rest.join("/"));
466                    return Some(params);
467                }
468            }
469        }
470
471        // Check that we consumed all path segments
472        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    // =========================================================================
491    // BrowserRouter Tests
492    // =========================================================================
493
494    #[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); // Initial + 3 pushes
515    }
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); // Initial + 1 push (replace doesn't add)
524    }
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(); // Should not panic
595        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(); // Should not panic
603        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(); // At page1
614        router.push("/new"); // Should truncate page2 and page3
615        assert_eq!(router.pathname(), "/new");
616        router.forward(); // Should not go anywhere
617        assert_eq!(router.pathname(), "/new");
618    }
619
620    // =========================================================================
621    // RouteMatch Tests
622    // =========================================================================
623
624    #[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    // =========================================================================
640    // RouteMatcher Tests
641    // =========================================================================
642
643    #[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        // Static routes should be added first to match first
698        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        // Empty pattern should match root
729        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}