Skip to main content

phantom_frame/
cache.rs

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