Skip to main content

phantom_frame/
cache.rs

1use dashmap::DashMap;
2use std::collections::{hash_map::DefaultHasher, HashMap, VecDeque};
3use std::hash::{Hash, Hasher};
4use std::path::PathBuf;
5use std::process;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8
9use tokio::sync::{broadcast, mpsc, oneshot, RwLock};
10
11use crate::compression::ContentEncoding;
12pub use crate::CacheStorageMode;
13
14static BODY_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
15
16/// Messages sent via the broadcast channel to invalidate cache entries.
17#[derive(Clone, Debug)]
18pub enum InvalidationMessage {
19    /// Invalidate all cache entries.
20    All,
21    /// Invalidate cache entries whose key matches a pattern (supports wildcards).
22    Pattern(String),
23}
24
25/// An operation sent to the snapshot worker for runtime SSG management.
26pub(crate) struct SnapshotRequest {
27    pub(crate) op: SnapshotOp,
28    pub(crate) done: oneshot::Sender<()>,
29}
30
31/// The kind of snapshot operation to perform.
32pub(crate) enum SnapshotOp {
33    /// Fetch `path` from upstream, store in the cache, and track it as a snapshot.
34    Add(String),
35    /// Re-fetch `path` from upstream and overwrite its cache entry.
36    Refresh(String),
37    /// Remove `path` from the cache and from the tracked snapshot list.
38    Remove(String),
39    /// Re-fetch every currently tracked snapshot path.
40    RefreshAll,
41}
42
43/// A cloneable handle for cache management — invalidating entries and (in
44/// PreGenerate mode) managing the list of pre-generated SSG snapshots at runtime.
45#[derive(Clone)]
46pub struct CacheHandle {
47    sender: broadcast::Sender<InvalidationMessage>,
48    /// Present only when the proxy is in `ProxyMode::PreGenerate`.
49    snapshot_tx: Option<mpsc::Sender<SnapshotRequest>>,
50}
51
52impl CacheHandle {
53    /// Create a new handle without snapshot support (Dynamic mode or tests).
54    pub fn new() -> Self {
55        let (sender, _) = broadcast::channel(16);
56        Self {
57            sender,
58            snapshot_tx: None,
59        }
60    }
61
62    /// Create a new handle wired to a snapshot worker (PreGenerate mode).
63    pub(crate) fn new_with_snapshots(snapshot_tx: mpsc::Sender<SnapshotRequest>) -> Self {
64        let (sender, _) = broadcast::channel(16);
65        Self {
66            sender,
67            snapshot_tx: Some(snapshot_tx),
68        }
69    }
70
71    /// Invalidate all cache entries.
72    pub fn invalidate_all(&self) {
73        let _ = self.sender.send(InvalidationMessage::All);
74    }
75
76    /// Invalidate cache entries whose key matches `pattern`.
77    /// Supports wildcards: `"/api/*"`, `"GET:/api/*"`, etc.
78    pub fn invalidate(&self, pattern: &str) {
79        let _ = self
80            .sender
81            .send(InvalidationMessage::Pattern(pattern.to_string()));
82    }
83
84    /// Returns `true` when this handle is connected to a snapshot worker
85    /// (i.e. the server is in `ProxyMode::PreGenerate`).
86    pub fn is_snapshot_capable(&self) -> bool {
87        self.snapshot_tx.is_some()
88    }
89
90    /// Subscribe to invalidation events.
91    pub fn subscribe(&self) -> broadcast::Receiver<InvalidationMessage> {
92        self.sender.subscribe()
93    }
94
95    /// Send an operation to the snapshot worker and await completion.
96    async fn send_snapshot_op(&self, op: SnapshotOp) -> anyhow::Result<()> {
97        let tx = self.snapshot_tx.as_ref().ok_or_else(|| {
98            anyhow::anyhow!("Snapshot operations are only available in PreGenerate proxy mode")
99        })?;
100        let (done_tx, done_rx) = oneshot::channel();
101        tx.send(SnapshotRequest { op, done: done_tx })
102            .await
103            .map_err(|_| anyhow::anyhow!("Snapshot worker is not running"))?;
104        done_rx
105            .await
106            .map_err(|_| anyhow::anyhow!("Snapshot worker dropped the completion signal"))
107    }
108
109    /// Fetch `path` from the upstream server, store it in the cache, and add it
110    /// to the tracked snapshot list. Only available in PreGenerate mode.
111    pub async fn add_snapshot(&self, path: &str) -> anyhow::Result<()> {
112        self.send_snapshot_op(SnapshotOp::Add(path.to_string()))
113            .await
114    }
115
116    /// Re-fetch `path` from the upstream server and update its cached entry.
117    /// Only available in PreGenerate mode.
118    pub async fn refresh_snapshot(&self, path: &str) -> anyhow::Result<()> {
119        self.send_snapshot_op(SnapshotOp::Refresh(path.to_string()))
120            .await
121    }
122
123    /// Remove `path` from the cache and from the tracked snapshot list.
124    /// Only available in PreGenerate mode.
125    pub async fn remove_snapshot(&self, path: &str) -> anyhow::Result<()> {
126        self.send_snapshot_op(SnapshotOp::Remove(path.to_string()))
127            .await
128    }
129
130    /// Re-fetch every currently tracked snapshot path from the upstream server.
131    /// Only available in PreGenerate mode.
132    pub async fn refresh_all_snapshots(&self) -> anyhow::Result<()> {
133        self.send_snapshot_op(SnapshotOp::RefreshAll).await
134    }
135}
136
137/// Helper function to check if a key matches a pattern with wildcard support
138fn matches_pattern(key: &str, pattern: &str) -> bool {
139    // Handle exact match
140    if key == pattern {
141        return true;
142    }
143
144    // Split pattern by '*' and check if all parts exist in order
145    let parts: Vec<&str> = pattern.split('*').collect();
146
147    if parts.len() == 1 {
148        // No wildcard, exact match already checked above
149        return false;
150    }
151
152    let mut current_pos = 0;
153
154    for (i, part) in parts.iter().enumerate() {
155        if part.is_empty() {
156            continue;
157        }
158
159        // First part must match from the beginning
160        if i == 0 {
161            if !key.starts_with(part) {
162                return false;
163            }
164            current_pos = part.len();
165        }
166        // Last part must match to the end
167        else if i == parts.len() - 1 {
168            if !key[current_pos..].ends_with(part) {
169                return false;
170            }
171        }
172        // Middle parts must exist in order
173        else if let Some(pos) = key[current_pos..].find(part) {
174            current_pos += pos + part.len();
175        } else {
176            return false;
177        }
178    }
179
180    true
181}
182
183/// Cache storage for prerendered content
184#[derive(Clone)]
185pub struct CacheStore {
186    store: Arc<DashMap<String, StoredCachedResponse>>,
187    // 404-specific store with bounded capacity and FIFO eviction
188    store_404: Arc<DashMap<String, StoredCachedResponse>>,
189    keys_404: Arc<RwLock<VecDeque<String>>>,
190    cache_404_capacity: usize,
191    handle: CacheHandle,
192    body_store: CacheBodyStore,
193}
194
195#[derive(Clone, Debug)]
196pub struct CachedResponse {
197    pub body: Vec<u8>,
198    pub headers: HashMap<String, String>,
199    pub status: u16,
200    pub content_encoding: Option<ContentEncoding>,
201}
202
203#[derive(Clone, Debug)]
204struct StoredCachedResponse {
205    body: StoredBody,
206    headers: HashMap<String, String>,
207    status: u16,
208    content_encoding: Option<ContentEncoding>,
209}
210
211#[derive(Clone, Debug)]
212enum StoredBody {
213    Memory(Vec<u8>),
214    File(PathBuf),
215}
216
217#[derive(Clone, Copy, Debug)]
218enum CacheBucket {
219    Standard,
220    NotFound,
221}
222
223impl CacheBucket {
224    fn directory_name(self) -> &'static str {
225        match self {
226            Self::Standard => "responses",
227            Self::NotFound => "responses-404",
228        }
229    }
230}
231
232#[derive(Clone, Debug)]
233struct CacheBodyStore {
234    mode: CacheStorageMode,
235    root_dir: Option<PathBuf>,
236}
237
238impl CacheBodyStore {
239    fn new(mode: CacheStorageMode, root_dir: Option<PathBuf>) -> Self {
240        let root_dir = match mode {
241            CacheStorageMode::Memory => None,
242            CacheStorageMode::Filesystem => {
243                let root_dir = root_dir.unwrap_or_else(default_cache_directory);
244                cleanup_orphaned_cache_files(&root_dir);
245                Some(root_dir)
246            }
247        };
248
249        Self { mode, root_dir }
250    }
251
252    async fn store(&self, key: &str, body: Vec<u8>, bucket: CacheBucket) -> StoredBody {
253        match self.mode {
254            CacheStorageMode::Memory => StoredBody::Memory(body),
255            CacheStorageMode::Filesystem => match self.write_body(key, &body, bucket).await {
256                Ok(path) => StoredBody::File(path),
257                Err(error) => {
258                    tracing::warn!(
259                        "Failed to persist cache body for '{}' to filesystem storage: {}",
260                        key,
261                        error
262                    );
263                    StoredBody::Memory(body)
264                }
265            },
266        }
267    }
268
269    async fn load(&self, body: &StoredBody) -> Option<Vec<u8>> {
270        match body {
271            StoredBody::Memory(bytes) => Some(bytes.clone()),
272            StoredBody::File(path) => match tokio::fs::read(path).await {
273                Ok(bytes) => Some(bytes),
274                Err(error) => {
275                    tracing::warn!(
276                        "Failed to read cached response body from '{}': {}",
277                        path.display(),
278                        error
279                    );
280                    None
281                }
282            },
283        }
284    }
285
286    async fn remove(&self, body: StoredBody) {
287        if let StoredBody::File(path) = body {
288            if let Err(error) = tokio::fs::remove_file(&path).await {
289                if error.kind() != std::io::ErrorKind::NotFound {
290                    tracing::warn!(
291                        "Failed to delete cached response body '{}': {}",
292                        path.display(),
293                        error
294                    );
295                }
296            }
297        }
298    }
299
300    async fn write_body(
301        &self,
302        key: &str,
303        body: &[u8],
304        bucket: CacheBucket,
305    ) -> std::io::Result<PathBuf> {
306        let root_dir = self
307            .root_dir
308            .as_ref()
309            .expect("filesystem cache storage requires a root directory");
310        let bucket_dir = root_dir.join(bucket.directory_name());
311        tokio::fs::create_dir_all(&bucket_dir).await?;
312
313        let stem = cache_file_stem(key);
314        let tmp_path = bucket_dir.join(format!("{}.tmp", stem));
315        let final_path = bucket_dir.join(format!("{}.bin", stem));
316
317        tokio::fs::write(&tmp_path, body).await?;
318        tokio::fs::rename(&tmp_path, &final_path).await?;
319
320        Ok(final_path)
321    }
322}
323
324impl StoredCachedResponse {
325    async fn materialize(self, body_store: &CacheBodyStore) -> Option<CachedResponse> {
326        let body = body_store.load(&self.body).await?;
327
328        Some(CachedResponse {
329            body,
330            headers: self.headers,
331            status: self.status,
332            content_encoding: self.content_encoding,
333        })
334    }
335}
336
337fn default_cache_directory() -> PathBuf {
338    std::env::temp_dir().join("phantom-frame-cache")
339}
340
341fn cleanup_orphaned_cache_files(root_dir: &std::path::Path) {
342    for bucket in [CacheBucket::Standard, CacheBucket::NotFound] {
343        let bucket_dir = root_dir.join(bucket.directory_name());
344        cleanup_bucket_directory(&bucket_dir);
345    }
346}
347
348fn cleanup_bucket_directory(bucket_dir: &std::path::Path) {
349    let entries = match std::fs::read_dir(bucket_dir) {
350        Ok(entries) => entries,
351        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
352        Err(error) => {
353            tracing::warn!(
354                "Failed to inspect cache directory '{}' during startup cleanup: {}",
355                bucket_dir.display(),
356                error
357            );
358            return;
359        }
360    };
361
362    for entry in entries {
363        let entry = match entry {
364            Ok(entry) => entry,
365            Err(error) => {
366                tracing::warn!(
367                    "Failed to enumerate cache directory '{}' during startup cleanup: {}",
368                    bucket_dir.display(),
369                    error
370                );
371                continue;
372            }
373        };
374
375        let path = entry.path();
376        let file_type = match entry.file_type() {
377            Ok(file_type) => file_type,
378            Err(error) => {
379                tracing::warn!(
380                    "Failed to inspect cache entry '{}' during startup cleanup: {}",
381                    path.display(),
382                    error
383                );
384                continue;
385            }
386        };
387
388        let cleanup_result = if file_type.is_dir() {
389            std::fs::remove_dir_all(&path)
390        } else {
391            std::fs::remove_file(&path)
392        };
393
394        if let Err(error) = cleanup_result {
395            tracing::warn!(
396                "Failed to remove orphaned cache entry '{}' during startup cleanup: {}",
397                path.display(),
398                error
399            );
400        }
401    }
402}
403
404fn cache_file_stem(key: &str) -> String {
405    let mut hasher = DefaultHasher::new();
406    key.hash(&mut hasher);
407
408    let hash = hasher.finish();
409    let counter = BODY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
410
411    format!("{:016x}-{:x}-{:016x}", hash, process::id(), counter)
412}
413
414fn into_stored_response(body: StoredBody, response: CachedResponse) -> StoredCachedResponse {
415    StoredCachedResponse {
416        body,
417        headers: response.headers,
418        status: response.status,
419        content_encoding: response.content_encoding,
420    }
421}
422
423impl CacheStore {
424    pub fn new(handle: CacheHandle, cache_404_capacity: usize) -> Self {
425        Self::with_storage(handle, cache_404_capacity, CacheStorageMode::Memory, None)
426    }
427
428    pub fn with_storage(
429        handle: CacheHandle,
430        cache_404_capacity: usize,
431        storage_mode: CacheStorageMode,
432        cache_directory: Option<PathBuf>,
433    ) -> Self {
434        Self {
435            store: Arc::new(DashMap::new()),
436            store_404: Arc::new(DashMap::new()),
437            keys_404: Arc::new(RwLock::new(VecDeque::new())),
438            cache_404_capacity,
439            handle,
440            body_store: CacheBodyStore::new(storage_mode, cache_directory),
441        }
442    }
443
444    pub async fn get(&self, key: &str) -> Option<CachedResponse> {
445        let cached = self.store.get(key).map(|entry| entry.clone())?;
446
447        cached.materialize(&self.body_store).await
448    }
449
450    /// Get a 404 cached response (if present)
451    pub async fn get_404(&self, key: &str) -> Option<CachedResponse> {
452        let cached = self.store_404.get(key).map(|entry| entry.clone())?;
453
454        cached.materialize(&self.body_store).await
455    }
456
457    pub async fn set(&self, key: String, response: CachedResponse) {
458        let body = self
459            .body_store
460            .store(&key, response.body.clone(), CacheBucket::Standard)
461            .await;
462        let stored = into_stored_response(body, response);
463
464        let replaced = self.store.insert(key, stored);
465
466        if let Some(old) = replaced {
467            self.body_store.remove(old.body).await;
468        }
469    }
470
471    /// Set a 404 cached response. Bounded by `cache_404_capacity` and evict the oldest entries when limit reached.
472    pub async fn set_404(&self, key: String, response: CachedResponse) {
473        if self.cache_404_capacity == 0 {
474            // 404 caching disabled
475            return;
476        }
477
478        let body = self
479            .body_store
480            .store(&key, response.body.clone(), CacheBucket::NotFound)
481            .await;
482        let stored = into_stored_response(body, response);
483
484        let removed_bodies = {
485            let mut keys = self.keys_404.write().await;
486            let mut removed = Vec::new();
487
488            if self.store_404.contains_key(&key) {
489                if let Some(pos) = keys.iter().position(|existing_key| existing_key == &key) {
490                    keys.remove(pos);
491                }
492            }
493
494            if let Some(old) = self.store_404.insert(key.clone(), stored) {
495                removed.push(old.body);
496            }
497            keys.push_back(key);
498
499            while keys.len() > self.cache_404_capacity {
500                if let Some(old_key) = keys.pop_front() {
501                    if let Some((_, old)) = self.store_404.remove(&old_key) {
502                        removed.push(old.body);
503                    }
504                }
505            }
506
507            removed
508        };
509
510        for body in removed_bodies {
511            self.body_store.remove(body).await;
512        }
513    }
514
515    pub async fn clear(&self) {
516        let standard_keys: Vec<String> =
517            self.store.iter().map(|entry| entry.key().clone()).collect();
518        let not_found_keys: Vec<String> = self
519            .store_404
520            .iter()
521            .map(|entry| entry.key().clone())
522            .collect();
523
524        let removed_bodies = {
525            let mut removed = Vec::new();
526
527            for key in standard_keys {
528                if let Some((_, response)) = self.store.remove(&key) {
529                    removed.push(response.body);
530                }
531            }
532
533            for key in not_found_keys {
534                if let Some((_, response)) = self.store_404.remove(&key) {
535                    removed.push(response.body);
536                }
537            }
538
539            let mut keys = self.keys_404.write().await;
540            keys.clear();
541
542            removed
543        };
544
545        for body in removed_bodies {
546            self.body_store.remove(body).await;
547        }
548    }
549
550    /// Clear cache entries matching a pattern (supports wildcards)
551    pub async fn clear_by_pattern(&self, pattern: &str) {
552        let keys_to_remove: Vec<String> = self
553            .store
554            .iter()
555            .filter(|entry| matches_pattern(entry.key(), pattern))
556            .map(|entry| entry.key().clone())
557            .collect();
558        let keys_to_remove_404: Vec<String> = self
559            .store_404
560            .iter()
561            .filter(|entry| matches_pattern(entry.key(), pattern))
562            .map(|entry| entry.key().clone())
563            .collect();
564
565        let removed_bodies = {
566            let mut removed = Vec::new();
567
568            for key in keys_to_remove {
569                if let Some((_, old)) = self.store.remove(&key) {
570                    removed.push(old.body);
571                }
572            }
573
574            for key in &keys_to_remove_404 {
575                if let Some((_, old)) = self.store_404.remove(key) {
576                    removed.push(old.body);
577                }
578            }
579
580            let mut keys = self.keys_404.write().await;
581            keys.retain(|key| !matches_pattern(key, pattern));
582
583            removed
584        };
585
586        for body in removed_bodies {
587            self.body_store.remove(body).await;
588        }
589    }
590
591    pub fn handle(&self) -> &CacheHandle {
592        &self.handle
593    }
594
595    /// Get the number of cached items
596    pub async fn size(&self) -> usize {
597        self.store.len()
598    }
599
600    /// Size of 404 cache
601    pub async fn size_404(&self) -> usize {
602        self.store_404.len()
603    }
604}
605
606impl Default for CacheHandle {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn unique_test_directory(name: &str) -> PathBuf {
617        std::env::temp_dir().join(format!(
618            "phantom-frame-test-{}-{:x}-{:016x}",
619            name,
620            process::id(),
621            BODY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed)
622        ))
623    }
624
625    #[test]
626    fn test_matches_pattern_exact() {
627        assert!(matches_pattern("GET:/api/users", "GET:/api/users"));
628        assert!(!matches_pattern("GET:/api/users", "GET:/api/posts"));
629    }
630
631    #[test]
632    fn test_matches_pattern_wildcard() {
633        // Wildcard at end
634        assert!(matches_pattern("GET:/api/users", "GET:/api/*"));
635        assert!(matches_pattern("GET:/api/users/123", "GET:/api/*"));
636        assert!(!matches_pattern("GET:/v2/users", "GET:/api/*"));
637
638        // Wildcard at start
639        assert!(matches_pattern("GET:/api/users", "*/users"));
640        assert!(matches_pattern("POST:/v2/users", "*/users"));
641        assert!(!matches_pattern("GET:/api/posts", "*/users"));
642
643        // Wildcard in middle
644        assert!(matches_pattern("GET:/api/v1/users", "GET:/api/*/users"));
645        assert!(matches_pattern("GET:/api/v2/users", "GET:/api/*/users"));
646        assert!(!matches_pattern("GET:/api/v1/posts", "GET:/api/*/users"));
647
648        // Multiple wildcards
649        assert!(matches_pattern("GET:/api/v1/users/123", "GET:*/users/*"));
650        assert!(matches_pattern("POST:/v2/admin/users/456", "*/users/*"));
651    }
652
653    #[test]
654    fn test_matches_pattern_wildcard_only() {
655        assert!(matches_pattern("GET:/api/users", "*"));
656        assert!(matches_pattern("POST:/anything", "*"));
657    }
658
659    #[tokio::test]
660    async fn test_404_cache_set_get_and_eviction() {
661        let trigger = CacheHandle::new();
662        // capacity 2 for quicker eviction
663        let store = CacheStore::new(trigger, 2);
664
665        let resp1 = CachedResponse {
666            body: vec![1],
667            headers: HashMap::new(),
668            status: 404,
669            content_encoding: None,
670        };
671        let resp2 = CachedResponse {
672            body: vec![2],
673            headers: HashMap::new(),
674            status: 404,
675            content_encoding: None,
676        };
677        let resp3 = CachedResponse {
678            body: vec![3],
679            headers: HashMap::new(),
680            status: 404,
681            content_encoding: None,
682        };
683
684        // Set two 404 entries
685        store
686            .set_404("GET:/notfound1".to_string(), resp1.clone())
687            .await;
688        store
689            .set_404("GET:/notfound2".to_string(), resp2.clone())
690            .await;
691
692        assert_eq!(store.size_404().await, 2);
693        assert_eq!(store.get_404("GET:/notfound1").await.unwrap().body, vec![1]);
694
695        // Add third entry - should evict oldest (notfound1)
696        store
697            .set_404("GET:/notfound3".to_string(), resp3.clone())
698            .await;
699        assert_eq!(store.size_404().await, 2);
700        assert!(store.get_404("GET:/notfound1").await.is_none());
701        assert_eq!(store.get_404("GET:/notfound2").await.unwrap().body, vec![2]);
702        assert_eq!(store.get_404("GET:/notfound3").await.unwrap().body, vec![3]);
703    }
704
705    #[tokio::test]
706    async fn test_clear_by_pattern_removes_404_entries() {
707        let trigger = CacheHandle::new();
708        let store = CacheStore::new(trigger, 10);
709
710        let resp = CachedResponse {
711            body: vec![1],
712            headers: HashMap::new(),
713            status: 404,
714            content_encoding: None,
715        };
716        store
717            .set_404("GET:/api/notfound".to_string(), resp.clone())
718            .await;
719        store
720            .set_404("GET:/api/another".to_string(), resp.clone())
721            .await;
722        assert_eq!(store.size_404().await, 2);
723
724        store.clear_by_pattern("GET:/api/*").await;
725        assert_eq!(store.size_404().await, 0);
726    }
727
728    #[tokio::test]
729    async fn test_filesystem_cache_round_trip() {
730        let cache_dir = unique_test_directory("round-trip");
731        let trigger = CacheHandle::new();
732        let store =
733            CacheStore::with_storage(trigger, 10, CacheStorageMode::Filesystem, Some(cache_dir));
734
735        let response = CachedResponse {
736            body: vec![1, 2, 3, 4],
737            headers: HashMap::from([("content-type".to_string(), "text/plain".to_string())]),
738            status: 200,
739            content_encoding: None,
740        };
741
742        store
743            .set("GET:/asset.js".to_string(), response.clone())
744            .await;
745
746        let stored_path = {
747            match &store.store.get("GET:/asset.js").unwrap().body {
748                StoredBody::File(path) => path.clone(),
749                StoredBody::Memory(_) => panic!("expected filesystem-backed cache body"),
750            }
751        };
752
753        assert!(tokio::fs::metadata(&stored_path).await.is_ok());
754
755        let cached = store.get("GET:/asset.js").await.unwrap();
756        assert_eq!(cached.body, response.body);
757
758        store.clear().await;
759        assert!(tokio::fs::metadata(&stored_path).await.is_err());
760    }
761
762    #[tokio::test]
763    async fn test_filesystem_404_eviction_removes_body_file() {
764        let cache_dir = unique_test_directory("eviction");
765        let trigger = CacheHandle::new();
766        let store =
767            CacheStore::with_storage(trigger, 2, CacheStorageMode::Filesystem, Some(cache_dir));
768
769        for index in 1..=2 {
770            store
771                .set_404(
772                    format!("GET:/missing{}", index),
773                    CachedResponse {
774                        body: vec![index as u8],
775                        headers: HashMap::new(),
776                        status: 404,
777                        content_encoding: None,
778                    },
779                )
780                .await;
781        }
782
783        let evicted_path = {
784            match &store.store_404.get("GET:/missing1").unwrap().body {
785                StoredBody::File(path) => path.clone(),
786                StoredBody::Memory(_) => panic!("expected filesystem-backed cache body"),
787            }
788        };
789
790        store
791            .set_404(
792                "GET:/missing3".to_string(),
793                CachedResponse {
794                    body: vec![3],
795                    headers: HashMap::new(),
796                    status: 404,
797                    content_encoding: None,
798                },
799            )
800            .await;
801
802        assert!(store.get_404("GET:/missing1").await.is_none());
803        assert!(tokio::fs::metadata(&evicted_path).await.is_err());
804    }
805
806    #[tokio::test]
807    async fn test_filesystem_clear_by_pattern_removes_matching_files() {
808        let cache_dir = unique_test_directory("pattern-clear");
809        let trigger = CacheHandle::new();
810        let store =
811            CacheStore::with_storage(trigger, 10, CacheStorageMode::Filesystem, Some(cache_dir));
812
813        store
814            .set(
815                "GET:/api/one".to_string(),
816                CachedResponse {
817                    body: vec![1],
818                    headers: HashMap::new(),
819                    status: 200,
820                    content_encoding: None,
821                },
822            )
823            .await;
824        store
825            .set(
826                "GET:/other/two".to_string(),
827                CachedResponse {
828                    body: vec![2],
829                    headers: HashMap::new(),
830                    status: 200,
831                    content_encoding: None,
832                },
833            )
834            .await;
835
836        let (removed_path, kept_path) = {
837            let removed = match &store.store.get("GET:/api/one").unwrap().body {
838                StoredBody::File(path) => path.clone(),
839                StoredBody::Memory(_) => panic!("expected filesystem-backed cache body"),
840            };
841            let kept = match &store.store.get("GET:/other/two").unwrap().body {
842                StoredBody::File(path) => path.clone(),
843                StoredBody::Memory(_) => panic!("expected filesystem-backed cache body"),
844            };
845            (removed, kept)
846        };
847
848        store.clear_by_pattern("GET:/api/*").await;
849
850        assert!(store.get("GET:/api/one").await.is_none());
851        assert!(store.get("GET:/other/two").await.is_some());
852        assert!(tokio::fs::metadata(&removed_path).await.is_err());
853        assert!(tokio::fs::metadata(&kept_path).await.is_ok());
854
855        store.clear().await;
856    }
857
858    #[test]
859    fn test_filesystem_startup_cleanup_removes_orphaned_cache_files() {
860        let cache_dir = unique_test_directory("startup-cleanup");
861        let standard_dir = cache_dir.join(CacheBucket::Standard.directory_name());
862        let not_found_dir = cache_dir.join(CacheBucket::NotFound.directory_name());
863        let unrelated_file = cache_dir.join("keep.txt");
864
865        std::fs::create_dir_all(&standard_dir).unwrap();
866        std::fs::create_dir_all(&not_found_dir).unwrap();
867        std::fs::write(standard_dir.join("stale.bin"), b"stale").unwrap();
868        std::fs::write(standard_dir.join("stale.tmp"), b"stale tmp").unwrap();
869        std::fs::write(not_found_dir.join("stale.bin"), b"stale 404").unwrap();
870        std::fs::write(&unrelated_file, b"keep me").unwrap();
871
872        let trigger = CacheHandle::new();
873        let _store = CacheStore::with_storage(
874            trigger,
875            10,
876            CacheStorageMode::Filesystem,
877            Some(cache_dir.clone()),
878        );
879
880        let standard_entries = std::fs::read_dir(&standard_dir)
881            .unwrap()
882            .collect::<Result<Vec<_>, _>>()
883            .unwrap();
884        let not_found_entries = std::fs::read_dir(&not_found_dir)
885            .unwrap()
886            .collect::<Result<Vec<_>, _>>()
887            .unwrap();
888
889        assert!(standard_entries.is_empty());
890        assert!(not_found_entries.is_empty());
891        assert_eq!(std::fs::read(&unrelated_file).unwrap(), b"keep me");
892
893        std::fs::remove_dir_all(&cache_dir).unwrap();
894    }
895}