Skip to main content

aperture_cli/
response_cache.rs

1use crate::constants;
2use crate::error::Error;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9/// Configuration for response caching
10#[derive(Debug, Clone)]
11pub struct CacheConfig {
12    /// Directory where cache files are stored
13    pub cache_dir: PathBuf,
14    /// Default TTL for cached responses
15    pub default_ttl: Duration,
16    /// Maximum number of cached responses per API
17    pub max_entries: usize,
18    /// Whether caching is enabled globally
19    pub enabled: bool,
20    /// Whether to cache responses from authenticated requests.
21    /// Default is `false` for security: auth headers could leak to disk.
22    pub allow_authenticated: bool,
23}
24
25impl Default for CacheConfig {
26    fn default() -> Self {
27        Self {
28            cache_dir: PathBuf::from(".cache/responses"),
29            default_ttl: Duration::from_secs(300), // 5 minutes
30            max_entries: 1000,
31            enabled: true,
32            allow_authenticated: false, // Secure by default
33        }
34    }
35}
36
37/// A cached API response
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CachedResponse {
40    /// The HTTP response body
41    pub body: String,
42    /// HTTP status code
43    pub status_code: u16,
44    /// Response headers
45    pub headers: HashMap<String, String>,
46    /// When this response was cached (Unix timestamp)
47    pub cached_at: u64,
48    /// TTL in seconds from `cached_at`
49    pub ttl_seconds: u64,
50    /// The original request that generated this response
51    pub request_info: CachedRequestInfo,
52}
53
54/// Information about the request that generated a cached response
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CachedRequestInfo {
57    /// HTTP method
58    pub method: String,
59    /// Full URL
60    pub url: String,
61    /// Request headers (excluding auth headers for security)
62    pub headers: HashMap<String, String>,
63    /// Request body hash (for POST/PUT requests)
64    pub body_hash: Option<String>,
65}
66
67/// Cache key components for generating cache file names
68#[derive(Debug)]
69pub struct CacheKey {
70    /// API specification name
71    pub api_name: String,
72    /// Operation ID from `OpenAPI` spec
73    pub operation_id: String,
74    /// Hash of request parameters and body
75    pub request_hash: String,
76}
77
78impl CacheKey {
79    /// Generate a cache key from request information
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if hashing fails (should be rare)
84    pub fn from_request(
85        api_name: &str,
86        operation_id: &str,
87        method: &str,
88        url: &str,
89        headers: &HashMap<String, String>,
90        body: Option<&str>,
91    ) -> Result<Self, Error> {
92        let mut hasher = Sha256::new();
93
94        // Include method, URL, and relevant headers in hash
95        hasher.update(method.as_bytes());
96        hasher.update(url.as_bytes());
97
98        // Sort headers for consistent hashing (exclude auth headers)
99        let mut sorted_headers: Vec<_> = headers
100            .iter()
101            .filter(|(key, _)| !is_auth_header(key))
102            .collect();
103        sorted_headers.sort_by_key(|(key, _)| *key);
104
105        for (key, value) in sorted_headers {
106            hasher.update(key.as_bytes());
107            hasher.update(value.as_bytes());
108        }
109
110        // Include body hash if present
111        if let Some(body_content) = body {
112            hasher.update(body_content.as_bytes());
113        }
114
115        let hash = hasher.finalize();
116        let request_hash = format!("{hash:x}");
117
118        Ok(Self {
119            api_name: api_name.to_string(),
120            operation_id: operation_id.to_string(),
121            request_hash,
122        })
123    }
124
125    /// Generate the cache file name for this key
126    #[must_use]
127    pub fn to_filename(&self) -> String {
128        let hash_prefix = if self.request_hash.len() >= 16 {
129            &self.request_hash[..16]
130        } else {
131            &self.request_hash
132        };
133
134        format!(
135            "{}_{}_{}_{}{}",
136            self.api_name,
137            self.operation_id,
138            hash_prefix,
139            constants::CACHE_SUFFIX,
140            constants::FILE_EXT_JSON
141        )
142    }
143}
144
145/// Response cache manager
146pub struct ResponseCache {
147    config: CacheConfig,
148}
149
150impl ResponseCache {
151    /// Creates a new response cache with the given configuration
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the cache directory cannot be created
156    pub fn new(config: CacheConfig) -> Result<Self, Error> {
157        // Ensure cache directory exists
158        std::fs::create_dir_all(&config.cache_dir)
159            .map_err(|e| Error::io_error(format!("Failed to create cache directory: {e}")))?;
160
161        Ok(Self { config })
162    }
163
164    /// Acquire the advisory directory lock asynchronously.
165    ///
166    /// The blocking `flock` call is offloaded to a blocking thread via
167    /// `spawn_blocking` so it does not stall the async runtime.
168    async fn acquire_lock(&self) -> Result<crate::atomic::DirLock, Error> {
169        let cache_dir = self.config.cache_dir.clone();
170        tokio::task::spawn_blocking(move || crate::atomic::DirLock::acquire(&cache_dir))
171            .await
172            .map_err(|e| Error::io_error(format!("Lock task failed: {e}")))?
173            .map_err(|e| Error::io_error(format!("Failed to acquire cache lock: {e}")))
174    }
175
176    /// Store a response in the cache
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if:
181    /// - The cache file cannot be written
182    /// - JSON serialization fails
183    /// - Cache cleanup fails
184    pub async fn store(
185        &self,
186        key: &CacheKey,
187        body: &str,
188        status_code: u16,
189        headers: &HashMap<String, String>,
190        request_info: CachedRequestInfo,
191        ttl: Option<Duration>,
192    ) -> Result<(), Error> {
193        if !self.config.enabled {
194            return Ok(());
195        }
196
197        let now = SystemTime::now()
198            .duration_since(UNIX_EPOCH)
199            .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
200            .as_secs();
201
202        let ttl_seconds = ttl.unwrap_or(self.config.default_ttl).as_secs();
203
204        let cached_response = CachedResponse {
205            body: body.to_string(),
206            status_code,
207            headers: headers.clone(),
208            cached_at: now,
209            ttl_seconds,
210            request_info,
211        };
212
213        let cache_file = self.config.cache_dir.join(key.to_filename());
214        let json_content = serde_json::to_string_pretty(&cached_response).map_err(|e| {
215            Error::serialization_error(format!("Failed to serialize cached response: {e}"))
216        })?;
217
218        // Acquire advisory lock on the cache directory to coordinate with
219        // other Aperture processes writing to the same cache.
220        let _lock = self.acquire_lock().await?;
221
222        crate::atomic::atomic_write(&cache_file, json_content.as_bytes())
223            .await
224            .map_err(|e| Error::io_error(format!("Failed to write cache file: {e}")))?;
225
226        // Clean up old entries if we exceed max_entries
227        self.cleanup_old_entries(&key.api_name).await?;
228
229        // Lock is released when `_lock` is dropped
230        Ok(())
231    }
232
233    /// Retrieve a response from the cache if it exists and is still valid
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if:
238    /// - The cache file cannot be read
239    /// - JSON deserialization fails
240    pub async fn get(&self, key: &CacheKey) -> Result<Option<CachedResponse>, Error> {
241        if !self.config.enabled {
242            return Ok(None);
243        }
244
245        let cache_file = self.config.cache_dir.join(key.to_filename());
246
247        if !cache_file.exists() {
248            return Ok(None);
249        }
250
251        let json_content = tokio::fs::read_to_string(&cache_file)
252            .await
253            .map_err(|e| Error::io_error(format!("Failed to read cache file: {e}")))?;
254        let cached_response: CachedResponse = serde_json::from_str(&json_content).map_err(|e| {
255            Error::serialization_error(format!("Failed to deserialize cached response: {e}"))
256        })?;
257
258        // Check if the cache entry is still valid
259        let now = SystemTime::now()
260            .duration_since(UNIX_EPOCH)
261            .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
262            .as_secs();
263
264        if now > cached_response.cached_at + cached_response.ttl_seconds {
265            // Cache entry has expired — don't eagerly delete here because
266            // deletion is a mutating operation that should be coordinated
267            // under the advisory lock. Expired entries are cleaned up by
268            // `cleanup_old_entries()` (called from `store()` under the lock).
269            return Ok(None);
270        }
271
272        Ok(Some(cached_response))
273    }
274
275    /// Check if a response is cached and valid for the given key
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if cache validation fails
280    pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
281        Ok(self.get(key).await?.is_some())
282    }
283
284    /// Clear all cached responses for a specific API
285    ///
286    /// Acquires the advisory directory lock to coordinate with concurrent
287    /// `store()` calls.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if cache files cannot be removed
292    pub async fn clear_api_cache(&self, api_name: &str) -> Result<usize, Error> {
293        let _lock = self.acquire_lock().await?;
294
295        let mut cleared_count = 0;
296        let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
297            .await
298            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
299
300        while let Some(entry) = entries
301            .next_entry()
302            .await
303            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
304        {
305            let filename = entry.file_name();
306            let filename_str = filename.to_string_lossy();
307
308            if filename_str.starts_with(&format!("{api_name}_"))
309                && filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
310            {
311                tokio::fs::remove_file(entry.path())
312                    .await
313                    .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
314                cleared_count += 1;
315            }
316        }
317
318        Ok(cleared_count)
319    }
320
321    /// Clear all cached responses
322    ///
323    /// Acquires the advisory directory lock to coordinate with concurrent
324    /// `store()` calls.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if cache directory cannot be cleared
329    pub async fn clear_all(&self) -> Result<usize, Error> {
330        let _lock = self.acquire_lock().await?;
331
332        let mut cleared_count = 0;
333        let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
334            .await
335            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
336
337        while let Some(entry) = entries
338            .next_entry()
339            .await
340            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
341        {
342            let filename = entry.file_name();
343            let filename_str = filename.to_string_lossy();
344
345            if filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
346                tokio::fs::remove_file(entry.path())
347                    .await
348                    .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
349                cleared_count += 1;
350            }
351        }
352
353        Ok(cleared_count)
354    }
355
356    /// Get cache statistics for an API
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if cache directory cannot be read
361    pub async fn get_stats(&self, api_name: Option<&str>) -> Result<CacheStats, Error> {
362        let mut stats = CacheStats::default();
363        let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
364            .await
365            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
366
367        while let Some(entry) = entries
368            .next_entry()
369            .await
370            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
371        {
372            let filename = entry.file_name();
373            let filename_str = filename.to_string_lossy();
374
375            if !filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
376                continue;
377            }
378
379            // Check if this entry matches the requested API
380            let Some(target_api) = api_name else {
381                // No filter, include all entries
382                stats.total_entries += 1;
383
384                // Check if entry is expired
385                let Ok(metadata) = entry.metadata().await else {
386                    continue;
387                };
388
389                stats.total_size_bytes += metadata.len();
390
391                // Try to read the cache file to check expiration
392                let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
393                    continue;
394                };
395
396                let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content)
397                else {
398                    continue;
399                };
400
401                let now = SystemTime::now()
402                    .duration_since(UNIX_EPOCH)
403                    .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
404                    .as_secs();
405
406                if now > cached_response.cached_at + cached_response.ttl_seconds {
407                    stats.expired_entries += 1;
408                } else {
409                    stats.valid_entries += 1;
410                }
411
412                continue;
413            };
414
415            if !filename_str.starts_with(&format!("{target_api}_")) {
416                continue;
417            }
418
419            stats.total_entries += 1;
420
421            // Check if entry is expired
422            let Ok(metadata) = entry.metadata().await else {
423                continue;
424            };
425
426            stats.total_size_bytes += metadata.len();
427
428            // Try to read the cache file to check expiration
429            let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
430                continue;
431            };
432
433            let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content) else {
434                continue;
435            };
436
437            let now = SystemTime::now()
438                .duration_since(UNIX_EPOCH)
439                .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
440                .as_secs();
441
442            if now > cached_response.cached_at + cached_response.ttl_seconds {
443                stats.expired_entries += 1;
444            } else {
445                stats.valid_entries += 1;
446            }
447        }
448
449        Ok(stats)
450    }
451
452    /// Check whether a directory entry is a stale temp file (older than 1 hour)
453    /// and, if so, add it to the collection for removal.
454    async fn collect_stale_temp_file(
455        &self,
456        entry: &tokio::fs::DirEntry,
457        now: SystemTime,
458        stale_files: &mut Vec<std::path::PathBuf>,
459    ) {
460        let is_stale = entry
461            .metadata()
462            .await
463            .ok()
464            .and_then(|m| m.modified().ok())
465            .is_some_and(|modified| {
466                now.duration_since(modified).unwrap_or(Duration::ZERO) > Duration::from_secs(3600)
467            });
468        if is_stale {
469            stale_files.push(entry.path());
470        }
471    }
472
473    /// Clean up old cache entries for an API, keeping only the most recent
474    /// `max_entries`.  Also sweeps orphaned `.*.tmp` files older than 1 hour
475    /// that may have been left behind by a crashed process.
476    async fn cleanup_old_entries(&self, api_name: &str) -> Result<(), Error> {
477        let mut entries = Vec::new();
478        let mut stale_tmp_files = Vec::new();
479        let now_system = SystemTime::now();
480
481        let mut dir_entries = tokio::fs::read_dir(&self.config.cache_dir)
482            .await
483            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
484
485        while let Some(entry) = dir_entries
486            .next_entry()
487            .await
488            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
489        {
490            let filename = entry.file_name();
491            let filename_str = filename.to_string_lossy();
492
493            // Detect orphaned temp files from crashed atomic writes.
494            // Pattern: .filename.random.tmp
495            let is_temp_file = filename_str.starts_with('.')
496                && filename_str.ends_with(".tmp")
497                && filename_str.len() > 4;
498
499            if is_temp_file {
500                self.collect_stale_temp_file(&entry, now_system, &mut stale_tmp_files)
501                    .await;
502                continue;
503            }
504
505            if !filename_str.starts_with(&format!("{api_name}_"))
506                || !filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
507            {
508                continue;
509            }
510
511            let Ok(metadata) = entry.metadata().await else {
512                continue;
513            };
514
515            let Ok(modified) = metadata.modified() else {
516                continue;
517            };
518
519            entries.push((entry.path(), modified));
520        }
521
522        // Remove orphaned temp files
523        for path in &stale_tmp_files {
524            let _ = tokio::fs::remove_file(path).await;
525        }
526
527        // If we have more entries than max_entries, remove the oldest ones
528        if entries.len() > self.config.max_entries {
529            entries.sort_by_key(|(_, modified)| *modified);
530            let to_remove = entries.len() - self.config.max_entries;
531
532            for (path, _) in entries.iter().take(to_remove) {
533                let _ = tokio::fs::remove_file(path).await;
534            }
535        }
536
537        Ok(())
538    }
539}
540
541/// Cache statistics
542#[derive(Debug, Default)]
543pub struct CacheStats {
544    /// Total number of cache entries
545    pub total_entries: usize,
546    /// Number of valid (non-expired) entries
547    pub valid_entries: usize,
548    /// Number of expired entries
549    pub expired_entries: usize,
550    /// Total size of cache files in bytes
551    pub total_size_bytes: u64,
552}
553
554/// Check if a header is an authentication header that should be excluded from caching
555#[must_use]
556pub fn is_auth_header(header_name: &str) -> bool {
557    constants::is_auth_header(header_name)
558        || header_name
559            .to_lowercase()
560            .starts_with(constants::HEADER_PREFIX_X_AUTH)
561        || header_name
562            .to_lowercase()
563            .starts_with(constants::HEADER_PREFIX_X_API)
564}
565
566/// Scrub authentication headers from a header map before caching.
567///
568/// This ensures sensitive credentials are never persisted to disk,
569/// maintaining the security boundary between configuration and secrets.
570#[must_use]
571pub fn scrub_auth_headers<S: std::hash::BuildHasher>(
572    headers: &HashMap<String, String, S>,
573) -> HashMap<String, String> {
574    headers
575        .iter()
576        .filter(|(key, _)| !is_auth_header(key))
577        .map(|(k, v)| (k.clone(), v.clone()))
578        .collect()
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use tempfile::TempDir;
585
586    fn create_test_cache_config() -> (CacheConfig, TempDir) {
587        let temp_dir = TempDir::new().unwrap();
588        let config = CacheConfig {
589            cache_dir: temp_dir.path().to_path_buf(),
590            default_ttl: Duration::from_secs(60),
591            max_entries: 10,
592            enabled: true,
593            allow_authenticated: false,
594        };
595        (config, temp_dir)
596    }
597
598    #[test]
599    fn test_cache_key_generation() {
600        let mut headers = HashMap::new();
601        headers.insert(
602            constants::HEADER_CONTENT_TYPE_LC.to_string(),
603            constants::CONTENT_TYPE_JSON.to_string(),
604        );
605        headers.insert(
606            constants::HEADER_AUTHORIZATION_LC.to_string(),
607            "Bearer secret".to_string(),
608        ); // Should be excluded
609
610        let key = CacheKey::from_request(
611            "test_api",
612            "getUser",
613            constants::HTTP_METHOD_GET,
614            "https://api.example.com/users/123",
615            &headers,
616            None,
617        )
618        .unwrap();
619
620        assert_eq!(key.api_name, "test_api");
621        assert_eq!(key.operation_id, "getUser");
622        assert!(!key.request_hash.is_empty());
623
624        let filename = key.to_filename();
625        assert!(filename.starts_with("test_api_getUser_"));
626        assert!(filename.ends_with(constants::CACHE_FILE_SUFFIX));
627    }
628
629    #[test]
630    fn test_is_auth_header() {
631        assert!(is_auth_header(constants::HEADER_AUTHORIZATION));
632        assert!(is_auth_header("X-API-Key"));
633        assert!(is_auth_header("x-auth-token"));
634        assert!(!is_auth_header(constants::HEADER_CONTENT_TYPE));
635        assert!(!is_auth_header("User-Agent"));
636    }
637
638    #[test]
639    fn test_scrub_auth_headers() {
640        let mut headers = HashMap::new();
641        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
642        headers.insert("X-API-Key".to_string(), "api-key-123".to_string());
643        headers.insert("x-auth-token".to_string(), "token-456".to_string());
644        headers.insert("Content-Type".to_string(), "application/json".to_string());
645        headers.insert("User-Agent".to_string(), "test-agent".to_string());
646        headers.insert("Accept".to_string(), "application/json".to_string());
647
648        let scrubbed = scrub_auth_headers(&headers);
649
650        // Auth headers should be removed
651        assert!(!scrubbed.contains_key("Authorization"));
652        assert!(!scrubbed.contains_key("X-API-Key"));
653        assert!(!scrubbed.contains_key("x-auth-token"));
654
655        // Non-auth headers should be preserved
656        assert_eq!(
657            scrubbed.get("Content-Type"),
658            Some(&"application/json".to_string())
659        );
660        assert_eq!(scrubbed.get("User-Agent"), Some(&"test-agent".to_string()));
661        assert_eq!(
662            scrubbed.get("Accept"),
663            Some(&"application/json".to_string())
664        );
665
666        // Only 3 non-auth headers should remain
667        assert_eq!(scrubbed.len(), 3);
668    }
669
670    #[tokio::test]
671    async fn test_cache_store_and_retrieve() {
672        let (config, _temp_dir) = create_test_cache_config();
673        let cache = ResponseCache::new(config).unwrap();
674
675        let key = CacheKey {
676            api_name: "test_api".to_string(),
677            operation_id: "getUser".to_string(),
678            request_hash: "abc123".to_string(),
679        };
680
681        let mut headers = HashMap::new();
682        headers.insert(
683            constants::HEADER_CONTENT_TYPE_LC.to_string(),
684            constants::CONTENT_TYPE_JSON.to_string(),
685        );
686
687        let request_info = CachedRequestInfo {
688            method: constants::HTTP_METHOD_GET.to_string(),
689            url: "https://api.example.com/users/123".to_string(),
690            headers: headers.clone(),
691            body_hash: None,
692        };
693
694        // Store a response
695        cache
696            .store(
697                &key,
698                r#"{"id": 123, "name": "John"}"#,
699                200,
700                &headers,
701                request_info,
702                Some(Duration::from_secs(60)),
703            )
704            .await
705            .unwrap();
706
707        // Retrieve the response
708        let cached = cache.get(&key).await.unwrap();
709        assert!(cached.is_some());
710
711        let response = cached.unwrap();
712        assert_eq!(response.body, r#"{"id": 123, "name": "John"}"#);
713        assert_eq!(response.status_code, 200);
714    }
715
716    #[tokio::test]
717    async fn test_cache_expiration() {
718        let (config, _temp_dir) = create_test_cache_config();
719        let cache = ResponseCache::new(config).unwrap();
720
721        let key = CacheKey {
722            api_name: "test_api".to_string(),
723            operation_id: "getUser".to_string(),
724            request_hash: "abc123def456".to_string(),
725        };
726
727        let headers = HashMap::new();
728        let request_info = CachedRequestInfo {
729            method: constants::HTTP_METHOD_GET.to_string(),
730            url: "https://api.example.com/users/123".to_string(),
731            headers: headers.clone(),
732            body_hash: None,
733        };
734
735        // Store a response with 1 second TTL
736        cache
737            .store(
738                &key,
739                "test response",
740                200,
741                &headers,
742                request_info,
743                Some(Duration::from_secs(1)),
744            )
745            .await
746            .unwrap();
747
748        // Should be cached immediately
749        assert!(cache.is_cached(&key).await.unwrap());
750
751        // Manually create an expired cache entry by modifying the cached_at time
752        let cache_file = cache.config.cache_dir.join(key.to_filename());
753        let mut cached_response: CachedResponse = {
754            let json_content = tokio::fs::read_to_string(&cache_file).await.unwrap();
755            serde_json::from_str(&json_content).unwrap()
756        };
757
758        // Set cached_at to a time in the past that exceeds TTL
759        cached_response.cached_at = SystemTime::now()
760            .duration_since(UNIX_EPOCH)
761            .unwrap()
762            .as_secs()
763            - 2; // 2 seconds ago, which exceeds 1 second TTL
764
765        let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
766        tokio::fs::write(&cache_file, json_content).await.unwrap();
767
768        // Should no longer be cached due to expiration
769        assert!(!cache.is_cached(&key).await.unwrap());
770
771        // The expired file is not eagerly deleted by get() — it is left for
772        // cleanup_old_entries() which runs under the advisory lock during
773        // store(). Verify the file still exists on disk.
774        assert!(cache_file.exists());
775    }
776
777    // ---- Helper: store a minimal entry for a given (api_name, operation_id) ----
778
779    async fn store_entry(cache: &ResponseCache, api_name: &str, operation_id: &str) {
780        let key = CacheKey {
781            api_name: api_name.to_string(),
782            operation_id: operation_id.to_string(),
783            request_hash: format!("{api_name}_{operation_id}"),
784        };
785        let request_info = CachedRequestInfo {
786            method: constants::HTTP_METHOD_GET.to_string(),
787            url: "https://api.example.com/test".to_string(),
788            headers: HashMap::new(),
789            body_hash: None,
790        };
791        cache
792            .store(
793                &key,
794                r#"{"ok": true}"#,
795                200,
796                &HashMap::new(),
797                request_info,
798                Some(Duration::from_secs(300)),
799            )
800            .await
801            .unwrap();
802    }
803
804    // ---- clear_api_cache ----
805
806    #[tokio::test]
807    async fn test_clear_api_cache_removes_only_target_api() {
808        let (config, _temp_dir) = create_test_cache_config();
809        let cache = ResponseCache::new(config).unwrap();
810
811        // Populate two APIs with one entry each.
812        store_entry(&cache, "api_a", "op1").await;
813        store_entry(&cache, "api_b", "op2").await;
814
815        let cleared = cache.clear_api_cache("api_a").await.unwrap();
816        assert_eq!(
817            cleared, 1,
818            "should have cleared exactly one entry for api_a"
819        );
820
821        // api_b entry must survive.
822        let stats = cache.get_stats(Some("api_b")).await.unwrap();
823        assert_eq!(stats.total_entries, 1, "api_b entry must remain");
824
825        // api_a must be empty.
826        let stats_a = cache.get_stats(Some("api_a")).await.unwrap();
827        assert_eq!(stats_a.total_entries, 0, "api_a entries must be gone");
828    }
829
830    #[tokio::test]
831    async fn test_clear_api_cache_multiple_entries() {
832        let (config, _temp_dir) = create_test_cache_config();
833        let cache = ResponseCache::new(config).unwrap();
834
835        store_entry(&cache, "api_a", "op1").await;
836        store_entry(&cache, "api_a", "op2").await;
837        store_entry(&cache, "api_a", "op3").await;
838        store_entry(&cache, "api_b", "opX").await;
839
840        let cleared = cache.clear_api_cache("api_a").await.unwrap();
841        assert_eq!(cleared, 3, "should clear all three api_a entries");
842
843        let remaining = cache.get_stats(None).await.unwrap();
844        assert_eq!(remaining.total_entries, 1, "only api_b entry should remain");
845    }
846
847    // ---- clear_all ----
848
849    #[tokio::test]
850    async fn test_clear_all_empties_the_cache() {
851        let (config, _temp_dir) = create_test_cache_config();
852        let cache = ResponseCache::new(config).unwrap();
853
854        store_entry(&cache, "api_a", "op1").await;
855        store_entry(&cache, "api_b", "op2").await;
856        store_entry(&cache, "api_c", "op3").await;
857
858        let cleared = cache.clear_all().await.unwrap();
859        assert_eq!(cleared, 3);
860
861        let stats = cache.get_stats(None).await.unwrap();
862        assert_eq!(
863            stats.total_entries, 0,
864            "cache must be empty after clear_all"
865        );
866    }
867
868    #[tokio::test]
869    async fn test_clear_all_on_empty_cache() {
870        let (config, _temp_dir) = create_test_cache_config();
871        let cache = ResponseCache::new(config).unwrap();
872
873        let cleared = cache.clear_all().await.unwrap();
874        assert_eq!(cleared, 0, "clearing an empty cache returns 0");
875    }
876
877    // ---- get_stats ----
878
879    #[tokio::test]
880    async fn test_get_stats_no_filter_counts_all_entries() {
881        let (config, _temp_dir) = create_test_cache_config();
882        let cache = ResponseCache::new(config).unwrap();
883
884        store_entry(&cache, "api_a", "op1").await;
885        store_entry(&cache, "api_b", "op2").await;
886
887        let stats = cache.get_stats(None).await.unwrap();
888        assert_eq!(stats.total_entries, 2);
889        assert_eq!(stats.valid_entries, 2);
890        assert_eq!(stats.expired_entries, 0);
891        assert!(stats.total_size_bytes > 0);
892    }
893
894    #[tokio::test]
895    async fn test_get_stats_with_api_filter() {
896        let (config, _temp_dir) = create_test_cache_config();
897        let cache = ResponseCache::new(config).unwrap();
898
899        store_entry(&cache, "api_a", "op1").await;
900        store_entry(&cache, "api_a", "op2").await;
901        store_entry(&cache, "api_b", "opX").await;
902
903        let stats = cache.get_stats(Some("api_a")).await.unwrap();
904        assert_eq!(stats.total_entries, 2, "filter must restrict to api_a");
905        assert_eq!(stats.valid_entries, 2);
906
907        let stats_b = cache.get_stats(Some("api_b")).await.unwrap();
908        assert_eq!(stats_b.total_entries, 1);
909    }
910
911    #[tokio::test]
912    async fn test_get_stats_counts_expired_entries() {
913        let (config, _temp_dir) = create_test_cache_config();
914        let cache = ResponseCache::new(config).unwrap();
915
916        let key = CacheKey {
917            api_name: "api_a".to_string(),
918            operation_id: "expiredOp".to_string(),
919            request_hash: "expiredhash".to_string(),
920        };
921        let request_info = CachedRequestInfo {
922            method: constants::HTTP_METHOD_GET.to_string(),
923            url: "https://api.example.com/test".to_string(),
924            headers: HashMap::new(),
925            body_hash: None,
926        };
927        cache
928            .store(
929                &key,
930                "body",
931                200,
932                &HashMap::new(),
933                request_info,
934                Some(Duration::from_secs(1)),
935            )
936            .await
937            .unwrap();
938
939        // Backdate cached_at so the entry is expired.
940        let cache_file = cache.config.cache_dir.join(key.to_filename());
941        let json = tokio::fs::read_to_string(&cache_file).await.unwrap();
942        let mut entry: CachedResponse = serde_json::from_str(&json).unwrap();
943        entry.cached_at = SystemTime::now()
944            .duration_since(UNIX_EPOCH)
945            .unwrap()
946            .as_secs()
947            - 10; // 10 seconds in the past, past the 1-second TTL
948        tokio::fs::write(&cache_file, serde_json::to_string_pretty(&entry).unwrap())
949            .await
950            .unwrap();
951
952        // Add one valid entry for the same API.
953        store_entry(&cache, "api_a", "validOp").await;
954
955        let stats = cache.get_stats(Some("api_a")).await.unwrap();
956        assert_eq!(stats.total_entries, 2);
957        assert_eq!(stats.expired_entries, 1);
958        assert_eq!(stats.valid_entries, 1);
959    }
960
961    // ---- cleanup_old_entries temp-file sweep ----
962
963    /// Verify that a stale `.*.tmp` file left by a crashed atomic write is removed
964    /// by the next `store()` call, which internally runs `cleanup_old_entries`.
965    #[tokio::test]
966    async fn test_cleanup_removes_stale_tmp_files() {
967        let (config, _temp_dir) = create_test_cache_config();
968        let cache = ResponseCache::new(config.clone()).unwrap();
969
970        // Place a fake orphaned temp file in the cache directory.
971        let tmp_path = config.cache_dir.join(".orphaned.1a2b3c.tmp");
972        tokio::fs::write(&tmp_path, b"partial write").await.unwrap();
973        assert!(tmp_path.exists(), "temp file must exist before cleanup");
974
975        // Set the temp file's mtime to Unix epoch (well over 1 hour old) so
976        // the sweep considers it stale. FileTimes is used instead of `touch`
977        // to avoid platform-specific CLI syntax differences (GNU vs BSD).
978        let epoch = std::time::SystemTime::UNIX_EPOCH;
979        let file = std::fs::OpenOptions::new()
980            .write(true)
981            .open(&tmp_path)
982            .expect("temp file must be openable");
983        file.set_modified(epoch)
984            .expect("setting mtime to epoch must succeed");
985
986        // A store() call triggers cleanup_old_entries for "api_sweep".
987        store_entry(&cache, "api_sweep", "op1").await;
988
989        assert!(
990            !tmp_path.exists(),
991            "stale temp file must be removed by cleanup_old_entries"
992        );
993    }
994}