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> = query
203            .iter()
204            .map(|(k, v)| (k.clone(), v.clone()))
205            .collect();
206        hypen_engine::build_url(path, &sorted)
207    }
208}
209
210impl Default for HypenRouter {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216/// Split `/path?k=v` into `(clean_path, query_map)` by delegating to
217/// [`hypen_engine::parse_query`]. The engine returns a `BTreeMap`; we
218/// widen to `HashMap` to match the surrounding SDK shape.
219fn parse_path_and_query(full_path: &str) -> (String, HashMap<String, String>) {
220    let (path, btree) = hypen_engine::parse_query(full_path);
221    (path, btree.into_iter().collect())
222}
223
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_push_and_state() {
231        let router = HypenRouter::new();
232        router.push("/users/42?tab=profile");
233
234        let state = router.state();
235        assert_eq!(state.current_path, "/users/42");
236        assert_eq!(state.query.get("tab").map(String::as_str), Some("profile"));
237        assert_eq!(state.previous_path, Some("/".to_string()));
238    }
239
240    #[test]
241    fn test_replace() {
242        let router = HypenRouter::new();
243        router.push("/page1");
244        router.replace("/page2");
245
246        let state = router.state();
247        assert_eq!(state.current_path, "/page2");
248        // previous_path should still be "/" (from before push), not "page1"
249        // because replace doesn't update previous_path
250        assert_eq!(state.previous_path, Some("/".to_string()));
251    }
252
253    #[test]
254    fn test_match_exact() {
255        let router = HypenRouter::new();
256        let m = router.match_path("/dashboard", "/dashboard");
257        assert!(m.is_some());
258        assert!(m.unwrap().params.is_empty());
259    }
260
261    #[test]
262    fn test_match_params() {
263        let router = HypenRouter::new();
264        let m = router
265            .match_path("/users/:id/posts/:postId", "/users/42/posts/99")
266            .unwrap();
267        assert_eq!(m.params["id"], "42");
268        assert_eq!(m.params["postId"], "99");
269    }
270
271    #[test]
272    fn test_match_wildcard() {
273        let router = HypenRouter::new();
274        assert!(router.match_path("/api/*", "/api/users/list").is_some());
275        assert!(router.match_path("/api/*", "/api").is_some());
276        assert!(router.match_path("/api/*", "/other").is_none());
277    }
278
279    #[test]
280    fn test_no_match() {
281        let router = HypenRouter::new();
282        assert!(router.match_path("/users/:id", "/posts/42").is_none());
283        assert!(router.match_path("/users/:id", "/users/42/extra").is_none());
284    }
285
286    #[test]
287    fn test_is_active() {
288        let router = HypenRouter::new();
289        router.push("/users/42");
290
291        assert!(router.is_active("/users/:id"));
292        assert!(!router.is_active("/posts/:id"));
293    }
294
295    #[test]
296    fn test_query_parsing() {
297        let (path, query) = parse_path_and_query("/search?q=hello&page=2");
298        assert_eq!(path, "/search");
299        assert_eq!(query["q"], "hello");
300        assert_eq!(query["page"], "2");
301    }
302
303    #[test]
304    fn test_build_url() {
305        let mut query = HashMap::new();
306        query.insert("tab".to_string(), "profile".to_string());
307
308        let url = HypenRouter::build_url("/users/42", &query);
309        assert_eq!(url, "/users/42?tab=profile");
310    }
311
312    #[test]
313    fn test_build_url_no_query() {
314        let url = HypenRouter::build_url("/home", &HashMap::new());
315        assert_eq!(url, "/home");
316    }
317
318    #[test]
319    fn test_build_url_encodes_special_chars() {
320        let mut query = HashMap::new();
321        query.insert("msg".to_string(), "hello world".to_string());
322        query.insert("a&b".to_string(), "1=2".to_string());
323
324        let url = HypenRouter::build_url("/search", &query);
325        // Both keys and values must be percent-encoded
326        assert!(url.contains("msg=hello%20world"));
327        assert!(url.contains("a%26b=1%3D2"));
328        assert!(url.starts_with("/search?"));
329    }
330
331    #[test]
332    fn test_parse_decodes_encoded_query() {
333        let (path, query) = parse_path_and_query("/search?msg=hello%20world&a%26b=1%3D2");
334        assert_eq!(path, "/search");
335        assert_eq!(query.get("msg").map(String::as_str), Some("hello world"));
336        assert_eq!(query.get("a&b").map(String::as_str), Some("1=2"));
337    }
338
339    #[test]
340    fn test_plus_decodes_to_space() {
341        let (path, query) = parse_path_and_query("/search?q=hello+world");
342        assert_eq!(path, "/search");
343        assert_eq!(query.get("q").map(String::as_str), Some("hello world"));
344    }
345
346    #[test]
347    fn test_on_navigate() {
348        use std::sync::atomic::{AtomicI32, Ordering};
349        use std::sync::Arc;
350
351        let router = HypenRouter::new();
352        let count = Arc::new(AtomicI32::new(0));
353        let count_clone = count.clone();
354
355        router.on_navigate(move |_| {
356            count_clone.fetch_add(1, Ordering::SeqCst);
357        });
358
359        router.push("/a");
360        router.push("/b");
361
362        assert_eq!(count.load(Ordering::SeqCst), 2);
363    }
364}