1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::events::EventEmitter;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct RouteMatch {
9 pub params: HashMap<String, String>,
11 pub query: HashMap<String, String>,
13 pub path: String,
15}
16
17#[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
26pub 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 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 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 self.events.emit(
116 crate::events::framework::ROUTE_CHANGED,
117 &serde_json::json!({ "path": path }),
118 );
119 }
120
121 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 pub fn off(&self, id: crate::events::SubscriptionId) {
145 self.events.off(id);
146 }
147
148 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 pub fn current_path(&self) -> String {
161 self.inner.lock().unwrap().current_path.clone()
162 }
163
164 pub fn query(&self) -> HashMap<String, String> {
166 self.inner.lock().unwrap().query.clone()
167 }
168
169 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 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 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 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
214fn 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 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 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}