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    }
111
112    /// Get the current route state.
113    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    /// Get the current path.
124    pub fn current_path(&self) -> String {
125        self.inner.lock().unwrap().current_path.clone()
126    }
127
128    /// Get current query parameters.
129    pub fn query(&self) -> HashMap<String, String> {
130        self.inner.lock().unwrap().query.clone()
131    }
132
133    /// Match a pattern against a given path.
134    ///
135    /// Returns `None` if the pattern doesn't match.
136    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    /// Check if a pattern matches the current route.
146    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    /// Subscribe to route changes.
152    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    /// Build a URL from a path and query parameters.
161    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
179/// Percent-encode a string for use in URL query parameters.
180///
181/// Encodes characters that are not unreserved (per RFC 3986) or that have
182/// special meaning in the query component: space, `&`, `=`, `?`, `#`, `%`, `+`.
183fn 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
200/// Decode a percent-encoded string.
201fn 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        // Also treat '+' as space (common in query strings)
214        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
235/// Split a path into the clean path and query parameters.
236///
237/// Query parameter keys and values are percent-decoded so that the
238/// in-memory representation contains the original characters.
239fn 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
254/// Match a route pattern against a path, extracting parameters.
255fn 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    // Wildcard: /foo/* matches /foo/anything/here
260    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        // previous_path should still be "/" (from before push), not "page1"
316        // because replace doesn't update previous_path
317        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        // Both keys and values must be percent-encoded
393        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        // Encoded form must not contain raw special characters
413        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}