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