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#[derive(Clone, Debug)]
18pub enum InvalidationMessage {
19 All,
21 Pattern(String),
23}
24
25pub(crate) struct SnapshotRequest {
27 pub(crate) op: SnapshotOp,
28 pub(crate) done: oneshot::Sender<()>,
29}
30
31pub(crate) enum SnapshotOp {
33 Add(String),
35 Refresh(String),
37 Remove(String),
39 RefreshAll,
41}
42
43#[derive(Clone)]
46pub struct CacheHandle {
47 sender: broadcast::Sender<InvalidationMessage>,
48 snapshot_tx: Option<mpsc::Sender<SnapshotRequest>>,
50}
51
52impl CacheHandle {
53 pub fn new() -> Self {
55 let (sender, _) = broadcast::channel(16);
56 Self {
57 sender,
58 snapshot_tx: None,
59 }
60 }
61
62 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 pub fn invalidate_all(&self) {
73 let _ = self.sender.send(InvalidationMessage::All);
74 }
75
76 pub fn invalidate(&self, pattern: &str) {
79 let _ = self
80 .sender
81 .send(InvalidationMessage::Pattern(pattern.to_string()));
82 }
83
84 pub fn is_snapshot_capable(&self) -> bool {
87 self.snapshot_tx.is_some()
88 }
89
90 pub fn subscribe(&self) -> broadcast::Receiver<InvalidationMessage> {
92 self.sender.subscribe()
93 }
94
95 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 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 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 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 pub async fn refresh_all_snapshots(&self) -> anyhow::Result<()> {
133 self.send_snapshot_op(SnapshotOp::RefreshAll).await
134 }
135}
136
137fn matches_pattern(key: &str, pattern: &str) -> bool {
139 if key == pattern {
141 return true;
142 }
143
144 let parts: Vec<&str> = pattern.split('*').collect();
146
147 if parts.len() == 1 {
148 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 if i == 0 {
161 if !key.starts_with(part) {
162 return false;
163 }
164 current_pos = part.len();
165 }
166 else if i == parts.len() - 1 {
168 if !key[current_pos..].ends_with(part) {
169 return false;
170 }
171 }
172 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#[derive(Clone)]
185pub struct CacheStore {
186 store: Arc<DashMap<String, StoredCachedResponse>>,
187 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 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 pub async fn set_404(&self, key: String, response: CachedResponse) {
473 if self.cache_404_capacity == 0 {
474 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 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 pub async fn size(&self) -> usize {
597 self.store.len()
598 }
599
600 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 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 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 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 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 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 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 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(¬_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(¬_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}