Skip to main content

aptu_core/
cache.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! TTL-based file caching for GitHub API responses.
4//!
5//! Stores issue and repository data as JSON files with embedded metadata
6//! (timestamp, optional etag). Cache entries are validated against TTL settings
7//! from configuration.
8
9// `async_yields_async` is suppressed because the FileCache trait uses async fn (RPITIT,
10// stable in Rust 1.95 / edition 2024). The trait is intentionally crate-internal
11// (not part of the public API) and is never used as `dyn FileCache`, so the lint
12// warning is a false positive. There is no plan to expose this trait publicly.
13
14use std::path::PathBuf;
15use std::sync::OnceLock;
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Duration, Utc};
19use serde::{Deserialize, Serialize};
20#[cfg(test)]
21use tracing::debug;
22use tracing::warn;
23
24/// Ensures the cache unavailable warning is only emitted once.
25static CACHE_UNAVAILABLE_WARNING: OnceLock<()> = OnceLock::new();
26
27/// Default TTL for issue cache entries (in minutes).
28pub const DEFAULT_ISSUE_TTL_MINS: i64 = 60;
29
30/// Default TTL for repository cache entries (in hours).
31pub const DEFAULT_REPO_TTL_HOURS: i64 = 24;
32
33/// Default TTL for model registry cache entries (in seconds).
34pub const DEFAULT_MODEL_TTL_SECS: u64 = 86400;
35
36/// Default TTL for security finding cache entries (in days).
37pub const DEFAULT_SECURITY_TTL_DAYS: i64 = 7;
38
39/// A cached entry with metadata.
40///
41/// Wraps cached data with timestamp and optional etag for validation.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub(crate) struct CacheEntry<T> {
44    /// The cached data.
45    pub data: T,
46    /// When the entry was cached.
47    pub cached_at: DateTime<Utc>,
48    /// Optional `ETag` for future conditional requests.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub etag: Option<String>,
51}
52
53impl<T> CacheEntry<T> {
54    /// Create a new cache entry.
55    pub fn new(data: T) -> Self {
56        Self {
57            data,
58            cached_at: Utc::now(),
59            etag: None,
60        }
61    }
62
63    /// Create a new cache entry with an etag.
64    #[cfg(test)]
65    pub fn with_etag(data: T, etag: String) -> Self {
66        Self {
67            data,
68            cached_at: Utc::now(),
69            etag: Some(etag),
70        }
71    }
72
73    /// Check if this entry is still valid based on TTL.
74    ///
75    /// # Arguments
76    ///
77    /// * `ttl` - Time-to-live duration
78    ///
79    /// # Returns
80    ///
81    /// `true` if the entry is within its TTL, `false` if expired.
82    pub fn is_valid(&self, ttl: Duration) -> bool {
83        let now = Utc::now();
84        now.signed_duration_since(self.cached_at) < ttl
85    }
86}
87
88/// Returns the cache directory.
89///
90/// - Linux: `~/.cache/aptu`
91/// - macOS: `~/Library/Caches/aptu`
92/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
93///
94/// Returns `None` if the cache directory cannot be determined.
95#[cfg(not(target_arch = "wasm32"))]
96#[must_use]
97pub fn cache_dir() -> Option<PathBuf> {
98    dirs::cache_dir().map(|dir| dir.join("aptu"))
99}
100
101/// Trait for TTL-based filesystem caching.
102///
103/// Provides a unified interface for caching serializable data with time-to-live validation.
104///
105/// `async_fn_in_trait` is suppressed because this trait is re-exported for use by crate
106/// consumers but is never intended to be implemented externally or used as `dyn FileCache`.
107/// All known implementors are in this crate, so auto-trait bounds are not a concern.
108#[allow(async_fn_in_trait)]
109pub(crate) trait FileCache<V> {
110    /// Get a cached value if it exists and is valid.
111    ///
112    /// # Arguments
113    ///
114    /// * `key` - Cache key (filename without extension)
115    ///
116    /// # Returns
117    ///
118    /// The cached value if it exists and is within TTL, `None` otherwise.
119    async fn get(&self, key: &str) -> Result<Option<V>>;
120
121    /// Get a cached value regardless of TTL (stale fallback).
122    ///
123    /// # Arguments
124    ///
125    /// * `key` - Cache key (filename without extension)
126    ///
127    /// # Returns
128    ///
129    /// The cached value if it exists, `None` otherwise.
130    async fn get_stale(&self, key: &str) -> Result<Option<V>>;
131
132    /// Set a cached value.
133    ///
134    /// # Arguments
135    ///
136    /// * `key` - Cache key (filename without extension)
137    /// * `value` - Value to cache
138    async fn set(&self, key: &str, value: &V) -> Result<()>;
139
140    /// Remove a cached value.
141    ///
142    /// # Arguments
143    ///
144    /// * `key` - Cache key (filename without extension)
145    #[cfg(test)]
146    async fn remove(&self, key: &str) -> Result<()>;
147}
148
149/// File-based cache implementation with TTL support.
150///
151/// Stores serialized data in JSON files with embedded metadata.
152/// When cache directory is unavailable (None), all operations become no-ops.
153#[cfg(not(target_arch = "wasm32"))]
154pub(crate) struct FileCacheImpl<V> {
155    cache_dir: Option<PathBuf>,
156    ttl: Duration,
157    subdirectory: String,
158    _phantom: std::marker::PhantomData<V>,
159}
160
161#[cfg(not(target_arch = "wasm32"))]
162impl<V> FileCacheImpl<V>
163where
164    V: Serialize + for<'de> Deserialize<'de>,
165{
166    /// Create a new file cache with default cache directory.
167    ///
168    /// # Arguments
169    ///
170    /// * `subdirectory` - Subdirectory within cache directory
171    /// * `ttl` - Time-to-live for cache entries
172    ///
173    /// If the cache directory cannot be determined, caching is disabled
174    /// and a warning is emitted.
175    #[must_use]
176    pub fn new(subdirectory: impl Into<String>, ttl: Duration) -> Self {
177        let cache_dir = cache_dir();
178        if cache_dir.is_none() {
179            CACHE_UNAVAILABLE_WARNING.get_or_init(|| {
180                warn!("Cache directory unavailable, caching disabled");
181            });
182        }
183        Self::with_dir(cache_dir, subdirectory, ttl)
184    }
185
186    /// Create a new file cache with custom cache directory.
187    ///
188    /// # Arguments
189    ///
190    /// * `cache_dir` - Base cache directory (None to disable caching)
191    /// * `subdirectory` - Subdirectory within cache directory
192    /// * `ttl` - Time-to-live for cache entries
193    #[must_use]
194    pub fn with_dir(
195        cache_dir: Option<PathBuf>,
196        subdirectory: impl Into<String>,
197        ttl: Duration,
198    ) -> Self {
199        Self {
200            cache_dir,
201            ttl,
202            subdirectory: subdirectory.into(),
203            _phantom: std::marker::PhantomData,
204        }
205    }
206
207    /// Check if caching is enabled.
208    fn is_enabled(&self) -> bool {
209        self.cache_dir.is_some()
210    }
211
212    /// Get the full path for a cache key.
213    ///
214    /// Returns `None` if the key contains path separators or parent directory
215    /// references, which could lead to path traversal vulnerabilities.
216    fn cache_path(&self, key: &str) -> Option<PathBuf> {
217        // Validate key to prevent path traversal; return None for invalid keys.
218        if key.contains('/') || key.contains('\\') || key.contains("..") {
219            return None;
220        }
221
222        let filename = if std::path::Path::new(key)
223            .extension()
224            .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
225        {
226            key.to_string()
227        } else {
228            format!("{key}.json")
229        };
230        self.cache_dir
231            .as_ref()
232            .map(|dir| dir.join(&self.subdirectory).join(filename))
233    }
234
235    /// Evict cache files older than the specified TTL.
236    ///
237    /// Scans the cache subdirectory and removes files with `cached_at` timestamps
238    /// older than `eviction_days`. Returns the count of files removed.
239    ///
240    /// # Arguments
241    ///
242    /// * `eviction_days` - Number of days to retain files
243    ///
244    /// # Returns
245    ///
246    /// The number of files evicted.
247    #[cfg(test)]
248    pub async fn evict_stale(&self, eviction_days: i64) -> usize {
249        if !self.is_enabled() {
250            return 0;
251        }
252
253        let Some(cache_dir) = &self.cache_dir else {
254            return 0;
255        };
256
257        let subdir = cache_dir.join(&self.subdirectory);
258
259        // Check if subdirectory exists
260        if !tokio::fs::try_exists(&subdir).await.unwrap_or(false) {
261            return 0;
262        }
263
264        let Ok(mut read_dir) = tokio::fs::read_dir(&subdir).await else {
265            return 0;
266        };
267
268        let mut evicted_count = 0;
269        let cutoff_time = Utc::now() - Duration::days(eviction_days);
270
271        while let Ok(Some(entry)) = read_dir.next_entry().await {
272            let path = entry.path();
273
274            // Only process .json files
275            if !path
276                .extension()
277                .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
278            {
279                continue;
280            }
281
282            let Ok(contents) = tokio::fs::read_to_string(&path).await else {
283                continue;
284            };
285
286            let Ok(entry_data) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&contents)
287            else {
288                continue;
289            };
290
291            if entry_data.cached_at < cutoff_time && tokio::fs::remove_file(&path).await.is_ok() {
292                debug!("Evicted stale cache file: {}", path.display());
293                evicted_count += 1;
294            }
295        }
296
297        evicted_count
298    }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302impl<V> FileCache<V> for FileCacheImpl<V>
303where
304    V: Serialize + for<'de> Deserialize<'de>,
305{
306    async fn get(&self, key: &str) -> Result<Option<V>> {
307        if !self.is_enabled() {
308            return Ok(None);
309        }
310
311        let Some(path) = self.cache_path(key) else {
312            return Ok(None);
313        };
314
315        if !tokio::fs::try_exists(&path)
316            .await
317            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
318        {
319            return Ok(None);
320        }
321
322        let contents = tokio::fs::read_to_string(&path)
323            .await
324            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
325
326        let entry: CacheEntry<V> = serde_json::from_str(&contents)
327            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
328
329        if entry.is_valid(self.ttl) {
330            Ok(Some(entry.data))
331        } else {
332            Ok(None)
333        }
334    }
335
336    async fn get_stale(&self, key: &str) -> Result<Option<V>> {
337        if !self.is_enabled() {
338            return Ok(None);
339        }
340
341        let Some(path) = self.cache_path(key) else {
342            return Ok(None);
343        };
344
345        if !tokio::fs::try_exists(&path)
346            .await
347            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
348        {
349            return Ok(None);
350        }
351
352        let contents = tokio::fs::read_to_string(&path)
353            .await
354            .with_context(|| format!("Failed to read cache file: {}", path.display()))?;
355
356        let entry: CacheEntry<V> = serde_json::from_str(&contents)
357            .with_context(|| format!("Failed to parse cache file: {}", path.display()))?;
358
359        Ok(Some(entry.data))
360    }
361
362    async fn set(&self, key: &str, value: &V) -> Result<()> {
363        if !self.is_enabled() {
364            return Ok(());
365        }
366
367        let Some(path) = self.cache_path(key) else {
368            return Ok(());
369        };
370
371        // Create parent directories if needed
372        if let Some(parent) = path.parent() {
373            tokio::fs::create_dir_all(parent).await.with_context(|| {
374                format!("Failed to create cache directory: {}", parent.display())
375            })?;
376        }
377
378        let entry = CacheEntry::new(value);
379        let contents =
380            serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
381
382        // Atomic write: write to temp file, then rename
383        let temp_path = path.with_extension("tmp");
384        tokio::fs::write(&temp_path, contents)
385            .await
386            .with_context(|| format!("Failed to write cache temp file: {}", temp_path.display()))?;
387
388        tokio::fs::rename(&temp_path, &path)
389            .await
390            .with_context(|| format!("Failed to rename cache file: {}", path.display()))?;
391
392        Ok(())
393    }
394
395    #[cfg(test)]
396    async fn remove(&self, key: &str) -> Result<()> {
397        if !self.is_enabled() {
398            return Ok(());
399        }
400
401        let Some(path) = self.cache_path(key) else {
402            return Ok(());
403        };
404
405        if tokio::fs::try_exists(&path)
406            .await
407            .with_context(|| format!("Failed to check cache file: {}", path.display()))?
408        {
409            tokio::fs::remove_file(&path)
410                .await
411                .with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
412        }
413        Ok(())
414    }
415}
416
417/// In-memory cache implementation using a `HashMap`.
418///
419/// Stores serialized data in memory with no filesystem dependency.
420/// Always available (no cfg gate), making it suitable for WASM and
421/// test environments. TTL is accepted but ignored -- entries never
422/// expire in memory.
423#[allow(dead_code)]
424pub(crate) struct InMemoryCache<V> {
425    store: std::sync::Arc<tokio::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>>,
426    _phantom: std::marker::PhantomData<V>,
427}
428
429impl<V> InMemoryCache<V>
430where
431    V: Serialize + serde::de::DeserializeOwned + Send,
432{
433    /// Create a new in-memory cache.
434    #[must_use]
435    #[allow(dead_code)]
436    pub fn new() -> Self {
437        Self {
438            store: std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
439            _phantom: std::marker::PhantomData,
440        }
441    }
442}
443
444impl<V> Default for InMemoryCache<V>
445where
446    V: Serialize + serde::de::DeserializeOwned + Send,
447{
448    fn default() -> Self {
449        Self::new()
450    }
451}
452
453impl<V> FileCache<V> for InMemoryCache<V>
454where
455    V: Serialize + serde::de::DeserializeOwned + Send,
456{
457    async fn get(&self, key: &str) -> Result<Option<V>> {
458        let guard = self.store.lock().await;
459        match guard.get(key) {
460            Some(bytes) => {
461                let value: V = serde_json::from_slice(bytes)
462                    .with_context(|| format!("Failed to deserialize cache entry for key: {key}"))?;
463                Ok(Some(value))
464            }
465            None => Ok(None),
466        }
467    }
468
469    async fn get_stale(&self, key: &str) -> Result<Option<V>> {
470        self.get(key).await
471    }
472
473    async fn set(&self, key: &str, value: &V) -> Result<()> {
474        let bytes = serde_json::to_vec(value)
475            .with_context(|| format!("Failed to serialize cache entry for key: {key}"))?;
476        self.store.lock().await.insert(key.to_string(), bytes);
477        Ok(())
478    }
479
480    #[cfg(test)]
481    async fn remove(&self, key: &str) -> Result<()> {
482        self.store.lock().await.remove(key);
483        Ok(())
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
492    struct TestData {
493        value: String,
494        count: u32,
495    }
496
497    #[test]
498    fn test_cache_entry_new() {
499        let data = TestData {
500            value: "test".to_string(),
501            count: 42,
502        };
503        let entry = CacheEntry::new(data.clone());
504
505        assert_eq!(entry.data, data);
506        assert!(entry.etag.is_none());
507    }
508
509    #[test]
510    fn test_cache_entry_with_etag() {
511        let data = TestData {
512            value: "test".to_string(),
513            count: 42,
514        };
515        let etag = "abc123".to_string();
516        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
517
518        assert_eq!(entry.data, data);
519        assert_eq!(entry.etag, Some(etag));
520    }
521
522    #[test]
523    fn test_cache_entry_is_valid_within_ttl() {
524        let data = TestData {
525            value: "test".to_string(),
526            count: 42,
527        };
528        let entry = CacheEntry::new(data);
529        let ttl = Duration::hours(1);
530
531        assert!(entry.is_valid(ttl));
532    }
533
534    #[test]
535    fn test_cache_entry_is_valid_expired() {
536        let data = TestData {
537            value: "test".to_string(),
538            count: 42,
539        };
540        let mut entry = CacheEntry::new(data);
541        // Manually set cached_at to 2 hours ago
542        entry.cached_at = Utc::now() - Duration::hours(2);
543        let ttl = Duration::hours(1);
544
545        assert!(!entry.is_valid(ttl));
546    }
547
548    #[test]
549    fn test_cache_dir_path() {
550        let dir = cache_dir();
551        assert!(dir.is_some());
552        assert!(dir.unwrap().ends_with("aptu"));
553    }
554
555    #[test]
556    fn test_cache_serialization_with_etag() {
557        let data = TestData {
558            value: "test".to_string(),
559            count: 42,
560        };
561        let etag = "xyz789".to_string();
562        let entry = CacheEntry::with_etag(data.clone(), etag.clone());
563
564        let json = serde_json::to_string(&entry).expect("serialize");
565        let parsed: CacheEntry<TestData> = serde_json::from_str(&json).expect("deserialize");
566
567        assert_eq!(parsed.data, data);
568        assert_eq!(parsed.etag, Some(etag));
569    }
570
571    #[tokio::test]
572    async fn test_file_cache_get_set() {
573        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
574        let data = TestData {
575            value: "test".to_string(),
576            count: 42,
577        };
578
579        // Set value
580        cache.set("test_key", &data).await.expect("set cache");
581
582        // Get value
583        let result = cache.get("test_key").await.expect("get cache");
584        assert!(result.is_some());
585        assert_eq!(result.unwrap(), data);
586
587        // Cleanup
588        cache.remove("test_key").await.ok();
589    }
590
591    #[tokio::test]
592    async fn test_file_cache_get_miss() {
593        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
594
595        let result = cache.get("nonexistent").await.expect("get cache");
596        assert!(result.is_none());
597    }
598
599    #[tokio::test]
600    async fn test_file_cache_get_stale() {
601        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::seconds(0));
602        let data = TestData {
603            value: "stale".to_string(),
604            count: 99,
605        };
606
607        // Set value
608        cache.set("stale_key", &data).await.expect("set cache");
609
610        // Wait for TTL to expire
611        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
612
613        // get() should return None (expired)
614        let result = cache.get("stale_key").await.expect("get cache");
615        assert!(result.is_none());
616
617        // get_stale() should return the value
618        let stale_result = cache.get_stale("stale_key").await.expect("get stale cache");
619        assert!(stale_result.is_some());
620        assert_eq!(stale_result.unwrap(), data);
621
622        // Cleanup
623        cache.remove("stale_key").await.ok();
624    }
625
626    #[tokio::test]
627    async fn test_file_cache_remove() {
628        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
629        let data = TestData {
630            value: "remove_me".to_string(),
631            count: 1,
632        };
633
634        // Set value
635        cache.set("remove_key", &data).await.expect("set cache");
636
637        // Verify it exists
638        assert!(cache.get("remove_key").await.expect("get cache").is_some());
639
640        // Remove it
641        cache.remove("remove_key").await.expect("remove cache");
642
643        // Verify it's gone
644        assert!(cache.get("remove_key").await.expect("get cache").is_none());
645    }
646
647    #[tokio::test]
648    async fn test_cache_key_rejects_forward_slash() {
649        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
650        let result = cache
651            .get("../etc/passwd")
652            .await
653            .expect("get should succeed");
654        assert!(result.is_none());
655    }
656
657    #[tokio::test]
658    async fn test_cache_key_rejects_backslash() {
659        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
660        let data = TestData {
661            value: "x".to_string(),
662            count: 0,
663        };
664        let result = cache
665            .set("..\\windows\\system32", &data)
666            .await
667            .expect("set should succeed silently");
668        assert_eq!(result, ());
669    }
670
671    #[tokio::test]
672    async fn test_cache_key_rejects_parent_dir() {
673        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_cache", Duration::hours(1));
674        let result = cache.get("foo..bar").await.expect("get should succeed");
675        assert!(result.is_none());
676    }
677
678    #[tokio::test]
679    async fn test_disabled_cache_get_returns_none() {
680        let cache: FileCacheImpl<TestData> =
681            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
682        let result = cache.get("any_key").await.expect("get should succeed");
683        assert!(result.is_none());
684    }
685
686    #[tokio::test]
687    async fn test_disabled_cache_set_succeeds_silently() {
688        let cache: FileCacheImpl<TestData> =
689            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
690        let data = TestData {
691            value: "test".to_string(),
692            count: 42,
693        };
694        cache
695            .set("any_key", &data)
696            .await
697            .expect("set should succeed");
698    }
699
700    #[tokio::test]
701    async fn test_disabled_cache_remove_succeeds_silently() {
702        let cache: FileCacheImpl<TestData> =
703            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
704        cache
705            .remove("any_key")
706            .await
707            .expect("remove should succeed");
708    }
709
710    #[tokio::test]
711    async fn test_disabled_cache_get_stale_returns_none() {
712        let cache: FileCacheImpl<TestData> =
713            FileCacheImpl::with_dir(None, "test_cache", Duration::hours(1));
714        let result = cache
715            .get_stale("any_key")
716            .await
717            .expect("get_stale should succeed");
718        assert!(result.is_none());
719    }
720
721    #[tokio::test]
722    async fn test_evict_stale_removes_old_files() {
723        let cache: FileCacheImpl<TestData> = FileCacheImpl::new("test_evict", Duration::hours(1));
724        let data = TestData {
725            value: "old".to_string(),
726            count: 1,
727        };
728
729        // Set a value
730        cache.set("old_key", &data).await.expect("set cache");
731
732        // Manually modify the cached_at timestamp to be old
733        if let Some(path) = cache.cache_path("old_key") {
734            let contents = tokio::fs::read_to_string(&path)
735                .await
736                .expect("read cache file");
737            let mut entry: CacheEntry<TestData> =
738                serde_json::from_str(&contents).expect("parse cache entry");
739            entry.cached_at = Utc::now() - Duration::days(10);
740            let new_contents = serde_json::to_string_pretty(&entry).expect("serialize cache entry");
741            tokio::fs::write(&path, new_contents)
742                .await
743                .expect("write cache file");
744        }
745
746        // Evict files older than 7 days
747        let evicted = cache.evict_stale(7).await;
748        assert_eq!(evicted, 1);
749
750        // Verify the file is gone
751        let result = cache.get("old_key").await.expect("get cache");
752        assert!(result.is_none());
753    }
754
755    #[tokio::test]
756    async fn test_evict_stale_preserves_fresh_files() {
757        let cache: FileCacheImpl<TestData> =
758            FileCacheImpl::new("test_evict_fresh", Duration::hours(1));
759        let data = TestData {
760            value: "fresh".to_string(),
761            count: 2,
762        };
763
764        // Set a value
765        cache.set("fresh_key", &data).await.expect("set cache");
766
767        // Evict files older than 7 days (this file is fresh, so it should be preserved)
768        let evicted = cache.evict_stale(7).await;
769        assert_eq!(evicted, 0);
770
771        // Verify the file still exists
772        let result = cache.get("fresh_key").await.expect("get cache");
773        assert!(result.is_some());
774        assert_eq!(result.unwrap(), data);
775
776        // Cleanup
777        cache.remove("fresh_key").await.ok();
778    }
779
780    #[tokio::test]
781    async fn test_in_memory_cache_get_set() {
782        let cache = InMemoryCache::<TestData>::new();
783        let data = TestData {
784            value: "hello".to_string(),
785            count: 42,
786        };
787        cache
788            .set("my_key", &data)
789            .await
790            .expect("set should succeed");
791        let result = cache.get("my_key").await.expect("get should succeed");
792        assert_eq!(result, Some(data));
793    }
794
795    #[tokio::test]
796    async fn test_in_memory_cache_get_miss() {
797        let cache = InMemoryCache::<TestData>::new();
798        let result = cache.get("no_such_key").await.expect("get should succeed");
799        assert!(result.is_none());
800    }
801
802    #[tokio::test]
803    async fn test_in_memory_cache_overwrite() {
804        let cache = InMemoryCache::<TestData>::new();
805        let data1 = TestData {
806            value: "first".to_string(),
807            count: 1,
808        };
809        let data2 = TestData {
810            value: "second".to_string(),
811            count: 2,
812        };
813        cache
814            .set("overwrite_key", &data1)
815            .await
816            .expect("first set should succeed");
817        cache
818            .set("overwrite_key", &data2)
819            .await
820            .expect("second set should succeed");
821        let result = cache
822            .get("overwrite_key")
823            .await
824            .expect("get should succeed");
825        assert_eq!(result, Some(data2));
826    }
827}