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> = 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
216fn 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 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 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}