1use crate::route::Route;
30use crate::{debug_log, trace_log, RouteParams};
31use lru::LruCache;
32use std::num::NonZeroUsize;
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct RouteId {
40 pub path: String,
42}
43
44impl RouteId {
45 #[must_use]
47 pub fn from_route(route: &Route) -> Self {
48 Self {
49 path: route.config.path.clone(),
50 }
51 }
52
53 pub fn from_path(path: impl Into<String>) -> Self {
55 Self { path: path.into() }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61struct OutletCacheKey {
62 path: String,
63 outlet_name: Option<String>,
64}
65
66#[derive(Debug, Clone)]
68struct ParentRouteCacheEntry {
69 parent_route_id: RouteId,
70}
71
72#[derive(Debug, Clone, Default)]
78pub struct CacheStats {
79 pub parent_hits: usize,
81 pub parent_misses: usize,
83 pub child_hits: usize,
85 pub child_misses: usize,
87 pub invalidations: usize,
89}
90
91impl CacheStats {
92 #[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 #[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 #[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#[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 #[must_use]
152 pub fn new() -> Self {
153 Self::with_capacity(Self::DEFAULT_CAPACITY)
154 }
155
156 #[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 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 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 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 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 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 #[must_use]
253 pub const fn stats(&self) -> &CacheStats {
254 &self.stats
255 }
256
257 pub fn reset_stats(&mut self) {
259 self.stats = CacheStats::default();
260 }
261
262 #[must_use]
264 pub fn parent_cache_size(&self) -> usize {
265 self.parent_cache.len()
266 }
267
268 #[must_use]
270 pub fn child_cache_size(&self) -> usize {
271 self.child_cache.len()
272 }
273
274 #[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}