Skip to main content

hypen_server/
router.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::events::EventEmitter;
5
6/// Result of matching a URL path against a route pattern.
7#[derive(Debug, Clone, PartialEq)]
8pub struct RouteMatch {
9    /// Extracted parameters (e.g., `{":id": "123"}`).
10    pub params: HashMap<String, String>,
11    /// Parsed query parameters.
12    pub query: HashMap<String, String>,
13    /// The matched path.
14    pub path: String,
15}
16
17/// Current route state.
18#[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
26/// A URL pattern-based router with history and parameter extraction.
27///
28/// # Pattern syntax
29///
30/// - Exact: `/dashboard`
31/// - Wildcard: `/dashboard/*`
32/// - Parameter: `/users/:id`
33/// - Multiple params: `/users/:id/posts/:postId`
34///
35/// # Example
36///
37/// ```rust
38/// use hypen_server::router::HypenRouter;
39///
40/// let router = HypenRouter::new();
41///
42/// // Navigate
43/// router.push("/users/42?tab=profile");
44///
45/// let state = router.state();
46/// assert_eq!(state.current_path, "/users/42");
47/// assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
48///
49/// // Match against a pattern
50/// let m = router.match_path("/users/:id", "/users/42").unwrap();
51/// assert_eq!(m.params["id"], "42");
52/// ```
53pub 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    /// Navigate to a path, adding to history.
81    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    /// Replace the current path without adding to history.
101    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        drop(inner);
111
112        // Match TS / Go / Swift / Kotlin: replace emits ROUTE_CHANGED so
113        // any [`ManagedRouter`](crate::managed_router::ManagedRouter) /
114        // direct subscriber sees the URL update.
115        self.events.emit(
116            crate::events::framework::ROUTE_CHANGED,
117            &serde_json::json!({ "path": path }),
118        );
119    }
120
121    /// Pop the current path off history, restoring the previous one.
122    /// No-op when history has fewer than two entries.
123    pub fn back(&self) {
124        let prev = {
125            let mut inner = self.inner.lock().unwrap();
126            if inner.history.len() < 2 {
127                return;
128            }
129            inner.history.pop();
130            let prev = inner.history.last().cloned().unwrap();
131            inner.previous_path = Some(inner.current_path.clone());
132            inner.current_path = prev.clone();
133            inner.query.clear();
134            inner.params.clear();
135            prev
136        };
137        self.events.emit(
138            crate::events::framework::ROUTE_CHANGED,
139            &serde_json::json!({ "path": prev }),
140        );
141    }
142
143    /// Cancel a subscription returned by [`on_navigate`](Self::on_navigate).
144    pub fn off(&self, id: crate::events::SubscriptionId) {
145        self.events.off(id);
146    }
147
148    /// Get the current route state.
149    pub fn state(&self) -> RouteState {
150        let inner = self.inner.lock().unwrap();
151        RouteState {
152            current_path: inner.current_path.clone(),
153            params: inner.params.clone(),
154            query: inner.query.clone(),
155            previous_path: inner.previous_path.clone(),
156        }
157    }
158
159    /// Get the current path.
160    pub fn current_path(&self) -> String {
161        self.inner.lock().unwrap().current_path.clone()
162    }
163
164    /// Get current query parameters.
165    pub fn query(&self) -> HashMap<String, String> {
166        self.inner.lock().unwrap().query.clone()
167    }
168
169    /// Match a pattern against a given path.
170    ///
171    /// Returns `None` if the pattern doesn't match.
172    pub fn match_path(&self, pattern: &str, path: &str) -> Option<RouteMatch> {
173        let (clean_path, query) = parse_path_and_query(path);
174        hypen_engine::match_path(pattern, &clean_path).map(|m| RouteMatch {
175            params: m.params.into_iter().collect(),
176            query,
177            path: clean_path,
178        })
179    }
180
181    /// Check if a pattern matches the current route.
182    pub fn is_active(&self, pattern: &str) -> bool {
183        let inner = self.inner.lock().unwrap();
184        hypen_engine::match_path(pattern, &inner.current_path).is_some()
185    }
186
187    /// Subscribe to route changes.
188    pub fn on_navigate<F>(&self, handler: F) -> crate::events::SubscriptionId
189    where
190        F: Fn(&serde_json::Value) + Send + Sync + 'static,
191    {
192        self.events
193            .on(crate::events::framework::ROUTE_CHANGED, handler)
194    }
195
196    /// Build a URL from a path and query parameters.
197    ///
198    /// Delegates to [`hypen_engine::build_url`]; this thin wrapper only
199    /// exists to translate the SDK's `HashMap` return shape into the
200    /// `BTreeMap` the engine takes.
201    pub fn build_url(path: &str, query: &HashMap<String, String>) -> String {
202        let sorted: std::collections::BTreeMap<String, String> =
203            query.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
204        hypen_engine::build_url(path, &sorted)
205    }
206}
207
208impl Default for HypenRouter {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214/// Split `/path?k=v` into `(clean_path, query_map)` by delegating to
215/// [`hypen_engine::parse_query`]. The engine returns a `BTreeMap`; we
216/// widen to `HashMap` to match the surrounding SDK shape.
217fn parse_path_and_query(full_path: &str) -> (String, HashMap<String, String>) {
218    let (path, btree) = hypen_engine::parse_query(full_path);
219    (path, btree.into_iter().collect())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_push_and_state() {
228        let router = HypenRouter::new();
229        router.push("/users/42?tab=profile");
230
231        let state = router.state();
232        assert_eq!(state.current_path, "/users/42");
233        assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
234        assert_eq!(state.previous_path, Some("/".to_string()));
235    }
236
237    #[test]
238    fn test_replace() {
239        let router = HypenRouter::new();
240        router.push("/page1");
241        router.replace("/page2");
242
243        let state = router.state();
244        assert_eq!(state.current_path, "/page2");
245        // previous_path should still be "/" (from before push), not "page1"
246        // because replace doesn't update previous_path
247        assert_eq!(state.previous_path, Some("/".to_string()));
248    }
249
250    #[test]
251    fn test_match_exact() {
252        let router = HypenRouter::new();
253        let m = router.match_path("/dashboard", "/dashboard");
254        assert!(m.is_some());
255        assert!(m.unwrap().params.is_empty());
256    }
257
258    #[test]
259    fn test_match_params() {
260        let router = HypenRouter::new();
261        let m = router
262            .match_path("/users/:id/posts/:postId", "/users/42/posts/99")
263            .unwrap();
264        assert_eq!(m.params["id"], "42");
265        assert_eq!(m.params["postId"], "99");
266    }
267
268    #[test]
269    fn test_match_wildcard() {
270        let router = HypenRouter::new();
271        assert!(router.match_path("/api/*", "/api/users/list").is_some());
272        assert!(router.match_path("/api/*", "/api").is_some());
273        assert!(router.match_path("/api/*", "/other").is_none());
274    }
275
276    #[test]
277    fn test_no_match() {
278        let router = HypenRouter::new();
279        assert!(router.match_path("/users/:id", "/posts/42").is_none());
280        assert!(router.match_path("/users/:id", "/users/42/extra").is_none());
281    }
282
283    #[test]
284    fn test_is_active() {
285        let router = HypenRouter::new();
286        router.push("/users/42");
287
288        assert!(router.is_active("/users/:id"));
289        assert!(!router.is_active("/posts/:id"));
290    }
291
292    #[test]
293    fn test_query_parsing() {
294        let (path, query) = parse_path_and_query("/search?q=hello&page=2");
295        assert_eq!(path, "/search");
296        assert_eq!(query["q"], "hello");
297        assert_eq!(query["page"], "2");
298    }
299
300    #[test]
301    fn test_build_url() {
302        let mut query = HashMap::new();
303        query.insert("tab".to_string(), "profile".to_string());
304
305        let url = HypenRouter::build_url("/users/42", &query);
306        assert_eq!(url, "/users/42?tab=profile");
307    }
308
309    #[test]
310    fn test_build_url_no_query() {
311        let url = HypenRouter::build_url("/home", &HashMap::new());
312        assert_eq!(url, "/home");
313    }
314
315    #[test]
316    fn test_build_url_encodes_special_chars() {
317        let mut query = HashMap::new();
318        query.insert("msg".to_string(), "hello world".to_string());
319        query.insert("a&b".to_string(), "1=2".to_string());
320
321        let url = HypenRouter::build_url("/search", &query);
322        // Both keys and values must be percent-encoded
323        assert!(url.contains("msg=hello%20world"));
324        assert!(url.contains("a%26b=1%3D2"));
325        assert!(url.starts_with("/search?"));
326    }
327
328    #[test]
329    fn test_parse_decodes_encoded_query() {
330        let (path, query) = parse_path_and_query("/search?msg=hello%20world&a%26b=1%3D2");
331        assert_eq!(path, "/search");
332        assert_eq!(query.get("msg").map(String::as_str), Some("hello world"));
333        assert_eq!(query.get("a&b").map(String::as_str), Some("1=2"));
334    }
335
336    #[test]
337    fn test_plus_decodes_to_space() {
338        let (path, query) = parse_path_and_query("/search?q=hello+world");
339        assert_eq!(path, "/search");
340        assert_eq!(query.get("q").map(String::as_str), Some("hello world"));
341    }
342
343    #[test]
344    fn test_on_navigate() {
345        use std::sync::atomic::{AtomicI32, Ordering};
346        use std::sync::Arc;
347
348        let router = HypenRouter::new();
349        let count = Arc::new(AtomicI32::new(0));
350        let count_clone = count.clone();
351
352        router.on_navigate(move |_| {
353            count_clone.fetch_add(1, Ordering::SeqCst);
354        });
355
356        router.push("/a");
357        router.push("/b");
358
359        assert_eq!(count.load(Ordering::SeqCst), 2);
360    }
361}