Skip to main content

gpui_navigator/
cache.rs

1//! Route resolution caching
2//!
3//! This module provides caching functionality to avoid repeated route lookups
4//! during rendering with LRU eviction policy.
5
6use crate::route::Route;
7use crate::{trace_log, RouteParams};
8use lru::LruCache;
9use std::num::NonZeroUsize;
10
11/// Unique identifier for a route in the tree
12///
13/// This allows us to reference routes without storing full Route clones.
14/// Routes are identified by their path hierarchy.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct RouteId {
17    /// Full path of the route (e.g., "/dashboard/analytics")
18    pub path: String,
19}
20
21impl RouteId {
22    /// Create a new route ID from a route
23    pub fn from_route(route: &Route) -> Self {
24        Self {
25            path: route.config.path.clone(),
26        }
27    }
28
29    /// Create a route ID from a path string
30    pub fn from_path(path: impl Into<String>) -> Self {
31        Self { path: path.into() }
32    }
33}
34
35/// Cache key for outlet resolution
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37struct OutletCacheKey {
38    path: String,
39    outlet_name: Option<String>,
40}
41
42/// Cached result of finding a parent route
43#[derive(Debug, Clone)]
44struct ParentRouteCacheEntry {
45    parent_route_id: RouteId,
46}
47
48/// Cache performance statistics
49#[derive(Debug, Clone, Default)]
50pub struct CacheStats {
51    pub parent_hits: usize,
52    pub parent_misses: usize,
53    pub child_hits: usize,
54    pub child_misses: usize,
55    pub invalidations: usize,
56}
57
58impl CacheStats {
59    pub fn parent_hit_rate(&self) -> f64 {
60        let total = self.parent_hits + self.parent_misses;
61        if total == 0 {
62            0.0
63        } else {
64            self.parent_hits as f64 / total as f64
65        }
66    }
67
68    pub fn child_hit_rate(&self) -> f64 {
69        let total = self.child_hits + self.child_misses;
70        if total == 0 {
71            0.0
72        } else {
73            self.child_hits as f64 / total as f64
74        }
75    }
76
77    pub fn overall_hit_rate(&self) -> f64 {
78        let total_hits = self.parent_hits + self.child_hits;
79        let total_misses = self.parent_misses + self.child_misses;
80        let total = total_hits + total_misses;
81        if total == 0 {
82            0.0
83        } else {
84            total_hits as f64 / total as f64
85        }
86    }
87}
88
89/// Route resolution cache with LRU eviction
90///
91/// Default capacity: 1000 entries per cache.
92#[derive(Debug)]
93pub struct RouteCache {
94    parent_cache: LruCache<String, ParentRouteCacheEntry>,
95    child_cache: LruCache<OutletCacheKey, RouteParams>,
96    stats: CacheStats,
97}
98
99impl RouteCache {
100    const DEFAULT_CAPACITY: usize = 1000;
101
102    pub fn new() -> Self {
103        Self::with_capacity(Self::DEFAULT_CAPACITY)
104    }
105
106    pub fn with_capacity(capacity: usize) -> Self {
107        let cap = NonZeroUsize::new(capacity).expect("Cache capacity must be non-zero");
108        Self {
109            parent_cache: LruCache::new(cap),
110            child_cache: LruCache::new(cap),
111            stats: CacheStats::default(),
112        }
113    }
114
115    pub fn clear(&mut self) {
116        trace_log!("Clearing route cache");
117        self.parent_cache.clear();
118        self.child_cache.clear();
119        self.stats.invalidations += 1;
120    }
121
122    pub fn get_parent(&mut self, path: &str) -> Option<RouteId> {
123        if let Some(entry) = self.parent_cache.get(path) {
124            self.stats.parent_hits += 1;
125            trace_log!("Parent cache hit for path: '{}'", path);
126            Some(entry.parent_route_id.clone())
127        } else {
128            self.stats.parent_misses += 1;
129            trace_log!("Parent cache miss for path: '{}'", path);
130            None
131        }
132    }
133
134    pub fn set_parent(&mut self, path: String, parent_route_id: RouteId) {
135        trace_log!(
136            "Caching parent route '{}' for path '{}'",
137            parent_route_id.path,
138            path
139        );
140        self.parent_cache
141            .push(path, ParentRouteCacheEntry { parent_route_id });
142    }
143
144    pub fn stats(&self) -> &CacheStats {
145        &self.stats
146    }
147
148    pub fn reset_stats(&mut self) {
149        self.stats = CacheStats::default();
150    }
151
152    pub fn parent_cache_size(&self) -> usize {
153        self.parent_cache.len()
154    }
155
156    pub fn child_cache_size(&self) -> usize {
157        self.child_cache.len()
158    }
159
160    pub fn total_size(&self) -> usize {
161        self.parent_cache_size() + self.child_cache_size()
162    }
163}
164
165impl Default for RouteCache {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl Clone for RouteCache {
172    fn clone(&self) -> Self {
173        let parent_cap = self.parent_cache.cap();
174        let child_cap = self.child_cache.cap();
175        Self {
176            parent_cache: LruCache::new(parent_cap),
177            child_cache: LruCache::new(child_cap),
178            stats: self.stats.clone(),
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_cache_creation() {
189        let cache = RouteCache::new();
190        assert_eq!(cache.parent_cache_size(), 0);
191        assert_eq!(cache.stats().parent_hits, 0);
192    }
193
194    #[test]
195    fn test_parent_cache_miss() {
196        let mut cache = RouteCache::new();
197        let result = cache.get_parent("/dashboard");
198        assert!(result.is_none());
199        assert_eq!(cache.stats().parent_misses, 1);
200    }
201
202    #[test]
203    fn test_parent_cache_hit() {
204        let mut cache = RouteCache::new();
205        let route_id = RouteId::from_path("/dashboard");
206        cache.set_parent("/dashboard/analytics".to_string(), route_id.clone());
207
208        let result = cache.get_parent("/dashboard/analytics");
209        assert!(result.is_some());
210        assert_eq!(result.unwrap().path, "/dashboard");
211        assert_eq!(cache.stats().parent_hits, 1);
212    }
213
214    #[test]
215    fn test_cache_clear() {
216        let mut cache = RouteCache::new();
217        cache.set_parent("/dashboard".to_string(), RouteId::from_path("/"));
218        assert_eq!(cache.parent_cache_size(), 1);
219
220        cache.clear();
221        assert_eq!(cache.parent_cache_size(), 0);
222        assert_eq!(cache.stats().invalidations, 1);
223    }
224
225    #[test]
226    fn test_hit_rate_calculation() {
227        let mut cache = RouteCache::new();
228        cache.get_parent("/a");
229        cache.get_parent("/b");
230        cache.get_parent("/c");
231
232        cache.set_parent("/a".to_string(), RouteId::from_path("/"));
233        cache.set_parent("/b".to_string(), RouteId::from_path("/"));
234
235        cache.get_parent("/a");
236        cache.get_parent("/b");
237
238        assert_eq!(cache.stats().parent_hits, 2);
239        assert_eq!(cache.stats().parent_misses, 3);
240        assert!((cache.stats().parent_hit_rate() - 0.4).abs() < 0.001);
241    }
242}