gpui_navigator/
route.rs

1//! Route definition and configuration
2
3#[cfg(feature = "guard")]
4use crate::guards::BoxedGuard;
5use crate::lifecycle::BoxedLifecycle;
6#[cfg(feature = "middleware")]
7use crate::middleware::BoxedMiddleware;
8use crate::params::RouteParams;
9#[cfg(feature = "transition")]
10use crate::transition::TransitionConfig;
11use crate::RouteMatch;
12use gpui::{AnyElement, App, IntoElement};
13use std::collections::HashMap;
14use std::sync::Arc;
15
16// ============================================================================
17// NamedRouteRegistry
18// ============================================================================
19
20/// Registry for named routes
21#[derive(Clone, Debug, Default)]
22pub struct NamedRouteRegistry {
23    /// Map of route names to path patterns
24    routes: HashMap<String, String>,
25}
26
27impl NamedRouteRegistry {
28    /// Create a new empty registry
29    pub fn new() -> Self {
30        Self {
31            routes: HashMap::new(),
32        }
33    }
34
35    /// Register a named route
36    pub fn register(&mut self, name: impl Into<String>, path: impl Into<String>) {
37        self.routes.insert(name.into(), path.into());
38    }
39
40    /// Get path pattern for a named route
41    pub fn get(&self, name: &str) -> Option<&str> {
42        self.routes.get(name).map(|s| s.as_str())
43    }
44
45    /// Check if a route name exists
46    pub fn contains(&self, name: &str) -> bool {
47        self.routes.contains_key(name)
48    }
49
50    /// Generate URL for a named route with parameters
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// use gpui-navigator::{NamedRouteRegistry, RouteParams};
56    ///
57    /// let mut registry = NamedRouteRegistry::new();
58    /// registry.register("user.detail", "/users/:id");
59    ///
60    /// let mut params = RouteParams::new();
61    /// params.set("id".to_string(), "123".to_string());
62    ///
63    /// let url = registry.url_for("user.detail", &params).unwrap();
64    /// assert_eq!(url, "/users/123");
65    /// ```
66    pub fn url_for(&self, name: &str, params: &RouteParams) -> Option<String> {
67        let pattern = self.get(name)?;
68        Some(substitute_params(pattern, params))
69    }
70
71    /// Clear all registered routes
72    pub fn clear(&mut self) {
73        self.routes.clear();
74    }
75
76    /// Get number of registered routes
77    pub fn len(&self) -> usize {
78        self.routes.len()
79    }
80
81    /// Check if registry is empty
82    pub fn is_empty(&self) -> bool {
83        self.routes.is_empty()
84    }
85}
86
87/// Substitute route parameters in a path pattern
88///
89/// Replaces `:param` with actual values from RouteParams
90fn substitute_params(pattern: &str, params: &RouteParams) -> String {
91    let mut result = pattern.to_string();
92
93    // Replace :param with actual values
94    for (key, value) in params.iter() {
95        let placeholder = format!(":{}", key);
96        result = result.replace(&placeholder, value);
97    }
98
99    result
100}
101
102// ============================================================================
103// Route Validation
104// ============================================================================
105
106/// Validate a route path pattern
107///
108/// Returns an error message if the path is invalid, None otherwise.
109///
110/// # Validation Rules
111///
112/// - Path can be empty (for index routes)
113/// - Path must start with '/' or be relative (no leading '/')
114/// - No consecutive slashes ('//')
115/// - Trailing slashes are allowed but not recommended (normalized internally)
116/// - Parameter names must be alphanumeric and not empty
117/// - No duplicate parameter names
118pub fn validate_route_path(path: &str) -> Result<(), String> {
119    // Empty path is allowed for index routes
120    if path.is_empty() {
121        return Ok(());
122    }
123
124    // Consecutive slashes check
125    if path.contains("//") {
126        return Err("Route path cannot contain consecutive slashes".to_string());
127    }
128
129    // Note: Trailing slashes are allowed for compatibility
130    // They are normalized during route matching
131
132    // Extract and validate parameters
133    let mut param_names = std::collections::HashSet::new();
134    for segment in path.split('/') {
135        if let Some(param) = segment.strip_prefix(':') {
136            // Check parameter name is not empty
137            if param.is_empty() {
138                return Err("Route parameter name cannot be empty".to_string());
139            }
140
141            // Check for constraint syntax (:id{uuid})
142            let param_name = if let Some(pos) = param.find('{') {
143                &param[..pos]
144            } else {
145                param
146            };
147
148            // Check parameter name is alphanumeric
149            if !param_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
150                return Err(format!(
151                    "Route parameter '{}' must contain only alphanumeric characters and underscores",
152                    param_name
153                ));
154            }
155
156            // Check for duplicate parameters
157            if !param_names.insert(param_name.to_string()) {
158                return Err(format!("Duplicate route parameter: '{}'", param_name));
159            }
160        }
161    }
162
163    Ok(())
164}
165
166// ============================================================================
167// RouteConfig
168// ============================================================================
169
170/// Route configuration
171#[derive(Debug, Clone)]
172pub struct RouteConfig {
173    /// Route path pattern (e.g., "/users/:id")
174    pub path: String,
175    /// Route name (optional)
176    pub name: Option<String>,
177    /// Child routes (NOTE: For nested routing, use Route.children() instead)
178    pub children: Vec<RouteConfig>,
179    /// Route metadata
180    pub meta: HashMap<String, String>,
181}
182
183impl RouteConfig {
184    /// Check if this is a layout route (has children but no explicit builder)
185    pub fn is_layout(&self) -> bool {
186        !self.children.is_empty()
187    }
188}
189
190impl RouteConfig {
191    /// Create a new route with path validation
192    ///
193    /// # Panics
194    ///
195    /// Panics if the path is invalid. Use `try_new` for non-panicking validation.
196    pub fn new(path: impl Into<String>) -> Self {
197        let path_str = path.into();
198        if let Err(e) = validate_route_path(&path_str) {
199            panic!("Invalid route path '{}': {}", path_str, e);
200        }
201        Self {
202            path: path_str,
203            name: None,
204            children: Vec::new(),
205            meta: HashMap::new(),
206        }
207    }
208
209    /// Create a new route with validation, returning Result
210    ///
211    /// Use this if you want to handle validation errors instead of panicking.
212    pub fn try_new(path: impl Into<String>) -> Result<Self, String> {
213        let path_str = path.into();
214        validate_route_path(&path_str)?;
215        Ok(Self {
216            path: path_str,
217            name: None,
218            children: Vec::new(),
219            meta: HashMap::new(),
220        })
221    }
222
223    /// Set route name
224    pub fn name(mut self, name: impl Into<String>) -> Self {
225        self.name = Some(name.into());
226        self
227    }
228
229    /// Add child routes
230    pub fn children(mut self, children: Vec<RouteConfig>) -> Self {
231        self.children = children;
232        self
233    }
234
235    /// Add a child route
236    pub fn child(mut self, child: RouteConfig) -> Self {
237        self.children.push(child);
238        self
239    }
240
241    /// Add metadata
242    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
243        self.meta.insert(key.into(), value.into());
244        self
245    }
246}
247
248/// Type for route builder function
249///
250/// Builder receives context and parameters, returns an AnyElement.
251/// Through context you have access to App, global state, and Navigator.
252///
253/// Note: When using `Route::new()`, your builder can return any type that implements
254/// `IntoElement` - the conversion to `AnyElement` is done automatically.
255pub type RouteBuilder = Arc<dyn Fn(&mut App, &RouteParams) -> AnyElement + Send + Sync>;
256
257/// Shared route handle.
258///
259/// A `Route` contains non-cloneable behavior (guards/middleware/lifecycle).
260/// To make route trees cheap to share and cache, the canonical way to pass
261/// routes around is via `Arc<Route>`.
262pub type RouteRef = Arc<Route>;
263
264/// Route definition with render function
265pub struct Route {
266    /// Route configuration
267    pub config: RouteConfig,
268    /// Builder function to create the view for this route
269    pub builder: Option<RouteBuilder>,
270    /// Child routes with their own builders
271    /// This is the preferred way to define nested routes (instead of RouteConfig.children)
272    pub children: Vec<RouteRef>,
273    /// Named outlets - map of outlet name to child routes
274    /// Allows multiple outlet areas in a single parent route
275    pub named_children: HashMap<String, Vec<RouteRef>>,
276    /// Guards that control access to this route
277    #[cfg(feature = "guard")]
278    pub guards: Vec<BoxedGuard>,
279    /// Middleware that runs before and after navigation to this route
280    #[cfg(feature = "middleware")]
281    pub middleware: Vec<BoxedMiddleware>,
282    /// Lifecycle hooks for this route
283    pub lifecycle: Option<BoxedLifecycle>,
284    /// Transition animation for this route
285    #[cfg(feature = "transition")]
286    pub transition: TransitionConfig,
287}
288
289impl Route {
290    /// Create a route with a builder function
291    ///
292    /// Routes are registered with a path pattern and a builder function that
293    /// creates the view. The builder receives the app context and extracted
294    /// route parameters.
295    ///
296    /// # Example
297    ///
298    /// ```no_run
299    /// use gpui-navigator::Route;
300    /// use gpui::*;
301    ///
302    /// // Simple static route
303    /// Route::new("/home", |cx, params| {
304    ///     div().child("Home Page")
305    /// });
306    ///
307    /// // Route with dynamic parameter
308    /// Route::new("/users/:id", |cx, params| {
309    ///     let id = params.get("id").unwrap();
310    ///     div().child(format!("User: {}", id))
311    /// });
312    /// ```
313    pub fn new<F, E>(path: impl Into<String>, builder: F) -> Self
314    where
315        E: IntoElement,
316        F: Fn(&mut App, &RouteParams) -> E + Send + Sync + 'static,
317    {
318        Self {
319            config: RouteConfig::new(path),
320            builder: Some(Arc::new(move |cx, params| {
321                builder(cx, params).into_any_element()
322            })),
323            children: Vec::new(),
324            named_children: HashMap::new(),
325            #[cfg(feature = "guard")]
326            guards: Vec::new(),
327            #[cfg(feature = "middleware")]
328            middleware: Vec::new(),
329            lifecycle: None,
330            #[cfg(feature = "transition")]
331            transition: TransitionConfig::default(),
332        }
333    }
334
335    /// Add child routes to this route
336    ///
337    /// Child routes will be rendered in a RouterOutlet within the parent's layout.
338    ///
339    /// # Example
340    ///
341    /// ```no_run
342    /// use gpui-navigator::{Route, router_outlet};
343    /// use gpui::*;
344    ///
345    /// Route::new("/dashboard", |cx, params| {
346    ///     div()
347    ///         .child("Dashboard Header")
348    ///         .child(router_outlet(cx)) // Children render here
349    /// })
350    /// .children(vec![
351    ///     Route::new("overview", |_cx, _params| {
352    ///         div().child("Overview")
353    ///     })
354    ///     .into(),
355    ///     Route::new("settings", |_cx, _params| {
356    ///         div().child("Settings")
357    ///     })
358    ///     .into(),
359    /// ]);
360    /// ```
361    pub fn children(mut self, children: Vec<RouteRef>) -> Self {
362        self.children = children;
363        self
364    }
365
366    /// Add a single child route
367    ///
368    /// # Example
369    ///
370    /// ```no_run
371    /// use gpui-navigator::Route;
372    /// use gpui::*;
373    ///
374    /// Route::new("/dashboard", |_cx, _params| div())
375    ///     .child(Route::new("overview", |_cx, _params| div()).into())
376    ///     .child(Route::new("settings", |_cx, _params| div()).into());
377    /// ```
378    pub fn child(mut self, child: RouteRef) -> Self {
379        self.children.push(child);
380        self
381    }
382
383    /// Set route name
384    ///
385    /// Named routes can be referenced by name instead of path.
386    pub fn name(mut self, name: impl Into<String>) -> Self {
387        self.config.name = Some(name.into());
388        self
389    }
390
391    /// Add metadata to the route
392    ///
393    /// Metadata can be used for guards, analytics, titles, etc.
394    ///
395    /// # Example
396    ///
397    /// ```no_run
398    /// use gpui-navigator::Route;
399    /// use gpui::*;
400    ///
401    /// Route::new("/admin", |_cx, _params| div())
402    ///     .meta("requiresAuth", "true")
403    ///     .meta("requiredRole", "admin")
404    ///     .meta("title", "Admin Panel");
405    /// ```
406    pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
407        self.config.meta.insert(key.into(), value.into());
408        self
409    }
410
411    /// Add routes for a named outlet
412    ///
413    /// Named outlets allow you to have multiple content areas in a single parent route.
414    /// For example, a main content area and a sidebar.
415    ///
416    /// # Example
417    ///
418    /// ```no_run
419    /// use gpui-navigator::{Route, render_router_outlet};
420    /// use gpui::*;
421    ///
422    /// Route::new("/dashboard", |cx, _params| {
423    ///     div()
424    ///         .child(render_router_outlet(cx, None))             // Main content
425    ///         .child(render_router_outlet(cx, Some("sidebar")))  // Sidebar
426    /// })
427    /// .children(vec![
428    ///     Route::new("analytics", |_cx, _params| div()).into(),
429    /// ])
430    /// .named_outlet("sidebar", vec![
431    ///     Route::new("stats", |_cx, _params| div()).into(),
432    /// ]);
433    /// ```
434    pub fn named_outlet(mut self, name: impl Into<String>, children: Vec<RouteRef>) -> Self {
435        self.named_children.insert(name.into(), children);
436        self
437    }
438
439    /// Add a guard to this route
440    ///
441    /// Guards control access to routes. If any guard denies access, navigation is blocked.
442    ///
443    /// # Example
444    ///
445    /// ```no_run
446    /// use gpui-navigator::{Route, AuthGuard, RoleGuard};
447    /// use gpui::*;
448    ///
449    /// fn is_authenticated(_cx: &App) -> bool { true }
450    /// fn get_role(_cx: &App) -> Option<String> { Some("user".into()) }
451    ///
452    /// Route::new("/dashboard", |_cx, _params| div())
453    ///     .guard(AuthGuard::new(is_authenticated, "/login"))
454    ///     .guard(RoleGuard::new(get_role, "user", Some("/forbidden")));
455    /// ```
456    #[cfg(feature = "guard")]
457    pub fn guard<G>(mut self, guard: G) -> Self
458    where
459        G: crate::guards::RouteGuard<
460            Future = std::pin::Pin<
461                Box<dyn std::future::Future<Output = crate::guards::GuardResult> + Send>,
462            >,
463        >,
464    {
465        self.guards.push(Box::new(guard));
466        self
467    }
468
469    /// Add multiple guards at once
470    ///
471    /// # Example
472    ///
473    /// ```no_run
474    /// use gpui-navigator::{Route, BoxedGuard};
475    /// use gpui::*;
476    ///
477    /// Route::new("/admin", |_cx, _params| div())
478    ///     .guards(vec![
479    ///         // Add boxed guards here
480    ///     ]);
481    /// ```
482    #[cfg(feature = "guard")]
483    pub fn guards(mut self, guards: Vec<BoxedGuard>) -> Self {
484        self.guards.extend(guards);
485        self
486    }
487
488    /// Add middleware to this route
489    ///
490    /// Middleware runs before and after navigation.
491    ///
492    /// # Example
493    ///
494    /// ```no_run
495    /// use gpui-navigator::Route;
496    /// use gpui::*;
497    ///
498    /// // Route::new("/dashboard", |_cx, _params| div().into_any_element())
499    /// //     .middleware(LoggingMiddleware::new());
500    /// ```
501    #[cfg(feature = "middleware")]
502    pub fn middleware<M>(mut self, middleware: M) -> Self
503    where
504        M: crate::middleware::RouteMiddleware<
505            Future = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
506        >,
507    {
508        self.middleware.push(Box::new(middleware));
509        self
510    }
511
512    /// Add multiple middleware at once
513    ///
514    /// # Example
515    ///
516    /// ```no_run
517    /// use gpui-navigator::{Route, BoxedMiddleware};
518    /// use gpui::*;
519    ///
520    /// Route::new("/dashboard", |_cx, _params| div().into_any_element())
521    ///     .middlewares(vec![
522    ///         // Add boxed middleware here
523    ///     ]);
524    /// ```
525    #[cfg(feature = "middleware")]
526    pub fn middlewares(mut self, middleware: Vec<BoxedMiddleware>) -> Self {
527        self.middleware.extend(middleware);
528        self
529    }
530
531    /// Add lifecycle hooks to this route
532    ///
533    /// Lifecycle hooks allow you to run code when entering/exiting routes.
534    ///
535    /// # Example
536    ///
537    /// ```no_run
538    /// use gpui-navigator::{Route, RouteLifecycle, LifecycleResult, NavigationRequest};
539    /// use gpui::*;
540    /// use std::pin::Pin;
541    /// use std::future::Future;
542    ///
543    /// // Lifecycle hooks allow running code when entering/exiting routes
544    /// // Implement RouteLifecycle trait for custom behavior
545    /// ```
546    pub fn lifecycle<L>(mut self, lifecycle: L) -> Self
547    where
548        L: crate::lifecycle::RouteLifecycle<
549            Future = std::pin::Pin<
550                Box<dyn std::future::Future<Output = crate::lifecycle::LifecycleResult> + Send>,
551            >,
552        >,
553    {
554        self.lifecycle = Some(Box::new(lifecycle));
555        self
556    }
557
558    /// Set the transition animation for this route
559    ///
560    /// # Example
561    /// ```no_run
562    /// use gpui-navigator::{Route, Transition};
563    /// use gpui::*;
564    ///
565    /// Route::new("/page", |_cx, _params| div().into_any_element())
566    ///     .transition(Transition::fade(200));
567    /// ```
568    #[cfg(feature = "transition")]
569    pub fn transition(mut self, transition: crate::transition::Transition) -> Self {
570        self.transition = TransitionConfig::new(transition);
571        self
572    }
573
574    /// Get child routes for a named outlet
575    ///
576    /// Returns None if the outlet doesn't exist
577    pub fn get_named_children(&self, name: &str) -> Option<&[RouteRef]> {
578        self.named_children.get(name).map(|v| v.as_slice())
579    }
580
581    /// Check if this route has a named outlet
582    pub fn has_named_outlet(&self, name: &str) -> bool {
583        self.named_children.contains_key(name)
584    }
585
586    /// Get all named outlet names
587    pub fn named_outlet_names(&self) -> Vec<&str> {
588        self.named_children.keys().map(|s| s.as_str()).collect()
589    }
590
591    /// Match a path against this route
592    pub fn matches(&self, path: &str) -> Option<RouteMatch> {
593        match_path(&self.config.path, path)
594    }
595
596    /// Build the view for this route
597    pub fn build(&self, cx: &mut App, params: &RouteParams) -> Option<AnyElement> {
598        self.builder.as_ref().map(|b| b(cx, params))
599    }
600
601    /// Find a child route by path segment
602    ///
603    /// Used internally by RouterOutlet to resolve child routes.
604    pub fn find_child(&self, segment: &str) -> Option<&RouteRef> {
605        self.children.iter().find(|child| {
606            child.config.path == segment || child.config.path.trim_start_matches('/') == segment
607        })
608    }
609
610    /// Get all child routes
611    pub fn get_children(&self) -> &[RouteRef] {
612        &self.children
613    }
614}
615
616impl std::fmt::Debug for Route {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        f.debug_struct("Route")
619            .field("config", &self.config)
620            .field("builder", &self.builder.is_some())
621            .field("children", &self.children.len())
622            .field(
623                "named_children",
624                &self.named_children.keys().collect::<Vec<_>>(),
625            )
626            .finish()
627    }
628}
629
630/// Match a path pattern against an actual path
631///
632/// Supports:
633/// - Static paths: `/users`
634/// - Dynamic segments: `/users/:id`
635/// - Wildcard: `/files/*`
636fn match_path(pattern: &str, path: &str) -> Option<RouteMatch> {
637    let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
638    let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
639
640    // Handle wildcard
641    if pattern_segments.last() == Some(&"*") {
642        if path_segments.len() < pattern_segments.len() - 1 {
643            return None;
644        }
645    } else if pattern_segments.len() != path_segments.len() {
646        return None;
647    }
648
649    let mut route_match = RouteMatch::new(path.to_string());
650
651    for (i, pattern_seg) in pattern_segments.iter().enumerate() {
652        if *pattern_seg == "*" {
653            // Wildcard matches rest of path
654            break;
655        }
656
657        if let Some(param_name) = pattern_seg.strip_prefix(':') {
658            // Dynamic segment
659            if let Some(path_seg) = path_segments.get(i) {
660                route_match
661                    .params
662                    .insert(param_name.to_string(), path_seg.to_string());
663            }
664        } else if pattern_segments.get(i) != path_segments.get(i) {
665            // Static segment mismatch
666            return None;
667        }
668    }
669
670    Some(route_match)
671}
672
673// ============================================================================
674// Route Builder Utilities
675// ============================================================================
676
677//
678// This module provides a flexible routing system that can accept both simple
679// string paths and route builders with parameters.
680
681/// Trait for types that can be converted into a route
682///
683/// This allows Navigator.push() to accept both strings and route builders:
684/// ```ignore
685/// use gpui-navigator::{Navigator, PageRoute};
686///
687/// // String path
688/// Navigator::push(cx, "/users");
689///
690/// // Route with builder
691/// Navigator::push(cx, PageRoute::builder("/profile", |_cx, _params| {
692///     gpui::div()
693/// }));
694/// ```
695pub trait IntoRoute {
696    /// Convert this type into a route path and optional builder
697    fn into_route(self) -> RouteDescriptor;
698}
699
700/// Type for route builder function
701pub type BuilderFn = Arc<dyn Fn(&mut App, &RouteParams) -> AnyElement + Send + Sync>;
702
703/// A route descriptor containing path, parameters, and optional builder
704pub struct RouteDescriptor {
705    /// The route path (e.g., "/users/:id")
706    pub path: String,
707
708    /// Parameters to pass to the route
709    pub params: RouteParams,
710
711    /// Optional builder function to create the view
712    pub builder: Option<BuilderFn>,
713}
714
715// RouteParams is now imported from crate::params::RouteParams
716
717// Implement IntoRoute for String (simple path navigation)
718impl IntoRoute for String {
719    fn into_route(self) -> RouteDescriptor {
720        RouteDescriptor {
721            path: self,
722            params: RouteParams::new(),
723            builder: None,
724        }
725    }
726}
727
728// Implement IntoRoute for &str
729impl IntoRoute for &str {
730    fn into_route(self) -> RouteDescriptor {
731        RouteDescriptor {
732            path: self.to_string(),
733            params: RouteParams::new(),
734            builder: None,
735        }
736    }
737}
738
739/// A page route with optional builder function
740///
741/// # Example
742///
743/// ```ignore
744/// use gpui-navigator::{Navigator, PageRoute};
745///
746/// // Simple path (no builder)
747/// Navigator::push(cx, PageRoute::new("/users"));
748///
749/// // With parameters
750/// Navigator::push(
751///     cx,
752///     PageRoute::new("/users/:id")
753///         .with_param("id".into(), "123".into())
754/// );
755///
756/// // With builder function
757/// Navigator::push(
758///     cx,
759///     PageRoute::builder("/profile", |cx, params| {
760///         div().child("Profile Page")
761///     })
762/// );
763/// ```
764pub struct PageRoute {
765    path: String,
766    params: RouteParams,
767    builder: Option<BuilderFn>,
768}
769
770impl PageRoute {
771    /// Create a new PageRoute with a path (no builder)
772    pub fn new(path: impl Into<String>) -> Self {
773        Self {
774            path: path.into(),
775            params: RouteParams::new(),
776            builder: None,
777        }
778    }
779
780    /// Create a PageRoute with a builder function
781    ///
782    /// The builder can return any type that implements `IntoElement` -
783    /// conversion to `AnyElement` is done automatically.
784    pub fn builder<F, E>(path: impl Into<String>, builder: F) -> Self
785    where
786        E: IntoElement,
787        F: Fn(&mut App, &RouteParams) -> E + Send + Sync + 'static,
788    {
789        Self {
790            path: path.into(),
791            params: RouteParams::new(),
792            builder: Some(Arc::new(move |cx, params| {
793                builder(cx, params).into_any_element()
794            })),
795        }
796    }
797
798    /// Set the builder function for this route
799    ///
800    /// The builder can return any type that implements `IntoElement` -
801    /// conversion to `AnyElement` is done automatically.
802    pub fn with_builder<F, E>(mut self, builder: F) -> Self
803    where
804        E: IntoElement,
805        F: Fn(&mut App, &RouteParams) -> E + Send + Sync + 'static,
806    {
807        self.builder = Some(Arc::new(move |cx, params| {
808            builder(cx, params).into_any_element()
809        }));
810        self
811    }
812
813    /// Add a parameter to this route
814    pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
815        self.params.insert(key.into(), value.into());
816        self
817    }
818
819    /// Add multiple parameters
820    pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
821        self.params = RouteParams::from_map(params);
822        self
823    }
824}
825
826impl IntoRoute for PageRoute {
827    fn into_route(self) -> RouteDescriptor {
828        RouteDescriptor {
829            path: self.path,
830            params: self.params,
831            builder: self.builder,
832        }
833    }
834}
835
836/// A named route for predefined routes
837///
838/// # Example
839///
840/// ```ignore
841/// use gpui-navigator::{Navigator, NamedRoute, RouteParams};
842///
843/// let mut params = RouteParams::new();
844/// params.set("userId".to_string(), "123".to_string());
845/// Navigator::push_named(cx, "user_profile", &params);
846/// ```
847pub struct NamedRoute {
848    name: String,
849    params: RouteParams,
850}
851
852impl NamedRoute {
853    /// Create a new named route
854    pub fn new(name: impl Into<String>) -> Self {
855        Self {
856            name: name.into(),
857            params: RouteParams::new(),
858        }
859    }
860
861    /// Add a parameter
862    pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
863        self.params.insert(key.into(), value.into());
864        self
865    }
866
867    /// Add multiple parameters
868    pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
869        self.params = RouteParams::from_map(params);
870        self
871    }
872}
873
874impl IntoRoute for NamedRoute {
875    fn into_route(self) -> RouteDescriptor {
876        RouteDescriptor {
877            path: self.name,
878            params: self.params,
879            builder: None,
880        }
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    // NamedRouteRegistry tests
889
890    #[test]
891    fn test_registry_register_and_get() {
892        let mut registry = NamedRouteRegistry::new();
893        registry.register("home", "/");
894        registry.register("user.detail", "/users/:id");
895
896        assert_eq!(registry.get("home"), Some("/"));
897        assert_eq!(registry.get("user.detail"), Some("/users/:id"));
898        assert_eq!(registry.get("unknown"), None);
899    }
900
901    #[test]
902    fn test_registry_contains() {
903        let mut registry = NamedRouteRegistry::new();
904        registry.register("home", "/");
905
906        assert!(registry.contains("home"));
907        assert!(!registry.contains("unknown"));
908    }
909
910    #[test]
911    fn test_url_for_simple() {
912        let mut registry = NamedRouteRegistry::new();
913        registry.register("home", "/");
914
915        let params = RouteParams::new();
916        assert_eq!(registry.url_for("home", &params), Some("/".to_string()));
917    }
918
919    #[test]
920    fn test_url_for_with_params() {
921        let mut registry = NamedRouteRegistry::new();
922        registry.register("user.detail", "/users/:id");
923
924        let mut params = RouteParams::new();
925        params.set("id".to_string(), "123".to_string());
926
927        assert_eq!(
928            registry.url_for("user.detail", &params),
929            Some("/users/123".to_string())
930        );
931    }
932
933    #[test]
934    fn test_url_for_multiple_params() {
935        let mut registry = NamedRouteRegistry::new();
936        registry.register("post.comment", "/posts/:postId/comments/:commentId");
937
938        let mut params = RouteParams::new();
939        params.set("postId".to_string(), "42".to_string());
940        params.set("commentId".to_string(), "99".to_string());
941
942        assert_eq!(
943            registry.url_for("post.comment", &params),
944            Some("/posts/42/comments/99".to_string())
945        );
946    }
947
948    #[test]
949    fn test_url_for_unknown_route() {
950        let registry = NamedRouteRegistry::new();
951        let params = RouteParams::new();
952
953        assert_eq!(registry.url_for("unknown", &params), None);
954    }
955
956    #[test]
957    fn test_registry_clear() {
958        let mut registry = NamedRouteRegistry::new();
959        registry.register("home", "/");
960        registry.register("about", "/about");
961
962        assert_eq!(registry.len(), 2);
963
964        registry.clear();
965
966        assert_eq!(registry.len(), 0);
967        assert!(registry.is_empty());
968    }
969
970    #[test]
971    fn test_substitute_params() {
972        let mut params = RouteParams::new();
973        params.set("id".to_string(), "123".to_string());
974        params.set("action".to_string(), "edit".to_string());
975
976        let result = substitute_params("/users/:id/:action", &params);
977        assert_eq!(result, "/users/123/edit");
978    }
979
980    // Route tests
981
982    #[test]
983    fn test_static_route() {
984        let result = match_path("/users", "/users");
985        assert!(result.is_some());
986
987        let result = match_path("/users", "/posts");
988        assert!(result.is_none());
989    }
990
991    #[test]
992    fn test_dynamic_route() {
993        let result = match_path("/users/:id", "/users/123");
994        assert!(result.is_some());
995
996        let route_match = result.unwrap();
997        assert_eq!(route_match.params.get("id"), Some(&"123".to_string()));
998    }
999
1000    #[test]
1001    fn test_wildcard_route() {
1002        let result = match_path("/files/*", "/files/documents/report.pdf");
1003        assert!(result.is_some());
1004
1005        let result = match_path("/files/*", "/other/path");
1006        assert!(result.is_none());
1007    }
1008
1009    #[test]
1010    fn test_string_into_route() {
1011        let route = "/users".into_route();
1012        assert_eq!(route.path, "/users");
1013        assert!(route.params.all().is_empty());
1014    }
1015
1016    #[test]
1017    fn test_material_route_with_params() {
1018        let route = PageRoute::new("/users/:id")
1019            .with_param("id", "123")
1020            .into_route();
1021
1022        assert_eq!(route.path, "/users/:id");
1023        assert_eq!(route.params.get("id"), Some(&"123".to_string()));
1024    }
1025
1026    #[test]
1027    fn test_named_route() {
1028        let route = NamedRoute::new("user_profile")
1029            .with_param("userId", "456")
1030            .into_route();
1031
1032        assert_eq!(route.path, "user_profile");
1033        assert_eq!(route.params.get("userId"), Some(&"456".to_string()));
1034    }
1035
1036    // Validation tests
1037
1038    #[test]
1039    fn test_validate_valid_paths() {
1040        assert!(validate_route_path("/").is_ok());
1041        assert!(validate_route_path("/users").is_ok());
1042        assert!(validate_route_path("/users/:id").is_ok());
1043        assert!(validate_route_path("/posts/:postId/comments/:commentId").is_ok());
1044        assert!(validate_route_path("/users/:id{uuid}").is_ok());
1045        assert!(validate_route_path("/api/v1/users").is_ok());
1046        assert!(validate_route_path("settings").is_ok()); // relative path
1047        assert!(validate_route_path("").is_ok()); // empty path (index route)
1048        assert!(validate_route_path("/users/").is_ok()); // trailing slash allowed
1049    }
1050
1051    #[test]
1052    fn test_validate_consecutive_slashes() {
1053        let result = validate_route_path("/users//profile");
1054        assert!(result.is_err());
1055        assert!(result.unwrap_err().contains("consecutive slashes"));
1056    }
1057
1058    #[test]
1059    fn test_validate_empty_parameter() {
1060        let result = validate_route_path("/users/:");
1061        assert!(result.is_err());
1062        assert!(result
1063            .unwrap_err()
1064            .contains("parameter name cannot be empty"));
1065    }
1066
1067    #[test]
1068    fn test_validate_invalid_parameter_name() {
1069        let result = validate_route_path("/users/:user-id");
1070        assert!(result.is_err());
1071        assert!(result.unwrap_err().contains("alphanumeric"));
1072    }
1073
1074    #[test]
1075    fn test_validate_duplicate_parameters() {
1076        let result = validate_route_path("/users/:id/posts/:id");
1077        assert!(result.is_err());
1078        assert!(result.unwrap_err().contains("Duplicate"));
1079    }
1080
1081    #[test]
1082    fn test_route_config_try_new_valid() {
1083        let result = RouteConfig::try_new("/users/:id");
1084        assert!(result.is_ok());
1085        assert_eq!(result.unwrap().path, "/users/:id");
1086    }
1087
1088    #[test]
1089    fn test_route_config_try_new_invalid() {
1090        let result = RouteConfig::try_new("/users//profile");
1091        assert!(result.is_err());
1092    }
1093
1094    #[test]
1095    #[should_panic(expected = "Invalid route path")]
1096    fn test_route_config_new_panics_on_invalid() {
1097        RouteConfig::new("/users//profile");
1098    }
1099}