gpui_navigator/guards.rs
1//! Route guards for authentication, authorization, and validation
2//!
3//! Guards are middleware that can block, allow, or redirect navigation.
4//! They're useful for authentication, authorization, and validation.
5
6use crate::{NavigationRequest, RouteMatch};
7use gpui::App;
8use std::future::Future;
9use std::pin::Pin;
10
11/// Result of a guard check
12#[derive(Debug, Clone, PartialEq)]
13pub enum GuardResult {
14 /// Allow navigation to proceed
15 Allow,
16
17 /// Deny navigation with a reason
18 Deny {
19 /// Reason for denying navigation
20 reason: String,
21 },
22
23 /// Redirect to a different path
24 Redirect {
25 /// Path to redirect to
26 to: String,
27 /// Reason for redirect (optional)
28 reason: Option<String>,
29 },
30}
31
32impl GuardResult {
33 /// Create an allow result
34 pub fn allow() -> Self {
35 GuardResult::Allow
36 }
37
38 /// Create a deny result with reason
39 pub fn deny(reason: impl Into<String>) -> Self {
40 GuardResult::Deny {
41 reason: reason.into(),
42 }
43 }
44
45 /// Create a redirect result
46 pub fn redirect(to: impl Into<String>) -> Self {
47 GuardResult::Redirect {
48 to: to.into(),
49 reason: None,
50 }
51 }
52
53 /// Create a redirect result with reason
54 pub fn redirect_with_reason(to: impl Into<String>, reason: impl Into<String>) -> Self {
55 GuardResult::Redirect {
56 to: to.into(),
57 reason: Some(reason.into()),
58 }
59 }
60
61 /// Check if result is allow
62 pub fn is_allow(&self) -> bool {
63 matches!(self, GuardResult::Allow)
64 }
65
66 /// Check if result is deny
67 pub fn is_deny(&self) -> bool {
68 matches!(self, GuardResult::Deny { .. })
69 }
70
71 /// Check if result is redirect
72 pub fn is_redirect(&self) -> bool {
73 matches!(self, GuardResult::Redirect { .. })
74 }
75
76 /// Get redirect path if this is a redirect
77 pub fn redirect_path(&self) -> Option<&str> {
78 match self {
79 GuardResult::Redirect { to, .. } => Some(to.as_str()),
80 _ => None,
81 }
82 }
83}
84
85/// Trait for route guards
86///
87/// Guards can block navigation, allow it, or redirect to a different path.
88/// Guards use an associated `Future` type for async operations without boxing.
89///
90/// # Design Benefits
91///
92/// - Zero-cost: No `Box<dyn Future>` allocation for concrete types
93/// - Compile-time: Types checked at compile time
94/// - Composable: Can be wrapped and combined
95/// - Trait object safe: Can still use `Box<dyn RouteGuard>` when needed
96///
97/// # Example
98///
99/// ```no_run
100/// use gpui_navigator::{RouteGuard, GuardResult, NavigationRequest};
101/// use std::future::Future;
102/// use std::pin::Pin;
103///
104/// struct MyAuthGuard {
105/// redirect_to: String,
106/// }
107///
108/// impl RouteGuard for MyAuthGuard {
109/// type Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>;
110///
111/// fn check(&self, _cx: &gpui::App, _request: &NavigationRequest) -> Self::Future {
112/// let redirect_to = self.redirect_to.clone();
113/// let is_authenticated = true; // Replace with actual check
114///
115/// Box::pin(async move {
116/// if is_authenticated {
117/// GuardResult::allow()
118/// } else {
119/// GuardResult::redirect(redirect_to)
120/// }
121/// })
122/// }
123/// }
124/// ```
125///
126/// # For Simple Guards
127///
128/// Use the `guard_fn` helper to create guards from async closures:
129///
130/// ```no_run
131/// use gpui_navigator::{guard_fn, GuardResult};
132///
133/// let guard = guard_fn(|_cx, _request| async move {
134/// // Replace with actual authentication check
135/// let is_authenticated = true;
136/// if is_authenticated {
137/// GuardResult::allow()
138/// } else {
139/// GuardResult::redirect("/login")
140/// }
141/// });
142/// ```
143pub trait RouteGuard: Send + Sync + 'static {
144 /// The future returned by check
145 type Future: Future<Output = GuardResult> + Send + 'static;
146
147 /// Check if navigation should be allowed
148 ///
149 /// # Parameters
150 /// - `cx`: Application context
151 /// - `request`: Navigation request with extensions for context
152 ///
153 /// # Returns
154 /// A future that resolves to:
155 /// - `GuardResult::Allow` to allow navigation
156 /// - `GuardResult::Deny` to block navigation
157 /// - `GuardResult::Redirect` to redirect to a different path
158 ///
159 /// # Note
160 ///
161 /// Guards can access route parameters and request data through the
162 /// `request` parameter to make authorization decisions.
163 fn check(&self, cx: &App, request: &NavigationRequest) -> Self::Future;
164
165 /// Get guard name (for debugging and error messages)
166 fn name(&self) -> &str {
167 "RouteGuard"
168 }
169
170 /// Optional priority for guard execution order
171 ///
172 /// Higher priority guards run first. Default is 0.
173 fn priority(&self) -> i32 {
174 0
175 }
176}
177
178/// Boxed route guard for dynamic dispatch
179pub type BoxedGuard =
180 Box<dyn RouteGuard<Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>>>;
181
182/// Create a guard from an async function or closure
183///
184/// This is a convenience helper for creating guards without manually implementing
185/// the `RouteGuard` trait.
186///
187/// # Example
188///
189/// ```no_run
190/// use gpui_navigator::{guard_fn, GuardResult};
191///
192/// let auth_guard = guard_fn(|_cx, _request| async move {
193/// // Replace with actual authentication check
194/// let is_authenticated = true;
195/// if is_authenticated {
196/// GuardResult::allow()
197/// } else {
198/// GuardResult::redirect("/login")
199/// }
200/// });
201/// ```
202pub fn guard_fn<F, Fut>(f: F) -> FnGuard<F>
203where
204 F: Fn(&App, &NavigationRequest) -> Fut + Send + Sync + 'static,
205 Fut: Future<Output = GuardResult> + Send + 'static,
206{
207 FnGuard { f }
208}
209
210/// Guard created from a function or closure
211pub struct FnGuard<F> {
212 f: F,
213}
214
215impl<F, Fut> RouteGuard for FnGuard<F>
216where
217 F: Fn(&App, &NavigationRequest) -> Fut + Send + Sync + 'static,
218 Fut: Future<Output = GuardResult> + Send + 'static,
219{
220 type Future = Fut;
221
222 fn check(&self, cx: &App, request: &NavigationRequest) -> Self::Future {
223 (self.f)(cx, request)
224 }
225}
226
227/// Guard context provides information about the navigation
228#[derive(Debug, Clone)]
229pub struct GuardContext {
230 /// Source route
231 pub from: Option<String>,
232
233 /// Target route
234 pub to: String,
235
236 /// Target route match
237 pub to_match: RouteMatch,
238}
239
240impl GuardContext {
241 /// Create a new guard context
242 pub fn new(from: Option<String>, to: String, to_match: RouteMatch) -> Self {
243 Self { from, to, to_match }
244 }
245
246 /// Get parameter from target route
247 pub fn param(&self, key: &str) -> Option<&String> {
248 self.to_match.params.get(key)
249 }
250
251 /// Get query parameter from target route
252 pub fn query(&self, key: &str) -> Option<&String> {
253 self.to_match.query.get(key)
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 use std::collections::HashMap;
262
263 #[test]
264 fn test_guard_result_allow() {
265 let result = GuardResult::allow();
266 assert!(result.is_allow());
267 assert!(!result.is_deny());
268 assert!(!result.is_redirect());
269 assert_eq!(result.redirect_path(), None);
270 }
271
272 #[test]
273 fn test_guard_result_deny() {
274 let result = GuardResult::deny("Not authorized");
275 assert!(!result.is_allow());
276 assert!(result.is_deny());
277 assert!(!result.is_redirect());
278
279 match result {
280 GuardResult::Deny { reason } => {
281 assert_eq!(reason, "Not authorized");
282 }
283 _ => panic!("Expected Deny"),
284 }
285 }
286
287 #[test]
288 fn test_guard_result_redirect() {
289 let result = GuardResult::redirect("/login");
290 assert!(!result.is_allow());
291 assert!(!result.is_deny());
292 assert!(result.is_redirect());
293 assert_eq!(result.redirect_path(), Some("/login"));
294 }
295
296 #[test]
297 fn test_guard_result_redirect_with_reason() {
298 let result = GuardResult::redirect_with_reason("/login", "Authentication required");
299
300 match result {
301 GuardResult::Redirect { to, reason } => {
302 assert_eq!(to, "/login");
303 assert_eq!(reason, Some("Authentication required".to_string()));
304 }
305 _ => panic!("Expected Redirect"),
306 }
307 }
308
309 #[test]
310 fn test_guard_context() {
311 let route_match = RouteMatch {
312 path: "/users/123".to_string(),
313 params: {
314 let mut map = HashMap::new();
315 map.insert("id".to_string(), "123".to_string());
316 map
317 },
318 query: {
319 let mut map = HashMap::new();
320 map.insert("page".to_string(), "1".to_string());
321 map
322 },
323 };
324
325 let ctx = GuardContext::new(Some("/".to_string()), "/users/123".to_string(), route_match);
326
327 assert_eq!(ctx.from, Some("/".to_string()));
328 assert_eq!(ctx.to, "/users/123");
329 assert_eq!(ctx.param("id"), Some(&"123".to_string()));
330 assert_eq!(ctx.query("page"), Some(&"1".to_string()));
331 assert_eq!(ctx.param("missing"), None);
332 }
333
334 // Mock guard for testing
335 struct AlwaysAllowGuard;
336
337 impl RouteGuard for AlwaysAllowGuard {
338 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>;
339
340 fn check(&self, _cx: &App, _request: &NavigationRequest) -> Self::Future {
341 Box::pin(async { GuardResult::allow() })
342 }
343
344 fn name(&self) -> &str {
345 "AlwaysAllowGuard"
346 }
347 }
348
349 #[test]
350 fn test_guard_trait_name() {
351 let guard = AlwaysAllowGuard;
352 assert_eq!(guard.name(), "AlwaysAllowGuard");
353 }
354
355 #[test]
356 fn test_guard_trait_priority() {
357 let guard = AlwaysAllowGuard;
358 assert_eq!(guard.priority(), 0); // Default priority
359 }
360
361 #[test]
362 fn test_guard_fn_helper() {
363 let guard = guard_fn(|_cx, _request| async { GuardResult::allow() });
364
365 assert_eq!(guard.name(), "RouteGuard"); // Default name
366 }
367}
368
369// ============================================================================
370// Authentication and Authorization Guards
371// ============================================================================
372
373/// Type alias for authentication check function.
374///
375/// The function receives the application context and returns whether the user is authenticated.
376pub type AuthCheckFn = Box<dyn Fn(&App) -> bool + Send + Sync>;
377
378/// Authentication guard that checks if user is logged in.
379///
380/// Unlike placeholder guards, this guard is fully configurable with a custom
381/// authentication check function that you provide.
382///
383/// # Example
384///
385/// ```ignore
386/// use gpui_navigator::*;
387/// use gpui::App;
388///
389/// // Define your authentication check
390/// fn is_authenticated(cx: &App) -> bool {
391/// // Check your auth state here
392/// cx.try_global::<AuthState>()
393/// .map(|state| state.is_logged_in())
394/// .unwrap_or(false)
395/// }
396///
397/// // Use the guard
398/// Route::new("/dashboard", dashboard_page)
399/// .guard(AuthGuard::new(is_authenticated, "/login"))
400/// # ;
401/// # fn dashboard_page(_: &mut App, _: &gpui_navigator::RouteParams) -> gpui::AnyElement { todo!() }
402/// # struct AuthState;
403/// # impl AuthState { fn is_logged_in(&self) -> bool { false } }
404/// ```
405pub struct AuthGuard {
406 /// Function to check if user is authenticated
407 check_fn: AuthCheckFn,
408 /// Path to redirect to if not authenticated
409 redirect_path: String,
410}
411
412impl AuthGuard {
413 /// Create a new auth guard with a custom check function and redirect path.
414 ///
415 /// # Arguments
416 ///
417 /// * `check_fn` - Function that returns `true` if the user is authenticated
418 /// * `redirect_path` - Path to redirect to if authentication fails
419 ///
420 /// # Example
421 ///
422 /// ```ignore
423 /// use gpui_navigator::*;
424 ///
425 /// let guard = AuthGuard::new(
426 /// |cx| cx.try_global::<IsLoggedIn>().is_some(),
427 /// "/login"
428 /// );
429 /// # struct IsLoggedIn;
430 /// ```
431 pub fn new<F>(check_fn: F, redirect_path: impl Into<String>) -> Self
432 where
433 F: Fn(&App) -> bool + Send + Sync + 'static,
434 {
435 Self {
436 check_fn: Box::new(check_fn),
437 redirect_path: redirect_path.into(),
438 }
439 }
440
441 /// Create an auth guard that always allows access (for testing/development).
442 ///
443 /// **Warning**: Do not use in production!
444 #[cfg(debug_assertions)]
445 pub fn allow_all() -> Self {
446 Self::new(|_| true, "/login")
447 }
448
449 /// Create an auth guard that always denies access (for testing/development).
450 ///
451 /// **Warning**: Do not use in production!
452 #[cfg(debug_assertions)]
453 pub fn deny_all(redirect_path: impl Into<String>) -> Self {
454 Self::new(|_| false, redirect_path)
455 }
456}
457
458impl RouteGuard for AuthGuard {
459 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>;
460
461 fn check(&self, cx: &App, _request: &NavigationRequest) -> Self::Future {
462 // Check authentication synchronously and return ready future
463 let is_authenticated = (self.check_fn)(cx);
464 let result = if is_authenticated {
465 GuardResult::allow()
466 } else {
467 GuardResult::redirect_with_reason(&self.redirect_path, "Authentication required")
468 };
469
470 Box::pin(async move { result })
471 }
472
473 fn name(&self) -> &str {
474 "AuthGuard"
475 }
476
477 fn priority(&self) -> i32 {
478 100 // High priority - check auth first
479 }
480}
481
482/// Type alias for role extraction function.
483///
484/// The function receives the application context and returns the user's current role(s).
485pub type RoleExtractorFn = Box<dyn Fn(&App) -> Option<String> + Send + Sync>;
486
487/// Role-based authorization guard.
488///
489/// Checks if user has required role for accessing a route. You must provide
490/// a function that extracts the user's role from your application state.
491///
492/// # Example
493///
494/// ```ignore
495/// use gpui_navigator::*;
496/// use gpui::App;
497///
498/// // Define how to get user's role
499/// fn get_user_role(cx: &App) -> Option<String> {
500/// cx.try_global::<CurrentUser>()
501/// .map(|user| user.role.clone())
502/// }
503///
504/// // Use the guard
505/// Route::new("/admin", admin_page)
506/// .guard(RoleGuard::new(get_user_role, "admin", Some("/forbidden")))
507/// # ;
508/// # fn admin_page(_: &mut App, _: &gpui_navigator::RouteParams) -> gpui::AnyElement { todo!() }
509/// # struct CurrentUser { role: String }
510/// ```
511pub struct RoleGuard {
512 /// Function to extract user's current role
513 role_extractor: RoleExtractorFn,
514 /// Required role
515 required_role: String,
516 /// Path to redirect to if unauthorized
517 redirect_path: Option<String>,
518}
519
520impl RoleGuard {
521 /// Create a new role guard with a role extractor function.
522 ///
523 /// # Arguments
524 ///
525 /// * `role_extractor` - Function that returns the user's current role (if any)
526 /// * `required_role` - The role required to access the route
527 /// * `redirect_path` - Optional path to redirect to if authorization fails
528 ///
529 /// # Example
530 ///
531 /// ```ignore
532 /// use gpui_navigator::*;
533 ///
534 /// let guard = RoleGuard::new(
535 /// |cx| cx.try_global::<UserRole>().map(|r| r.0.clone()),
536 /// "admin",
537 /// Some("/forbidden")
538 /// );
539 /// # struct UserRole(String);
540 /// ```
541 pub fn new<F>(
542 role_extractor: F,
543 required_role: impl Into<String>,
544 redirect_path: Option<impl Into<String>>,
545 ) -> Self
546 where
547 F: Fn(&App) -> Option<String> + Send + Sync + 'static,
548 {
549 Self {
550 role_extractor: Box::new(role_extractor),
551 required_role: required_role.into(),
552 redirect_path: redirect_path.map(Into::into),
553 }
554 }
555
556 /// Check if the extracted role matches the required role
557 fn has_required_role(&self, cx: &App) -> bool {
558 (self.role_extractor)(cx)
559 .map(|role| role == self.required_role)
560 .unwrap_or(false)
561 }
562}
563
564impl RouteGuard for RoleGuard {
565 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>;
566
567 fn check(&self, cx: &App, _request: &NavigationRequest) -> Self::Future {
568 let result = if self.has_required_role(cx) {
569 GuardResult::allow()
570 } else if let Some(redirect) = &self.redirect_path {
571 GuardResult::redirect_with_reason(
572 redirect,
573 format!("Requires '{}' role", self.required_role),
574 )
575 } else {
576 GuardResult::deny(format!("Missing required role: {}", self.required_role))
577 };
578
579 Box::pin(async move { result })
580 }
581
582 fn name(&self) -> &str {
583 "RoleGuard"
584 }
585
586 fn priority(&self) -> i32 {
587 90 // Slightly lower than auth guard
588 }
589}
590
591/// Type alias for permission check function.
592///
593/// The function receives the application context and the required permission,
594/// and returns whether the user has that permission.
595pub type PermissionCheckFn = Box<dyn Fn(&App, &str) -> bool + Send + Sync>;
596
597/// Permission-based authorization guard.
598///
599/// Checks if user has specific permission. You must provide a function
600/// that checks permissions against your application's permission system.
601///
602/// # Example
603///
604/// ```ignore
605/// use gpui_navigator::*;
606/// use gpui::App;
607///
608/// // Define permission check
609/// fn has_permission(cx: &App, permission: &str) -> bool {
610/// cx.try_global::<UserPermissions>()
611/// .map(|perms| perms.contains(permission))
612/// .unwrap_or(false)
613/// }
614///
615/// // Use the guard
616/// Route::new("/users/:id/delete", delete_user)
617/// .guard(PermissionGuard::new(has_permission, "users.delete"))
618/// # ;
619/// # fn delete_user(_: &mut App, _: &gpui_navigator::RouteParams) -> gpui::AnyElement { todo!() }
620/// # struct UserPermissions;
621/// # impl UserPermissions { fn contains(&self, _: &str) -> bool { false } }
622/// ```
623pub struct PermissionGuard {
624 /// Function to check if user has a permission
625 check_fn: PermissionCheckFn,
626 /// Required permission
627 permission: String,
628 /// Optional redirect path
629 redirect_path: Option<String>,
630}
631
632impl PermissionGuard {
633 /// Create a new permission guard with a check function.
634 ///
635 /// # Arguments
636 ///
637 /// * `check_fn` - Function that checks if user has the given permission
638 /// * `permission` - The permission required to access the route
639 ///
640 /// # Example
641 ///
642 /// ```ignore
643 /// use gpui_navigator::*;
644 ///
645 /// let guard = PermissionGuard::new(
646 /// |cx, perm| {
647 /// cx.try_global::<Permissions>()
648 /// .map(|p| p.has(perm))
649 /// .unwrap_or(false)
650 /// },
651 /// "users.delete"
652 /// );
653 /// # struct Permissions;
654 /// # impl Permissions { fn has(&self, _: &str) -> bool { false } }
655 /// ```
656 pub fn new<F>(check_fn: F, permission: impl Into<String>) -> Self
657 where
658 F: Fn(&App, &str) -> bool + Send + Sync + 'static,
659 {
660 Self {
661 check_fn: Box::new(check_fn),
662 permission: permission.into(),
663 redirect_path: None,
664 }
665 }
666
667 /// Add a redirect path for when permission is denied.
668 #[must_use]
669 pub fn with_redirect(mut self, path: impl Into<String>) -> Self {
670 self.redirect_path = Some(path.into());
671 self
672 }
673}
674
675impl RouteGuard for PermissionGuard {
676 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>;
677
678 fn check(&self, cx: &App, _request: &NavigationRequest) -> Self::Future {
679 let has_perm = (self.check_fn)(cx, &self.permission);
680 let result = if has_perm {
681 GuardResult::allow()
682 } else if let Some(redirect) = &self.redirect_path {
683 GuardResult::redirect_with_reason(
684 redirect,
685 format!("Missing permission: {}", self.permission),
686 )
687 } else {
688 GuardResult::deny(format!("Missing permission: {}", self.permission))
689 };
690
691 Box::pin(async move { result })
692 }
693
694 fn name(&self) -> &str {
695 "PermissionGuard"
696 }
697
698 fn priority(&self) -> i32 {
699 80
700 }
701}
702
703// ============================================================================
704// Guard Composition
705// ============================================================================
706
707// ============================================================================
708// Guard Composition
709// ============================================================================
710
711/// Combines multiple guards with AND logic
712///
713/// All guards must allow navigation for the combined guard to allow.
714/// If any guard denies or redirects, that result is returned.
715///
716/// # Example
717///
718/// ```ignore
719/// use gpui_navigator::{Guards, AuthGuard, RoleGuard};
720///
721/// // Builder syntax for combining guards
722/// let guard = Guards::builder()
723/// .guard(AuthGuard::new(|_| true, "/login"))
724/// .guard(RoleGuard::new(|_| Some("admin".into()), "admin", None::<&str>))
725/// .build();
726/// ```
727pub struct Guards {
728 guards: Vec<BoxedGuard>,
729}
730
731impl Guards {
732 /// Create a new AND composition of guards
733 ///
734 /// # Example
735 /// ```ignore
736 /// use gpui_navigator::{Guards, BoxedGuard};
737 ///
738 /// let guard = Guards::new(vec![
739 /// // Add boxed guards here
740 /// ]);
741 /// ```
742 pub fn new(guards: Vec<BoxedGuard>) -> Self {
743 Self { guards }
744 }
745
746 /// Create from individual guards (auto-boxing)
747 pub fn from_guards(guards: impl IntoIterator<Item = BoxedGuard>) -> Self {
748 Self {
749 guards: guards.into_iter().collect(),
750 }
751 }
752
753 /// Start building a guard composition
754 pub fn builder() -> GuardBuilder {
755 GuardBuilder::new()
756 }
757}
758
759/// Helper macro for creating Guards composition
760///
761/// # Example
762/// ```ignore
763/// use gpui_navigator::{guards, AuthGuard, RoleGuard};
764///
765/// let guard = guards![
766/// AuthGuard::new(|_| true, "/login"),
767/// ];
768/// ```
769#[macro_export]
770macro_rules! guards {
771($($guard:expr),* $(,)?) => {
772$crate::guards::Guards::new(
773 vec![$(Box::new($guard) as Box<dyn $crate::guards::RouteGuard>),*]
774)
775};
776}
777
778/// Builder for Guards with fluent API
779pub struct GuardBuilder {
780 guards: Vec<BoxedGuard>,
781}
782
783impl GuardBuilder {
784 /// Create a new builder
785 pub fn new() -> Self {
786 Self { guards: Vec::new() }
787 }
788
789 /// Add a guard to the composition
790 pub fn guard<G>(mut self, guard: G) -> Self
791 where
792 G: RouteGuard<Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>>,
793 {
794 self.guards.push(Box::new(guard));
795 self
796 }
797
798 /// Add a boxed guard
799 pub fn boxed_guard(mut self, guard: BoxedGuard) -> Self {
800 self.guards.push(guard);
801 self
802 }
803
804 /// Build the final Guards
805 pub fn build(self) -> Guards {
806 Guards::new(self.guards)
807 }
808}
809
810impl Default for GuardBuilder {
811 fn default() -> Self {
812 Self::new()
813 }
814}
815
816impl RouteGuard for Guards {
817 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send + 'static>>;
818
819 fn check(&self, cx: &App, request: &NavigationRequest) -> Self::Future {
820 // Execute guards in priority order
821 let mut sorted_guards: Vec<_> = self.guards.iter().collect();
822 sorted_guards.sort_by_key(|g| -g.priority());
823
824 let mut futures = Vec::new();
825 for guard in sorted_guards {
826 futures.push(guard.check(cx, request));
827 }
828
829 Box::pin(async move {
830 for future in futures {
831 match future.await {
832 GuardResult::Allow => continue,
833 other => return other,
834 }
835 }
836 GuardResult::Allow
837 })
838 }
839
840 fn name(&self) -> &str {
841 "Guards"
842 }
843
844 fn priority(&self) -> i32 {
845 // Priority is max of all child guards
846 self.guards.iter().map(|g| g.priority()).max().unwrap_or(0)
847 }
848}
849
850/// Inverts a guard result
851///
852/// Allow becomes Deny, Deny becomes Allow, Redirect is preserved.
853///
854/// # Example
855///
856/// ```ignore
857/// use gpui_navigator::{NotGuard, AuthGuard};
858///
859/// // Allow only if NOT authenticated (for login page)
860/// let guard = NotGuard::new(AuthGuard::new(|_| true, "/login"));
861/// ```
862pub struct NotGuard {
863 guard: BoxedGuard,
864}
865
866impl NotGuard {
867 /// Create a new NOT guard
868 pub fn new<G>(guard: G) -> Self
869 where
870 G: RouteGuard<Future = Pin<Box<dyn Future<Output = GuardResult> + Send>>>,
871 {
872 Self {
873 guard: Box::new(guard),
874 }
875 }
876
877 /// Create from a boxed guard
878 pub fn from_boxed(guard: BoxedGuard) -> Self {
879 Self { guard }
880 }
881}
882
883impl RouteGuard for NotGuard {
884 type Future = Pin<Box<dyn Future<Output = GuardResult> + Send + 'static>>;
885
886 fn check(&self, cx: &App, request: &NavigationRequest) -> Self::Future {
887 let future = self.guard.check(cx, request);
888
889 Box::pin(async move {
890 match future.await {
891 GuardResult::Allow => GuardResult::deny("Inverted: guard allowed but NOT expected"),
892 GuardResult::Deny { .. } => GuardResult::Allow,
893 redirect @ GuardResult::Redirect { .. } => redirect, // Preserve redirect
894 }
895 })
896 }
897
898 fn name(&self) -> &str {
899 "NotGuard"
900 }
901
902 fn priority(&self) -> i32 {
903 self.guard.priority()
904 }
905}
906
907// Additional imports for composition tests
908
909#[cfg(test)]
910mod auth_tests {
911 use super::*;
912 use gpui::TestAppContext;
913
914 #[gpui::test]
915 fn test_auth_guard_allows_authenticated(cx: &mut TestAppContext) {
916 // Create guard that always returns authenticated
917 let guard = AuthGuard::new(|_| true, "/login");
918 assert_eq!(guard.name(), "AuthGuard");
919 assert_eq!(guard.priority(), 100);
920
921 let request = NavigationRequest::new("/dashboard".to_string());
922 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
923
924 assert!(result.is_allow());
925 }
926
927 #[gpui::test]
928 fn test_auth_guard_blocks_unauthenticated(cx: &mut TestAppContext) {
929 // Create guard that always returns not authenticated
930 let guard = AuthGuard::new(|_| false, "/login");
931 let request = NavigationRequest::new("/dashboard".to_string());
932
933 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
934
935 assert!(result.is_redirect());
936 assert_eq!(result.redirect_path(), Some("/login"));
937 }
938
939 #[gpui::test]
940 fn test_role_guard_allows_correct_role(cx: &mut TestAppContext) {
941 // Create guard that returns "admin" role
942 let guard = RoleGuard::new(|_| Some("admin".to_string()), "admin", None::<String>);
943 assert_eq!(guard.name(), "RoleGuard");
944 assert_eq!(guard.priority(), 90);
945
946 let request = NavigationRequest::new("/admin".to_string());
947 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
948
949 assert!(result.is_allow());
950 }
951
952 #[gpui::test]
953 fn test_role_guard_with_redirect(cx: &mut TestAppContext) {
954 // Create guard that returns wrong role
955 let guard = RoleGuard::new(|_| Some("user".to_string()), "admin", Some("/403"));
956 let request = NavigationRequest::new("/admin".to_string());
957
958 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
959
960 assert!(result.is_redirect());
961 assert_eq!(result.redirect_path(), Some("/403"));
962 }
963
964 #[gpui::test]
965 fn test_role_guard_deny_without_redirect(cx: &mut TestAppContext) {
966 // Create guard that returns no role
967 let guard = RoleGuard::new(|_| None, "admin", None::<String>);
968 let request = NavigationRequest::new("/admin".to_string());
969
970 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
971
972 assert!(result.is_deny());
973 }
974
975 #[gpui::test]
976 fn test_permission_guard_allows(cx: &mut TestAppContext) {
977 // Create guard that always allows
978 let guard = PermissionGuard::new(|_, _| true, "users.delete");
979 assert_eq!(guard.name(), "PermissionGuard");
980
981 let request = NavigationRequest::new("/users/123/delete".to_string());
982 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
983
984 assert!(result.is_allow());
985 }
986
987 #[gpui::test]
988 fn test_permission_guard_denies(cx: &mut TestAppContext) {
989 // Create guard that always denies
990 let guard = PermissionGuard::new(|_, _| false, "users.delete");
991 let request = NavigationRequest::new("/users/123/delete".to_string());
992
993 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
994
995 assert!(result.is_deny());
996 }
997
998 #[gpui::test]
999 fn test_permission_guard_with_redirect(cx: &mut TestAppContext) {
1000 let guard = PermissionGuard::new(|_, _| false, "users.delete").with_redirect("/forbidden");
1001 let request = NavigationRequest::new("/users/123/delete".to_string());
1002
1003 let result = cx.update(|cx| pollster::block_on(guard.check(cx, &request)));
1004
1005 assert!(result.is_redirect());
1006 assert_eq!(result.redirect_path(), Some("/forbidden"));
1007 }
1008}