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 subscribe(&self) -> broadcast::Receiver<InvalidationMessage> {
85 self.sender.subscribe()
86 }
87
88 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 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 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 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 pub async fn refresh_all_snapshots(&self) -> anyhow::Result<()> {
126 self.send_snapshot_op(SnapshotOp::RefreshAll).await
127 }
128}
129
130fn matches_pattern(key: &str, pattern: &str) -> bool {
132 if key == pattern {
134 return true;
135 }
136
137 let parts: Vec<&str> = pattern.split('*').collect();
139
140 if parts.len() == 1 {
141 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 if i == 0 {
154 if !key.starts_with(part) {
155 return false;
156 }
157 current_pos = part.len();
158 }
159 else if i == parts.len() - 1 {
161 if !key[current_pos..].ends_with(part) {
162 return false;
163 }
164 }
165 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#[derive(Clone)]
178pub struct CacheStore {
179 store: Arc<RwLock<HashMap<String, StoredCachedResponse>>>,
180 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 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 pub async fn set_404(&self, key: String, response: CachedResponse) {
475 if self.cache_404_capacity == 0 {
476 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 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 pub async fn size(&self) -> usize {
585 let store = self.store.read().await;
586 store.len()
587 }
588
589 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 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 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 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 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 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 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 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(¬_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(¬_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}