windjammer_ui/
routing.rs

1//! File-based routing system for all platforms
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7/// Route definition
8#[derive(Debug, Clone)]
9pub struct Route {
10    /// Route path pattern (e.g., "/users/:id")
11    pub path: String,
12    /// Component name or handler
13    pub handler: String,
14    /// Route parameters extracted from URL
15    pub params: HashMap<String, String>,
16    /// Query parameters
17    pub query: HashMap<String, String>,
18    /// Child routes (for nested routing)
19    pub children: Vec<Route>,
20}
21
22impl Route {
23    /// Create a new route
24    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    /// Add a child route
35    pub fn child(mut self, route: Route) -> Self {
36        self.children.push(route);
37        self
38    }
39
40    /// Match a path against this route
41    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                // Dynamic parameter
54                params.insert(param_name.to_string(), path_part.to_string());
55            } else if let Some(param_name) = route_part.strip_prefix('*') {
56                // Wildcard - matches everything
57                params.insert(param_name.to_string(), path_part.to_string());
58            } else if route_part != path_part {
59                // Static part doesn't match
60                return None;
61            }
62        }
63
64        Some(params)
65    }
66}
67
68/// Router for managing routes and navigation
69pub struct Router {
70    /// Registered routes
71    routes: Arc<Mutex<Vec<Route>>>,
72    /// Current route
73    current: Arc<Mutex<Option<Route>>>,
74    /// Navigation history
75    history: Arc<Mutex<Vec<String>>>,
76    /// Navigation listeners
77    #[allow(clippy::type_complexity)]
78    listeners: Arc<Mutex<Vec<Arc<dyn Fn(&Route) + Send + Sync>>>>,
79}
80
81impl Router {
82    /// Create a new router
83    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    /// Register a route
93    pub fn add_route(&self, route: Route) {
94        let mut routes = self.routes.lock().unwrap();
95        routes.push(route);
96    }
97
98    /// Navigate to a path
99    pub fn navigate(&self, path: &str) -> Result<(), String> {
100        let (matched_route, params) = self.find_route(path)?;
101
102        // Update history
103        let mut history = self.history.lock().unwrap();
104        history.push(path.to_string());
105
106        // Update current route
107        let mut current = self.current.lock().unwrap();
108        let mut route = matched_route.clone();
109        route.params = params;
110
111        // Parse query string if present
112        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        // Notify listeners
119        self.notify_listeners(&route);
120
121        Ok(())
122    }
123
124    /// Go back in history
125    pub fn back(&self) -> Result<(), String> {
126        let mut history = self.history.lock().unwrap();
127        if history.len() > 1 {
128            history.pop(); // Remove current
129            if let Some(previous) = history.last() {
130                let path = previous.clone();
131                drop(history); // Release lock before navigate
132                return self.navigate(&path);
133            }
134        }
135        Err("No history to go back to".to_string())
136    }
137
138    /// Go forward in history (if available)
139    pub fn forward(&self) -> Result<(), String> {
140        // In a full implementation, would track forward history
141        Err("Forward navigation not yet implemented".to_string())
142    }
143
144    /// Get current route
145    pub fn current(&self) -> Option<Route> {
146        self.current.lock().unwrap().clone()
147    }
148
149    /// Get route parameter
150    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    /// Get query parameter
159    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    /// Add a navigation listener
168    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        // Remove query string for matching
180        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
218/// File-based router that automatically discovers routes from filesystem
219pub struct FileBasedRouter {
220    /// Base directory for routes (e.g., "src/pages")
221    base_dir: PathBuf,
222    /// Underlying router
223    router: Router,
224}
225
226impl FileBasedRouter {
227    /// Create a new file-based router
228    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    /// Scan directory and register routes
236    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    /// Get the underlying router
243    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(()); // Directory doesn't exist yet, that's ok
250        }
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                // Recurse into subdirectories
261                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                // Register file as route
269                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        // Convert file names to routes:
280        // index.wj -> /
281        // about.wj -> /about
282        // users/[id].wj -> /users/:id
283        // blog/[...slug].wj -> /blog/*slug
284
285        let mut route = prefix.to_string();
286
287        // Remove extension
288        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            // index.wj -> /prefix (or / if no prefix)
295            if route.is_empty() {
296                route = "/".to_string();
297            }
298        } else if name.starts_with('[') && name.ends_with(']') {
299            // Dynamic route: [id].wj -> /:id
300            let param = &name[1..name.len() - 1];
301            if let Some(stripped) = param.strip_prefix("...") {
302                // Catch-all: [...slug].wj -> /*slug
303                route = format!("{}/*{}", route, stripped);
304            } else {
305                // Regular param: [id].wj -> /:id
306                route = format!("{}/:{}", route, param);
307            }
308        } else {
309            // Static route: about.wj -> /about
310            route = format!("{}/{}", route, name);
311        }
312
313        route
314    }
315}
316
317/// Platform-specific route guard
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub enum RoutePlatform {
320    Web,
321    Desktop,
322    Mobile,
323    All,
324}
325
326/// Route guard for authentication, authorization, etc.
327pub trait RouteGuard: Send + Sync {
328    /// Check if navigation is allowed
329    fn can_activate(&self, route: &Route) -> bool;
330
331    /// Get redirect path if navigation is blocked
332    fn redirect(&self) -> Option<String> {
333        None
334    }
335}
336
337/// Authentication guard example
338pub 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}