1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7#[derive(Debug, Clone)]
9pub struct Route {
10 pub path: String,
12 pub handler: String,
14 pub params: HashMap<String, String>,
16 pub query: HashMap<String, String>,
18 pub children: Vec<Route>,
20}
21
22impl Route {
23 pub fn new(path: String, handler: String) -> Self {
25 Self {
26 path,
27 handler,
28 params: HashMap::new(),
29 query: HashMap::new(),
30 children: Vec::new(),
31 }
32 }
33
34 pub fn child(mut self, route: Route) -> Self {
36 self.children.push(route);
37 self
38 }
39
40 pub fn matches(&self, path: &str) -> Option<HashMap<String, String>> {
42 let route_parts: Vec<&str> = self.path.split('/').filter(|s| !s.is_empty()).collect();
43 let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
44
45 if route_parts.len() != path_parts.len() {
46 return None;
47 }
48
49 let mut params = HashMap::new();
50
51 for (route_part, path_part) in route_parts.iter().zip(path_parts.iter()) {
52 if let Some(param_name) = route_part.strip_prefix(':') {
53 params.insert(param_name.to_string(), path_part.to_string());
55 } else if let Some(param_name) = route_part.strip_prefix('*') {
56 params.insert(param_name.to_string(), path_part.to_string());
58 } else if route_part != path_part {
59 return None;
61 }
62 }
63
64 Some(params)
65 }
66}
67
68pub struct Router {
70 routes: Arc<Mutex<Vec<Route>>>,
72 current: Arc<Mutex<Option<Route>>>,
74 history: Arc<Mutex<Vec<String>>>,
76 #[allow(clippy::type_complexity)]
78 listeners: Arc<Mutex<Vec<Arc<dyn Fn(&Route) + Send + Sync>>>>,
79}
80
81impl Router {
82 pub fn new() -> Self {
84 Self {
85 routes: Arc::new(Mutex::new(Vec::new())),
86 current: Arc::new(Mutex::new(None)),
87 history: Arc::new(Mutex::new(Vec::new())),
88 listeners: Arc::new(Mutex::new(Vec::new())),
89 }
90 }
91
92 pub fn add_route(&self, route: Route) {
94 let mut routes = self.routes.lock().unwrap();
95 routes.push(route);
96 }
97
98 pub fn navigate(&self, path: &str) -> Result<(), String> {
100 let (matched_route, params) = self.find_route(path)?;
101
102 let mut history = self.history.lock().unwrap();
104 history.push(path.to_string());
105
106 let mut current = self.current.lock().unwrap();
108 let mut route = matched_route.clone();
109 route.params = params;
110
111 if let Some(query_start) = path.find('?') {
113 route.query = self.parse_query(&path[query_start + 1..]);
114 }
115
116 *current = Some(route.clone());
117
118 self.notify_listeners(&route);
120
121 Ok(())
122 }
123
124 pub fn back(&self) -> Result<(), String> {
126 let mut history = self.history.lock().unwrap();
127 if history.len() > 1 {
128 history.pop(); if let Some(previous) = history.last() {
130 let path = previous.clone();
131 drop(history); return self.navigate(&path);
133 }
134 }
135 Err("No history to go back to".to_string())
136 }
137
138 pub fn forward(&self) -> Result<(), String> {
140 Err("Forward navigation not yet implemented".to_string())
142 }
143
144 pub fn current(&self) -> Option<Route> {
146 self.current.lock().unwrap().clone()
147 }
148
149 pub fn param(&self, name: &str) -> Option<String> {
151 self.current
152 .lock()
153 .unwrap()
154 .as_ref()
155 .and_then(|r| r.params.get(name).cloned())
156 }
157
158 pub fn query(&self, name: &str) -> Option<String> {
160 self.current
161 .lock()
162 .unwrap()
163 .as_ref()
164 .and_then(|r| r.query.get(name).cloned())
165 }
166
167 pub fn on_navigate<F>(&self, listener: F)
169 where
170 F: Fn(&Route) + Send + Sync + 'static,
171 {
172 let mut listeners = self.listeners.lock().unwrap();
173 listeners.push(Arc::new(listener));
174 }
175
176 fn find_route(&self, path: &str) -> Result<(Route, HashMap<String, String>), String> {
177 let routes = self.routes.lock().unwrap();
178
179 let path_without_query = path.split('?').next().unwrap_or(path);
181
182 for route in routes.iter() {
183 if let Some(params) = route.matches(path_without_query) {
184 return Ok((route.clone(), params));
185 }
186 }
187
188 Err(format!("No route found for path: {}", path))
189 }
190
191 fn parse_query(&self, query: &str) -> HashMap<String, String> {
192 let mut result = HashMap::new();
193 for pair in query.split('&') {
194 if let Some((key, value)) = pair.split_once('=') {
195 result.insert(
196 urlencoding::decode(key).unwrap_or_default().to_string(),
197 urlencoding::decode(value).unwrap_or_default().to_string(),
198 );
199 }
200 }
201 result
202 }
203
204 fn notify_listeners(&self, route: &Route) {
205 let listeners = self.listeners.lock().unwrap();
206 for listener in listeners.iter() {
207 listener(route);
208 }
209 }
210}
211
212impl Default for Router {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218pub struct FileBasedRouter {
220 base_dir: PathBuf,
222 router: Router,
224}
225
226impl FileBasedRouter {
227 pub fn new<P: AsRef<Path>>(base_dir: P) -> Self {
229 Self {
230 base_dir: base_dir.as_ref().to_path_buf(),
231 router: Router::new(),
232 }
233 }
234
235 pub fn scan(&mut self) -> Result<(), String> {
237 let base_dir = self.base_dir.clone();
238 self.scan_directory(&base_dir, "")?;
239 Ok(())
240 }
241
242 pub fn router(&self) -> &Router {
244 &self.router
245 }
246
247 fn scan_directory(&mut self, dir: &Path, prefix: &str) -> Result<(), String> {
248 if !dir.exists() {
249 return Ok(()); }
251
252 let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
253
254 for entry in entries {
255 let entry = entry.map_err(|e| e.to_string())?;
256 let path = entry.path();
257 let file_name = entry.file_name().to_string_lossy().to_string();
258
259 if path.is_dir() {
260 let new_prefix = if prefix.is_empty() {
262 format!("/{}", file_name)
263 } else {
264 format!("{}/{}", prefix, file_name)
265 };
266 self.scan_directory(&path, &new_prefix)?;
267 } else if path.is_file() {
268 let route_path = self.file_to_route(&file_name, prefix);
270 let handler = path.to_string_lossy().to_string();
271 self.router.add_route(Route::new(route_path, handler));
272 }
273 }
274
275 Ok(())
276 }
277
278 fn file_to_route(&self, file_name: &str, prefix: &str) -> String {
279 let mut route = prefix.to_string();
286
287 let name = file_name
289 .strip_suffix(".wj")
290 .or_else(|| file_name.strip_suffix(".rs"))
291 .unwrap_or(file_name);
292
293 if name == "index" {
294 if route.is_empty() {
296 route = "/".to_string();
297 }
298 } else if name.starts_with('[') && name.ends_with(']') {
299 let param = &name[1..name.len() - 1];
301 if let Some(stripped) = param.strip_prefix("...") {
302 route = format!("{}/*{}", route, stripped);
304 } else {
305 route = format!("{}/:{}", route, param);
307 }
308 } else {
309 route = format!("{}/{}", route, name);
311 }
312
313 route
314 }
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum RoutePlatform {
320 Web,
321 Desktop,
322 Mobile,
323 All,
324}
325
326pub trait RouteGuard: Send + Sync {
328 fn can_activate(&self, route: &Route) -> bool;
330
331 fn redirect(&self) -> Option<String> {
333 None
334 }
335}
336
337pub struct AuthGuard {
339 authenticated: Arc<Mutex<bool>>,
340}
341
342impl AuthGuard {
343 pub fn new(authenticated: bool) -> Self {
344 Self {
345 authenticated: Arc::new(Mutex::new(authenticated)),
346 }
347 }
348
349 pub fn set_authenticated(&self, value: bool) {
350 *self.authenticated.lock().unwrap() = value;
351 }
352}
353
354impl RouteGuard for AuthGuard {
355 fn can_activate(&self, _route: &Route) -> bool {
356 *self.authenticated.lock().unwrap()
357 }
358
359 fn redirect(&self) -> Option<String> {
360 Some("/login".to_string())
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_route_matches_static() {
370 let route = Route::new("/about".to_string(), "AboutPage".to_string());
371 assert!(route.matches("/about").is_some());
372 assert!(route.matches("/contact").is_none());
373 }
374
375 #[test]
376 fn test_route_matches_dynamic() {
377 let route = Route::new("/users/:id".to_string(), "UserPage".to_string());
378 let params = route.matches("/users/123").unwrap();
379 assert_eq!(params.get("id"), Some(&"123".to_string()));
380 }
381
382 #[test]
383 fn test_route_matches_multiple_params() {
384 let route = Route::new("/posts/:category/:id".to_string(), "PostPage".to_string());
385 let params = route.matches("/posts/tech/456").unwrap();
386 assert_eq!(params.get("category"), Some(&"tech".to_string()));
387 assert_eq!(params.get("id"), Some(&"456".to_string()));
388 }
389
390 #[test]
391 fn test_router_navigate() {
392 let router = Router::new();
393 router.add_route(Route::new("/home".to_string(), "HomePage".to_string()));
394
395 assert!(router.navigate("/home").is_ok());
396 assert!(router.current().is_some());
397 assert_eq!(router.current().unwrap().handler, "HomePage");
398 }
399
400 #[test]
401 fn test_router_params() {
402 let router = Router::new();
403 router.add_route(Route::new("/users/:id".to_string(), "UserPage".to_string()));
404
405 router.navigate("/users/789").unwrap();
406 assert_eq!(router.param("id"), Some("789".to_string()));
407 }
408
409 #[test]
410 fn test_router_query() {
411 let router = Router::new();
412 router.add_route(Route::new("/search".to_string(), "SearchPage".to_string()));
413
414 router.navigate("/search?q=rust&page=2").unwrap();
415 assert_eq!(router.query("q"), Some("rust".to_string()));
416 assert_eq!(router.query("page"), Some("2".to_string()));
417 }
418
419 #[test]
420 fn test_router_back() {
421 let router = Router::new();
422 router.add_route(Route::new("/home".to_string(), "HomePage".to_string()));
423 router.add_route(Route::new("/about".to_string(), "AboutPage".to_string()));
424
425 router.navigate("/home").unwrap();
426 router.navigate("/about").unwrap();
427 assert_eq!(router.current().unwrap().path, "/about");
428
429 router.back().unwrap();
430 assert_eq!(router.current().unwrap().path, "/home");
431 }
432
433 #[test]
434 fn test_route_guard_authenticated() {
435 let guard = AuthGuard::new(true);
436 let route = Route::new("/dashboard".to_string(), "DashboardPage".to_string());
437 assert!(guard.can_activate(&route));
438 }
439
440 #[test]
441 fn test_route_guard_not_authenticated() {
442 let guard = AuthGuard::new(false);
443 let route = Route::new("/dashboard".to_string(), "DashboardPage".to_string());
444 assert!(!guard.can_activate(&route));
445 assert_eq!(guard.redirect(), Some("/login".to_string()));
446 }
447
448 #[test]
449 fn test_file_to_route_index() {
450 let router = FileBasedRouter::new("pages");
451 assert_eq!(router.file_to_route("index.wj", ""), "/");
452 }
453
454 #[test]
455 fn test_file_to_route_static() {
456 let router = FileBasedRouter::new("pages");
457 assert_eq!(router.file_to_route("about.wj", ""), "/about");
458 }
459
460 #[test]
461 fn test_file_to_route_dynamic() {
462 let router = FileBasedRouter::new("pages");
463 assert_eq!(router.file_to_route("[id].wj", "/users"), "/users/:id");
464 }
465
466 #[test]
467 fn test_file_to_route_catchall() {
468 let router = FileBasedRouter::new("pages");
469 assert_eq!(router.file_to_route("[...slug].wj", "/blog"), "/blog/*slug");
470 }
471}