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#[derive(Clone, Debug)]
17pub enum InvalidationMessage {
18 All,
20 Pattern(String),
22}
23
24pub(crate) struct SnapshotRequest {
26 pub(crate) op: SnapshotOp,
27 pub(crate) done: oneshot::Sender<()>,
28}
29
30pub(crate) enum SnapshotOp {
32 Add(String),
34 Refresh(String),
36 Remove(String),
38 RefreshAll,
40}
41
42#[derive(Clone)]
45pub struct CacheHandle {
46 sender: broadcast::Sender<InvalidationMessage>,
47 snapshot_tx: Option<mpsc::Sender<SnapshotRequest>>,
49}
50
51impl CacheHandle {
52 pub fn new() -> Self {
54 let (sender, _) = broadcast::channel(16);
55 Self {
56 sender,
57 snapshot_tx: None,
58 }
59 }
60
61 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 pub fn invalidate_all(&self) {
72 let _ = self.sender.send(InvalidationMessage::All);
73 }
74
75 pub fn invalidate(&self, pattern: &str) {
78 let _ = self
79 .sender
80 .send(InvalidationMessage::Pattern(pattern.to_string()));
81 }
82
83 pub fn is_snapshot_capable(&self) -> bool {
86 self.snapshot_tx.is_some()
87 }
88
89 pub fn subscribe(&self) -> broadcast::Receiver<InvalidationMessage> {
91 self.sender.subscribe()
92 }
93
94 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 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 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 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 pub async fn refresh_all_snapshots(&self) -> anyhow::Result<()> {
132 self.send_snapshot_op(SnapshotOp::RefreshAll).await
133 }
134}
135
136fn matches_pattern(key: &str, pattern: &str) -> bool {
138 if key == pattern {
140 return true;
141 }
142
143 let parts: Vec<&str> = pattern.split('*').collect();
145
146 if parts.len() == 1 {
147 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 if i == 0 {
160 if !key.starts_with(part) {
161 return false;
162 }
163 current_pos = part.len();
164 }
165 else if i == parts.len() - 1 {
167 if !key[current_pos..].ends_with(part) {
168 return false;
169 }
170 }
171 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#[derive(Clone)]
184pub struct CacheStore {
185 store: Arc<RwLock<HashMap<String, StoredCachedResponse>>>,
186 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 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 pub async fn set_404(&self, key: String, response: CachedResponse) {
481 if self.cache_404_capacity == 0 {
482 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 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 pub async fn size(&self) -> usize {
591 let store = self.store.read().await;
592 store.len()
593 }
594
595 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 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 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 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 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 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 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 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(¬_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(¬_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}