Skip to main content

gpui_navigator/
cache.rs

1//! Route resolution caching.
2//!
3//! This module provides [`RouteCache`] — an LRU-based cache that avoids
4//! repeated route tree lookups during rendering. It is gated behind the
5//! `cache` feature flag and uses the [`lru`] crate internally.
6//!
7//! Two independent LRU caches are maintained:
8//!
9//! - **Parent cache** — maps a full request path to the [`RouteId`] of the
10//!   parent route that owns it (e.g. `"/dashboard/analytics"` → `"/dashboard"`).
11//! - **Child cache** — maps an `(path, outlet_name)` pair to resolved
12//!   [`RouteParams`].
13//!
14//! [`CacheStats`] tracks hits, misses, and invalidations so you can monitor
15//! cache effectiveness at runtime.
16//!
17//! # Examples
18//!
19//! ```
20//! use gpui_navigator::cache::{RouteCache, RouteId};
21//!
22//! let mut cache = RouteCache::new();
23//! cache.set_parent("/dashboard/analytics".to_string(), RouteId::from_path("/dashboard"));
24//!
25//! assert_eq!(cache.get_parent("/dashboard/analytics").unwrap().path, "/dashboard");
26//! assert_eq!(cache.stats().parent_hits, 1);
27//! ```
28
29use crate::route::Route;
30use crate::{debug_log, trace_log, RouteParams};
31use lru::LruCache;
32use std::num::NonZeroUsize;
33
34/// Unique identifier for a route in the tree
35///
36/// This allows us to reference routes without storing full Route clones.
37/// Routes are identified by their path hierarchy.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct RouteId {
40    /// Full path of the route (e.g., "/dashboard/analytics")
41    pub path: String,
42}
43
44impl RouteId {
45    /// Create a new route ID from a route
46    #[must_use]
47    pub fn from_route(route: &Route) -> Self {
48        Self {
49            path: route.config.path.clone(),
50        }
51    }
52
53    /// Create a route ID from a path string
54    pub fn from_path(path: impl Into<String>) -> Self {
55        Self { path: path.into() }
56    }
57}
58
59/// Cache key for outlet resolution
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61struct OutletCacheKey {
62    path: String,
63    outlet_name: Option<String>,
64}
65
66/// Cached result of finding a parent route
67#[derive(Debug, Clone)]
68struct ParentRouteCacheEntry {
69    parent_route_id: RouteId,
70}
71
72/// Counters tracking cache hit/miss rates and invalidations.
73///
74/// Use [`parent_hit_rate`](Self::parent_hit_rate),
75/// [`child_hit_rate`](Self::child_hit_rate), or
76/// [`overall_hit_rate`](Self::overall_hit_rate) for quick ratio access.
77#[derive(Debug, Clone, Default)]
78pub struct CacheStats {
79    /// Number of parent-cache hits.
80    pub parent_hits: usize,
81    /// Number of parent-cache misses.
82    pub parent_misses: usize,
83    /// Number of child-cache hits.
84    pub child_hits: usize,
85    /// Number of child-cache misses.
86    pub child_misses: usize,
87    /// Number of full cache invalidations (via [`RouteCache::clear`]).
88    pub invalidations: usize,
89}
90
91impl CacheStats {
92    /// Return the parent-cache hit rate as a value in `0.0..=1.0`.
93    ///
94    /// Returns `0.0` if no parent lookups have been performed.
95    #[allow(clippy::cast_precision_loss)]
96    #[must_use]
97    pub fn parent_hit_rate(&self) -> f64 {
98        let total = self.parent_hits + self.parent_misses;
99        if total == 0 {
100            0.0
101        } else {
102            self.parent_hits as f64 / total as f64
103        }
104    }
105
106    /// Return the child-cache hit rate as a value in `0.0..=1.0`.
107    #[allow(clippy::cast_precision_loss)]
108    #[must_use]
109    pub fn child_hit_rate(&self) -> f64 {
110        let total = self.child_hits + self.child_misses;
111        if total == 0 {
112            0.0
113        } else {
114            self.child_hits as f64 / total as f64
115        }
116    }
117
118    /// Return the combined (parent + child) hit rate as a value in `0.0..=1.0`.
119    #[allow(clippy::cast_precision_loss)]
120    #[must_use]
121    pub fn overall_hit_rate(&self) -> f64 {
122        let total_hits = self.parent_hits + self.child_hits;
123        let total_misses = self.parent_misses + self.child_misses;
124        let total = total_hits + total_misses;
125        if total == 0 {
126            0.0
127        } else {
128            total_hits as f64 / total as f64
129        }
130    }
131}
132
133/// LRU cache for route resolution results.
134///
135/// Maintains separate parent and child caches, each with independent LRU
136/// eviction. Default capacity is 1000 entries per cache.
137///
138/// The cache is automatically cleared on route registration and navigation
139/// to ensure consistency.
140#[derive(Debug)]
141pub struct RouteCache {
142    parent_cache: LruCache<String, ParentRouteCacheEntry>,
143    child_cache: LruCache<OutletCacheKey, RouteParams>,
144    stats: CacheStats,
145}
146
147impl RouteCache {
148    const DEFAULT_CAPACITY: usize = 1000;
149
150    /// Create a cache with the default capacity (1000 entries per sub-cache).
151    #[must_use]
152    pub fn new() -> Self {
153        Self::with_capacity(Self::DEFAULT_CAPACITY)
154    }
155
156    /// Create a cache with a custom per-sub-cache capacity.
157    ///
158    /// # Panics
159    ///
160    /// Panics if `capacity` is zero.
161    #[must_use]
162    pub fn with_capacity(capacity: usize) -> Self {
163        let cap = NonZeroUsize::new(capacity).expect("Cache capacity must be non-zero");
164        Self {
165            parent_cache: LruCache::new(cap),
166            child_cache: LruCache::new(cap),
167            stats: CacheStats::default(),
168        }
169    }
170
171    /// Clear both sub-caches and increment the invalidation counter.
172    pub fn clear(&mut self) {
173        let parent_len = self.parent_cache.len();
174        let child_len = self.child_cache.len();
175        self.parent_cache.clear();
176        self.child_cache.clear();
177        self.stats.invalidations += 1;
178        debug_log!(
179            "Cache cleared: {} parent + {} child entries removed ({} total invalidations, parent hit rate: {:.1}%)",
180            parent_len,
181            child_len,
182            self.stats.invalidations,
183            self.stats.parent_hit_rate() * 100.0
184        );
185    }
186
187    /// Look up the cached parent [`RouteId`] for the given `path`.
188    ///
189    /// Returns `None` on a cache miss. Updates hit/miss stats.
190    pub fn get_parent(&mut self, path: &str) -> Option<RouteId> {
191        if let Some(entry) = self.parent_cache.get(path) {
192            self.stats.parent_hits += 1;
193            trace_log!("Parent cache hit for path: '{}'", path);
194            Some(entry.parent_route_id.clone())
195        } else {
196            self.stats.parent_misses += 1;
197            trace_log!("Parent cache miss for path: '{}'", path);
198            None
199        }
200    }
201
202    /// Insert a parent route mapping into the cache.
203    pub fn set_parent(&mut self, path: String, parent_route_id: RouteId) {
204        trace_log!(
205            "Caching parent route '{}' for path '{}'",
206            parent_route_id.path,
207            path
208        );
209        self.parent_cache
210            .push(path, ParentRouteCacheEntry { parent_route_id });
211    }
212
213    /// Look up the cached child [`RouteParams`] for the given path and outlet name.
214    ///
215    /// Returns `None` on a cache miss. Updates hit/miss stats.
216    pub fn get_child(&mut self, path: &str, outlet_name: Option<&str>) -> Option<RouteParams> {
217        let key = OutletCacheKey {
218            path: path.to_string(),
219            outlet_name: outlet_name.map(String::from),
220        };
221        if let Some(params) = self.child_cache.get(&key) {
222            self.stats.child_hits += 1;
223            trace_log!(
224                "Child cache hit for path: '{}', outlet: {:?}",
225                path,
226                outlet_name
227            );
228            Some(params.clone())
229        } else {
230            self.stats.child_misses += 1;
231            trace_log!(
232                "Child cache miss for path: '{}', outlet: {:?}",
233                path,
234                outlet_name
235            );
236            None
237        }
238    }
239
240    /// Insert a child route params mapping into the cache.
241    pub fn set_child(&mut self, path: String, outlet_name: Option<String>, params: RouteParams) {
242        trace_log!(
243            "Caching child params for path '{}', outlet: {:?}",
244            path,
245            outlet_name
246        );
247        self.child_cache
248            .push(OutletCacheKey { path, outlet_name }, params);
249    }
250
251    /// Return a reference to the current cache statistics.
252    #[must_use]
253    pub const fn stats(&self) -> &CacheStats {
254        &self.stats
255    }
256
257    /// Reset all counters in [`CacheStats`] to zero.
258    pub fn reset_stats(&mut self) {
259        self.stats = CacheStats::default();
260    }
261
262    /// Return the number of entries currently in the parent cache.
263    #[must_use]
264    pub fn parent_cache_size(&self) -> usize {
265        self.parent_cache.len()
266    }
267
268    /// Return the number of entries currently in the child cache.
269    #[must_use]
270    pub fn child_cache_size(&self) -> usize {
271        self.child_cache.len()
272    }
273
274    /// Return the total number of entries across both sub-caches.
275    #[must_use]
276    pub fn total_size(&self) -> usize {
277        self.parent_cache_size() + self.child_cache_size()
278    }
279}
280
281impl Default for RouteCache {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl Clone for RouteCache {
288    fn clone(&self) -> Self {
289        let parent_cap = self.parent_cache.cap();
290        let child_cap = self.child_cache.cap();
291        Self {
292            parent_cache: LruCache::new(parent_cap),
293            child_cache: LruCache::new(child_cap),
294            stats: self.stats.clone(),
295        }
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_cache_creation() {
305        let cache = RouteCache::new();
306        assert_eq!(cache.parent_cache_size(), 0);
307        assert_eq!(cache.stats().parent_hits, 0);
308    }
309
310    #[test]
311    fn test_parent_cache_miss() {
312        let mut cache = RouteCache::new();
313        let result = cache.get_parent("/dashboard");
314        assert!(result.is_none());
315        assert_eq!(cache.stats().parent_misses, 1);
316    }
317
318    #[test]
319    fn test_parent_cache_hit() {
320        let mut cache = RouteCache::new();
321        let route_id = RouteId::from_path("/dashboard");
322        cache.set_parent("/dashboard/analytics".to_string(), route_id);
323
324        let result = cache.get_parent("/dashboard/analytics");
325        assert!(result.is_some());
326        assert_eq!(result.unwrap().path, "/dashboard");
327        assert_eq!(cache.stats().parent_hits, 1);
328    }
329
330    #[test]
331    fn test_cache_clear() {
332        let mut cache = RouteCache::new();
333        cache.set_parent("/dashboard".to_string(), RouteId::from_path("/"));
334        assert_eq!(cache.parent_cache_size(), 1);
335
336        cache.clear();
337        assert_eq!(cache.parent_cache_size(), 0);
338        assert_eq!(cache.stats().invalidations, 1);
339    }
340
341    #[test]
342    fn test_hit_rate_calculation() {
343        let mut cache = RouteCache::new();
344        cache.get_parent("/a");
345        cache.get_parent("/b");
346        cache.get_parent("/c");
347
348        cache.set_parent("/a".to_string(), RouteId::from_path("/"));
349        cache.set_parent("/b".to_string(), RouteId::from_path("/"));
350
351        cache.get_parent("/a");
352        cache.get_parent("/b");
353
354        assert_eq!(cache.stats().parent_hits, 2);
355        assert_eq!(cache.stats().parent_misses, 3);
356        assert!((cache.stats().parent_hit_rate() - 0.4).abs() < 0.001);
357    }
358
359    #[test]
360    fn test_child_cache_miss() {
361        let mut cache = RouteCache::new();
362        let result = cache.get_child("/dashboard", Some("sidebar"));
363        assert!(result.is_none());
364        assert_eq!(cache.stats().child_misses, 1);
365    }
366
367    #[test]
368    fn test_child_cache_hit() {
369        let mut cache = RouteCache::new();
370        let mut params = RouteParams::default();
371        params.set("id", "42");
372        cache.set_child(
373            "/dashboard".to_string(),
374            Some("sidebar".to_string()),
375            params,
376        );
377
378        let result = cache.get_child("/dashboard", Some("sidebar"));
379        assert!(result.is_some());
380        assert_eq!(result.unwrap().get("id").map(String::as_str), Some("42"));
381        assert_eq!(cache.stats().child_hits, 1);
382    }
383
384    #[test]
385    fn test_child_cache_different_outlets() {
386        let mut cache = RouteCache::new();
387        let mut params1 = RouteParams::default();
388        params1.set("view", "list");
389        let mut params2 = RouteParams::default();
390        params2.set("view", "detail");
391
392        cache.set_child("/app".to_string(), Some("main".to_string()), params1);
393        cache.set_child("/app".to_string(), Some("sidebar".to_string()), params2);
394
395        let r1 = cache.get_child("/app", Some("main")).unwrap();
396        assert_eq!(r1.get("view").map(String::as_str), Some("list"));
397
398        let r2 = cache.get_child("/app", Some("sidebar")).unwrap();
399        assert_eq!(r2.get("view").map(String::as_str), Some("detail"));
400
401        assert_eq!(cache.stats().child_hits, 2);
402    }
403
404    #[test]
405    fn test_child_cache_none_outlet() {
406        let mut cache = RouteCache::new();
407        let params = RouteParams::default();
408        cache.set_child("/page".to_string(), None, params);
409
410        assert!(cache.get_child("/page", None).is_some());
411        assert!(cache.get_child("/page", Some("named")).is_none());
412        assert_eq!(cache.stats().child_hits, 1);
413        assert_eq!(cache.stats().child_misses, 1);
414    }
415}