1use once_cell::sync::{Lazy, OnceCell};
20use parking_lot::RwLock;
21use pingora_cache::eviction::simple_lru::Manager as LruEvictionManager;
22use pingora_cache::eviction::EvictionManager;
23use pingora_cache::lock::CacheLock;
24use pingora_cache::storage::Storage;
25use pingora_cache::MemCache;
26use regex::Regex;
27use std::collections::HashMap;
28use std::sync::Arc;
29use std::time::{Duration, Instant};
30use tracing::{debug, error, info, trace, warn};
31
32use crate::disk_cache::DiskCacheStorage;
33use crate::hybrid_cache::HybridCacheStorage;
34use grapsus_config::{CacheBackend, CacheStorageConfig};
35
36const DEFAULT_CACHE_SIZE_BYTES: usize = 100 * 1024 * 1024;
42
43const DEFAULT_EVICTION_LIMIT_BYTES: usize = 100 * 1024 * 1024;
45
46const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 10;
48
49static CACHE_CONFIG: OnceCell<CacheStorageConfig> = OnceCell::new();
54
55pub fn configure_cache(config: CacheStorageConfig) -> bool {
73 match CACHE_CONFIG.set(config) {
74 Ok(()) => {
75 info!("Cache storage configured");
76 true
77 }
78 Err(_) => {
79 warn!("Cache already initialized, configuration ignored");
80 false
81 }
82 }
83}
84
85fn get_cache_config() -> &'static CacheStorageConfig {
87 CACHE_CONFIG.get_or_init(CacheStorageConfig::default)
88}
89
90pub fn is_cache_enabled() -> bool {
92 get_cache_config().enabled
93}
94
95static HTTP_CACHE_STORAGE: Lazy<&'static (dyn Storage + Sync)> = Lazy::new(|| {
104 let config = get_cache_config();
105 info!(
106 cache_size_mb = config.max_size_bytes / 1024 / 1024,
107 backend = ?config.backend,
108 "Initializing HTTP cache storage"
109 );
110 match config.backend {
111 CacheBackend::Memory => Box::leak(Box::new(MemCache::new())),
112 CacheBackend::Disk => {
113 let path = config
114 .disk_path
115 .as_ref()
116 .expect("disk-path is required for disk backend (validated by config parser)");
117 Box::leak(Box::new(DiskCacheStorage::new(
118 path,
119 config.disk_shards,
120 config.max_size_bytes,
121 )))
122 }
123 CacheBackend::Hybrid => {
124 let path = config
125 .disk_path
126 .as_ref()
127 .expect("disk-path is required for hybrid backend (validated by config parser)");
128 let disk_max = config.disk_max_size_bytes.unwrap_or(config.max_size_bytes);
129 let memory: &'static MemCache = Box::leak(Box::new(MemCache::new()));
130 let disk: &'static DiskCacheStorage = Box::leak(Box::new(DiskCacheStorage::new(
131 path,
132 config.disk_shards,
133 disk_max,
134 )));
135 Box::leak(Box::new(HybridCacheStorage::new(memory, disk)))
136 }
137 }
138});
139
140static HTTP_CACHE_EVICTION: Lazy<LruEvictionManager> = Lazy::new(|| {
142 let config = get_cache_config();
143 let limit = config.eviction_limit_bytes.unwrap_or(config.max_size_bytes);
144 info!(
145 eviction_limit_mb = limit / 1024 / 1024,
146 "Initializing HTTP cache eviction manager"
147 );
148 LruEvictionManager::new(limit)
149});
150
151static HTTP_CACHE_LOCK: Lazy<CacheLock> = Lazy::new(|| {
153 let config = get_cache_config();
154 info!(
155 lock_timeout_secs = config.lock_timeout_secs,
156 "Initializing HTTP cache lock"
157 );
158 CacheLock::new(Duration::from_secs(config.lock_timeout_secs))
159});
160
161pub fn get_cache_storage() -> &'static (dyn Storage + Sync) {
165 *HTTP_CACHE_STORAGE
166}
167
168pub fn get_cache_eviction() -> &'static LruEvictionManager {
170 &HTTP_CACHE_EVICTION
171}
172
173pub fn get_cache_lock() -> &'static CacheLock {
175 &HTTP_CACHE_LOCK
176}
177
178pub async fn init_disk_cache_state() {
183 let config = get_cache_config();
184 if matches!(config.backend, CacheBackend::Disk | CacheBackend::Hybrid) {
185 let path = config.disk_path.as_ref().unwrap();
186 let eviction = get_cache_eviction();
187
188 let eviction_dir = path.join("eviction");
190 if eviction_dir.exists() {
191 if let Err(e) = eviction
192 .load(eviction_dir.to_str().unwrap_or_default())
193 .await
194 {
195 warn!(error = %e, "Failed to load saved eviction state, rebuilding from disk");
196 } else {
197 info!("Loaded saved eviction state");
198 }
199 }
200
201 crate::disk_cache::rebuild_eviction_state(path, config.disk_shards, eviction).await;
203 }
204}
205
206pub async fn save_disk_cache_state() {
210 let config = get_cache_config();
211 if matches!(config.backend, CacheBackend::Disk | CacheBackend::Hybrid) {
212 let eviction_path = config.disk_path.as_ref().unwrap().join("eviction");
213 if let Err(e) = std::fs::create_dir_all(&eviction_path) {
214 error!(error = %e, "Failed to create eviction state directory");
215 return;
216 }
217 if let Err(e) = get_cache_eviction()
218 .save(eviction_path.to_str().unwrap_or_default())
219 .await
220 {
221 error!(error = %e, "Failed to save eviction state");
222 } else {
223 info!("Saved disk cache eviction state");
224 }
225 }
226}
227
228#[derive(Debug, Clone)]
230pub struct CacheConfig {
231 pub enabled: bool,
233 pub default_ttl_secs: u64,
235 pub max_size_bytes: usize,
237 pub cache_private: bool,
239 pub stale_while_revalidate_secs: u64,
241 pub stale_if_error_secs: u64,
243 pub cacheable_methods: Vec<String>,
245 pub cacheable_status_codes: Vec<u16>,
247 pub exclude_extensions: Vec<String>,
249 pub exclude_paths: Vec<Regex>,
251}
252
253impl Default for CacheConfig {
254 fn default() -> Self {
255 Self {
256 enabled: false, default_ttl_secs: 3600,
258 max_size_bytes: 10 * 1024 * 1024, cache_private: false,
260 stale_while_revalidate_secs: 60,
261 stale_if_error_secs: 300,
262 cacheable_methods: vec!["GET".to_string(), "HEAD".to_string()],
263 cacheable_status_codes: vec![200, 203, 204, 206, 300, 301, 308, 404, 410],
264 exclude_extensions: Vec::new(),
265 exclude_paths: Vec::new(),
266 }
267 }
268}
269
270#[derive(Debug, Default)]
272pub struct HttpCacheStats {
273 hits: std::sync::atomic::AtomicU64,
274 misses: std::sync::atomic::AtomicU64,
275 stores: std::sync::atomic::AtomicU64,
276 evictions: std::sync::atomic::AtomicU64,
277 memory_hits: std::sync::atomic::AtomicU64,
278 disk_hits: std::sync::atomic::AtomicU64,
279}
280
281impl HttpCacheStats {
282 pub fn record_hit(&self) {
284 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
285 }
286
287 pub fn record_miss(&self) {
289 self.misses
290 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
291 }
292
293 pub fn record_store(&self) {
295 self.stores
296 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
297 }
298
299 pub fn record_eviction(&self) {
301 self.evictions
302 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
303 }
304
305 pub fn hits(&self) -> u64 {
307 self.hits.load(std::sync::atomic::Ordering::Relaxed)
308 }
309
310 pub fn misses(&self) -> u64 {
312 self.misses.load(std::sync::atomic::Ordering::Relaxed)
313 }
314
315 pub fn hit_ratio(&self) -> f64 {
317 let hits = self.hits() as f64;
318 let total = hits + self.misses() as f64;
319 if total == 0.0 {
320 0.0
321 } else {
322 hits / total
323 }
324 }
325
326 pub fn stores(&self) -> u64 {
328 self.stores.load(std::sync::atomic::Ordering::Relaxed)
329 }
330
331 pub fn evictions(&self) -> u64 {
333 self.evictions.load(std::sync::atomic::Ordering::Relaxed)
334 }
335
336 pub fn record_memory_hit(&self) {
338 self.memory_hits
339 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
340 }
341
342 pub fn record_disk_hit(&self) {
344 self.disk_hits
345 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
346 }
347
348 pub fn memory_hits(&self) -> u64 {
350 self.memory_hits.load(std::sync::atomic::Ordering::Relaxed)
351 }
352
353 pub fn disk_hits(&self) -> u64 {
355 self.disk_hits.load(std::sync::atomic::Ordering::Relaxed)
356 }
357}
358
359#[derive(Debug, Clone)]
361struct PurgeEntry {
362 created_at: Instant,
364 pattern: Option<String>,
366}
367
368const PURGE_ENTRY_LIFETIME: Duration = Duration::from_secs(60);
370
371pub struct CacheManager {
376 route_configs: RwLock<HashMap<String, CacheConfig>>,
378 stats: Arc<HttpCacheStats>,
380 purged_keys: RwLock<HashMap<String, Instant>>,
382 purge_patterns: RwLock<Vec<PurgeEntry>>,
384 compiled_patterns: RwLock<Vec<(Regex, Instant)>>,
386}
387
388impl CacheManager {
389 pub fn new() -> Self {
391 Self {
392 route_configs: RwLock::new(HashMap::new()),
393 stats: Arc::new(HttpCacheStats::default()),
394 purged_keys: RwLock::new(HashMap::new()),
395 purge_patterns: RwLock::new(Vec::new()),
396 compiled_patterns: RwLock::new(Vec::new()),
397 }
398 }
399
400 pub fn stats(&self) -> Arc<HttpCacheStats> {
402 self.stats.clone()
403 }
404
405 pub fn register_route(&self, route_id: &str, config: CacheConfig) {
407 trace!(
408 route_id = route_id,
409 enabled = config.enabled,
410 default_ttl = config.default_ttl_secs,
411 "Registering cache configuration for route"
412 );
413 self.route_configs
414 .write()
415 .insert(route_id.to_string(), config);
416 }
417
418 pub fn get_route_config(&self, route_id: &str) -> Option<CacheConfig> {
420 self.route_configs.read().get(route_id).cloned()
421 }
422
423 pub fn is_enabled(&self, route_id: &str) -> bool {
425 self.route_configs
426 .read()
427 .get(route_id)
428 .map(|c| c.enabled)
429 .unwrap_or(false)
430 }
431
432 pub fn generate_cache_key(method: &str, host: &str, path: &str, query: Option<&str>) -> String {
434 match query {
435 Some(q) => format!("{}:{}:{}?{}", method, host, path, q),
436 None => format!("{}:{}:{}", method, host, path),
437 }
438 }
439
440 pub fn is_method_cacheable(&self, route_id: &str, method: &str) -> bool {
442 self.route_configs
443 .read()
444 .get(route_id)
445 .map(|c| {
446 c.cacheable_methods
447 .iter()
448 .any(|m| m.eq_ignore_ascii_case(method))
449 })
450 .unwrap_or(false)
451 }
452
453 pub fn is_path_cacheable(&self, route_id: &str, path: &str) -> bool {
458 let configs = self.route_configs.read();
459 let config = match configs.get(route_id) {
460 Some(c) => c,
461 None => return true,
462 };
463
464 if !config.exclude_extensions.is_empty() {
466 if let Some(ext) = path.rsplit('.').next() {
467 if path.contains('.') {
469 let ext_lower = ext.to_lowercase();
470 if config
471 .exclude_extensions
472 .iter()
473 .any(|e| e.eq_ignore_ascii_case(&ext_lower))
474 {
475 trace!(
476 route_id = route_id,
477 path = path,
478 extension = %ext_lower,
479 "Path excluded from cache by extension"
480 );
481 return false;
482 }
483 }
484 }
485 }
486
487 for regex in &config.exclude_paths {
489 if regex.is_match(path) {
490 trace!(
491 route_id = route_id,
492 path = path,
493 pattern = %regex.as_str(),
494 "Path excluded from cache by pattern"
495 );
496 return false;
497 }
498 }
499
500 true
501 }
502
503 pub fn is_status_cacheable(&self, route_id: &str, status: u16) -> bool {
505 self.route_configs
506 .read()
507 .get(route_id)
508 .map(|c| c.cacheable_status_codes.contains(&status))
509 .unwrap_or(false)
510 }
511
512 pub fn parse_max_age(header_value: &str) -> Option<u64> {
514 for directive in header_value.split(',') {
516 let directive = directive.trim();
517 if let Some(value) = directive.strip_prefix("max-age=") {
518 if let Ok(secs) = value.trim().parse::<u64>() {
519 return Some(secs);
520 }
521 }
522 if let Some(value) = directive.strip_prefix("s-maxage=") {
523 if let Ok(secs) = value.trim().parse::<u64>() {
524 return Some(secs);
525 }
526 }
527 }
528 None
529 }
530
531 pub fn is_no_cache(header_value: &str) -> bool {
533 let lower = header_value.to_lowercase();
534 lower.contains("no-store") || lower.contains("no-cache") || lower.contains("private")
535 }
536
537 pub fn calculate_ttl(&self, route_id: &str, cache_control: Option<&str>) -> Duration {
539 let config = self.get_route_config(route_id).unwrap_or_default();
540
541 if let Some(cc) = cache_control {
542 if Self::is_no_cache(cc) && !config.cache_private {
544 return Duration::ZERO;
545 }
546
547 if let Some(max_age) = Self::parse_max_age(cc) {
549 return Duration::from_secs(max_age);
550 }
551 }
552
553 Duration::from_secs(config.default_ttl_secs)
555 }
556
557 pub fn should_serve_stale(
559 &self,
560 route_id: &str,
561 stale_duration: Duration,
562 is_error: bool,
563 ) -> bool {
564 let config = self.get_route_config(route_id).unwrap_or_default();
565
566 if is_error {
567 stale_duration.as_secs() <= config.stale_if_error_secs
568 } else {
569 stale_duration.as_secs() <= config.stale_while_revalidate_secs
570 }
571 }
572
573 pub fn route_count(&self) -> usize {
575 self.route_configs.read().len()
576 }
577
578 pub fn purge(&self, path: &str) -> usize {
588 let keys_to_purge: Vec<String> =
591 vec![format!("GET:*:{}", path), format!("HEAD:*:{}", path)];
592
593 let now = Instant::now();
594 let mut purged = self.purged_keys.write();
595
596 for key in &keys_to_purge {
597 purged.insert(key.clone(), now);
598 }
599
600 purged.insert(path.to_string(), now);
602
603 debug!(
604 path = %path,
605 purged_keys = keys_to_purge.len() + 1,
606 "Purged cache entry"
607 );
608
609 self.stats.record_eviction();
610 1
611 }
612
613 pub fn purge_wildcard(&self, pattern: &str) -> usize {
622 let regex_pattern = glob_to_regex(pattern);
624
625 match Regex::new(®ex_pattern) {
626 Ok(regex) => {
627 let now = Instant::now();
628
629 self.compiled_patterns.write().push((regex, now));
631
632 self.purge_patterns.write().push(PurgeEntry {
634 created_at: now,
635 pattern: Some(pattern.to_string()),
636 });
637
638 debug!(
639 pattern = %pattern,
640 regex = %regex_pattern,
641 "Registered wildcard cache purge"
642 );
643
644 self.stats.record_eviction();
645 1
646 }
647 Err(e) => {
648 warn!(
649 pattern = %pattern,
650 error = %e,
651 "Failed to compile purge pattern as regex"
652 );
653 0
654 }
655 }
656 }
657
658 pub fn should_invalidate(&self, cache_key: &str) -> bool {
663 self.cleanup_expired_purges();
665
666 {
668 let purged = self.purged_keys.read();
669 if purged.contains_key(cache_key) {
670 trace!(cache_key = %cache_key, "Cache key matches purged key");
671 return true;
672 }
673
674 if let Some(path) = extract_path_from_cache_key(cache_key) {
677 if purged.contains_key(path) {
678 trace!(cache_key = %cache_key, path = %path, "Cache path matches purged path");
679 return true;
680 }
681 }
682 }
683
684 {
686 let patterns = self.compiled_patterns.read();
687 let path = extract_path_from_cache_key(cache_key).unwrap_or(cache_key);
688
689 for (regex, _) in patterns.iter() {
690 if regex.is_match(path) {
691 trace!(
692 cache_key = %cache_key,
693 path = %path,
694 pattern = %regex.as_str(),
695 "Cache key matches purge pattern"
696 );
697 return true;
698 }
699 }
700 }
701
702 false
703 }
704
705 fn cleanup_expired_purges(&self) {
707 let now = Instant::now();
708
709 {
711 let mut purged = self.purged_keys.write();
712 purged.retain(|_, created_at| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
713 }
714
715 {
717 let mut patterns = self.purge_patterns.write();
718 patterns.retain(|entry| now.duration_since(entry.created_at) < PURGE_ENTRY_LIFETIME);
719 }
720
721 {
723 let mut compiled = self.compiled_patterns.write();
724 compiled
725 .retain(|(_, created_at)| now.duration_since(*created_at) < PURGE_ENTRY_LIFETIME);
726 }
727 }
728
729 pub fn active_purge_count(&self) -> usize {
731 self.purged_keys.read().len() + self.purge_patterns.read().len()
732 }
733
734 #[cfg(test)]
736 pub fn clear_purges(&self) {
737 self.purged_keys.write().clear();
738 self.purge_patterns.write().clear();
739 self.compiled_patterns.write().clear();
740 }
741}
742
743pub fn compile_glob_to_regex(pattern: &str) -> String {
752 glob_to_regex(pattern)
753}
754
755fn glob_to_regex(pattern: &str) -> String {
757 let mut regex = String::with_capacity(pattern.len() * 2);
758 regex.push('^');
759
760 let chars: Vec<char> = pattern.chars().collect();
761 let mut i = 0;
762
763 while i < chars.len() {
764 let c = chars[i];
765 match c {
766 '*' => {
767 if i + 1 < chars.len() && chars[i + 1] == '*' {
769 regex.push_str(".*");
770 i += 2;
771 } else {
772 regex.push_str("[^/]*");
774 i += 1;
775 }
776 }
777 '?' => {
778 regex.push('.');
779 i += 1;
780 }
781 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
783 regex.push('\\');
784 regex.push(c);
785 i += 1;
786 }
787 _ => {
788 regex.push(c);
789 i += 1;
790 }
791 }
792 }
793
794 regex.push('$');
795 regex
796}
797
798fn extract_path_from_cache_key(cache_key: &str) -> Option<&str> {
802 let mut colon_count = 0;
804 for (i, c) in cache_key.char_indices() {
805 if c == ':' {
806 colon_count += 1;
807 if colon_count == 2 {
808 return Some(&cache_key[i + 1..]);
810 }
811 }
812 }
813 None
814}
815
816impl Default for CacheManager {
817 fn default() -> Self {
818 Self::new()
819 }
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 #[test]
827 fn test_cache_key_generation() {
828 let key = CacheManager::generate_cache_key("GET", "example.com", "/api/users", None);
829 assert_eq!(key, "GET:example.com:/api/users");
830
831 let key_with_query = CacheManager::generate_cache_key(
832 "GET",
833 "example.com",
834 "/api/users",
835 Some("page=1&limit=10"),
836 );
837 assert_eq!(key_with_query, "GET:example.com:/api/users?page=1&limit=10");
838 }
839
840 #[test]
841 fn test_cache_config_defaults() {
842 let config = CacheConfig::default();
843 assert!(!config.enabled);
844 assert_eq!(config.default_ttl_secs, 3600);
845 assert!(config.cacheable_methods.contains(&"GET".to_string()));
846 assert!(config.cacheable_status_codes.contains(&200));
847 }
848
849 #[test]
850 fn test_route_config_registration() {
851 let manager = CacheManager::new();
852
853 manager.register_route(
854 "api",
855 CacheConfig {
856 enabled: true,
857 default_ttl_secs: 300,
858 ..Default::default()
859 },
860 );
861
862 assert!(manager.is_enabled("api"));
863 assert!(!manager.is_enabled("unknown"));
864 }
865
866 #[test]
867 fn test_method_cacheability() {
868 let manager = CacheManager::new();
869
870 manager.register_route(
871 "api",
872 CacheConfig {
873 enabled: true,
874 cacheable_methods: vec!["GET".to_string(), "HEAD".to_string()],
875 ..Default::default()
876 },
877 );
878
879 assert!(manager.is_method_cacheable("api", "GET"));
880 assert!(manager.is_method_cacheable("api", "get"));
881 assert!(!manager.is_method_cacheable("api", "POST"));
882 }
883
884 #[test]
885 fn test_parse_max_age() {
886 assert_eq!(CacheManager::parse_max_age("max-age=3600"), Some(3600));
887 assert_eq!(
888 CacheManager::parse_max_age("public, max-age=300"),
889 Some(300)
890 );
891 assert_eq!(
892 CacheManager::parse_max_age("s-maxage=600, max-age=300"),
893 Some(600)
894 );
895 assert_eq!(CacheManager::parse_max_age("no-store"), None);
896 }
897
898 #[test]
899 fn test_is_no_cache() {
900 assert!(CacheManager::is_no_cache("no-store"));
901 assert!(CacheManager::is_no_cache("no-cache"));
902 assert!(CacheManager::is_no_cache("private"));
903 assert!(CacheManager::is_no_cache("private, max-age=300"));
904 assert!(!CacheManager::is_no_cache("public, max-age=3600"));
905 }
906
907 #[test]
908 fn test_cache_stats() {
909 let stats = HttpCacheStats::default();
910
911 stats.record_hit();
912 stats.record_hit();
913 stats.record_miss();
914
915 assert_eq!(stats.hits(), 2);
916 assert_eq!(stats.misses(), 1);
917 assert!((stats.hit_ratio() - 0.666).abs() < 0.01);
918 }
919
920 #[test]
921 fn test_calculate_ttl() {
922 let manager = CacheManager::new();
923 manager.register_route(
924 "api",
925 CacheConfig {
926 enabled: true,
927 default_ttl_secs: 600,
928 ..Default::default()
929 },
930 );
931
932 let ttl = manager.calculate_ttl("api", Some("max-age=3600"));
934 assert_eq!(ttl.as_secs(), 3600);
935
936 let ttl = manager.calculate_ttl("api", None);
938 assert_eq!(ttl.as_secs(), 600);
939
940 let ttl = manager.calculate_ttl("api", Some("no-store"));
942 assert_eq!(ttl.as_secs(), 0);
943 }
944
945 #[test]
950 fn test_purge_single_entry() {
951 let manager = CacheManager::new();
952
953 let count = manager.purge("/api/users/123");
955 assert_eq!(count, 1);
956
957 assert!(manager.active_purge_count() > 0);
959
960 let cache_key =
962 CacheManager::generate_cache_key("GET", "example.com", "/api/users/123", None);
963 assert!(manager.should_invalidate(&cache_key));
964
965 let other_key =
967 CacheManager::generate_cache_key("GET", "example.com", "/api/users/456", None);
968 assert!(!manager.should_invalidate(&other_key));
969
970 manager.clear_purges();
972 }
973
974 #[test]
975 fn test_purge_wildcard_pattern() {
976 let manager = CacheManager::new();
977
978 let count = manager.purge_wildcard("/api/users/*");
980 assert_eq!(count, 1);
981
982 assert!(manager.should_invalidate("/api/users/123"));
984 assert!(manager.should_invalidate("/api/users/456"));
985 assert!(manager.should_invalidate("/api/users/abc"));
986
987 assert!(!manager.should_invalidate("/api/posts/123"));
989 assert!(!manager.should_invalidate("/api/users")); manager.clear_purges();
992 }
993
994 #[test]
995 fn test_purge_double_wildcard() {
996 let manager = CacheManager::new();
997
998 let count = manager.purge_wildcard("/api/**");
1000 assert_eq!(count, 1);
1001
1002 assert!(manager.should_invalidate("/api/users/123"));
1004 assert!(manager.should_invalidate("/api/posts/456/comments"));
1005 assert!(manager.should_invalidate("/api/deep/nested/path"));
1006
1007 assert!(!manager.should_invalidate("/other/path"));
1009
1010 manager.clear_purges();
1011 }
1012
1013 #[test]
1014 fn test_glob_to_regex() {
1015 let regex = glob_to_regex("/api/users/*");
1017 assert_eq!(regex, "^/api/users/[^/]*$");
1018
1019 let regex = glob_to_regex("/api/**");
1021 assert_eq!(regex, "^/api/.*$");
1022
1023 let regex = glob_to_regex("/api/user?");
1025 assert_eq!(regex, "^/api/user.$");
1026
1027 let regex = glob_to_regex("/api/v1.0/users");
1029 assert_eq!(regex, "^/api/v1\\.0/users$");
1030 }
1031
1032 #[test]
1033 fn test_extract_path_from_cache_key() {
1034 let path = extract_path_from_cache_key("GET:example.com:/api/users");
1036 assert_eq!(path, Some("/api/users"));
1037
1038 let path = extract_path_from_cache_key("GET:example.com:/api/users?page=1");
1040 assert_eq!(path, Some("/api/users?page=1"));
1041
1042 let path = extract_path_from_cache_key("invalid");
1044 assert_eq!(path, None);
1045 }
1046
1047 #[test]
1048 fn test_purge_eviction_stats() {
1049 let manager = CacheManager::new();
1050
1051 let initial_evictions = manager.stats().evictions();
1052
1053 manager.purge("/path1");
1055 manager.purge("/path2");
1056 manager.purge_wildcard("/pattern/*");
1057
1058 assert_eq!(manager.stats().evictions(), initial_evictions + 3);
1059
1060 manager.clear_purges();
1061 }
1062}