Skip to main content

opencode_provider_manager/discovery/
cache.rs

1//! File-based caching for discovery results.
2//!
3//! Cache directory: varies by platform
4//! - Linux: ~/.cache/opencode-provider-manager/
5//! - macOS: ~/Library/Caches/opencode-provider-manager/
6//! - Windows: %LOCALAPPDATA%\opencode-provider-manager\cache\
7//!
8//! Cache TTL: configurable, default 24 hours.
9
10use super::error::{DiscoveryError, Result};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15/// Default cache TTL in seconds (24 hours).
16const DEFAULT_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
17
18/// Cache entry with metadata.
19/// Uses `serde_json::Value` for serialization to avoid generic bounds issues.
20#[derive(Debug, Serialize, Deserialize)]
21pub struct CacheEntry {
22    /// Cached data as JSON value.
23    pub data: serde_json::Value,
24    /// Timestamp when this entry was created (UNIX epoch seconds).
25    pub created_at: u64,
26    /// TTL in seconds.
27    pub ttl_secs: u64,
28}
29
30impl CacheEntry {
31    /// Create a new cache entry from a serializable value.
32    pub fn new<T: Serialize>(data: T, ttl_secs: u64) -> Result<Self> {
33        let data = serde_json::to_value(&data)
34            .map_err(|e| DiscoveryError::Cache(format!("Failed to serialize cache data: {e}")))?;
35        Ok(Self {
36            data,
37            created_at: SystemTime::now()
38                .duration_since(UNIX_EPOCH)
39                .unwrap_or_default()
40                .as_secs(),
41            ttl_secs,
42        })
43    }
44
45    /// Check if this cache entry has expired.
46    pub fn is_expired(&self) -> bool {
47        let now = SystemTime::now()
48            .duration_since(UNIX_EPOCH)
49            .unwrap_or_default()
50            .as_secs();
51        now > self.created_at + self.ttl_secs
52    }
53}
54
55/// File-based cache manager.
56pub struct CacheManager {
57    cache_dir: PathBuf,
58    default_ttl: Duration,
59}
60
61impl CacheManager {
62    /// Create a new cache manager with the default cache directory.
63    pub fn new() -> Result<Self> {
64        let cache_dir = dirs::cache_dir()
65            .ok_or_else(|| DiscoveryError::Cache("Cannot determine cache directory".to_string()))?
66            .join("opencode-provider-manager");
67
68        // Ensure cache directory exists
69        std::fs::create_dir_all(&cache_dir)
70            .map_err(|e| DiscoveryError::Cache(format!("Failed to create cache dir: {e}")))?;
71
72        Ok(Self {
73            cache_dir,
74            default_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
75        })
76    }
77
78    /// Create a cache manager with a custom directory.
79    pub fn with_dir(cache_dir: PathBuf) -> Result<Self> {
80        std::fs::create_dir_all(&cache_dir)
81            .map_err(|e| DiscoveryError::Cache(format!("Failed to create cache dir: {e}")))?;
82
83        Ok(Self {
84            cache_dir,
85            default_ttl: Duration::from_secs(DEFAULT_CACHE_TTL_SECS),
86        })
87    }
88
89    /// Set the default TTL.
90    pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
91        self.default_ttl = ttl;
92        self
93    }
94
95    /// Get a cached value.
96    pub fn get<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
97        let path = self.cache_dir.join(format!("{}.json", key));
98        if !path.exists() {
99            return Ok(None);
100        }
101
102        let content = std::fs::read_to_string(&path)
103            .map_err(|e| DiscoveryError::Cache(format!("Failed to read cache: {e}")))?;
104
105        let entry: CacheEntry = serde_json::from_str(&content)
106            .map_err(|e| DiscoveryError::Cache(format!("Failed to parse cache: {e}")))?;
107
108        if entry.is_expired() {
109            // Clean up expired entry
110            let _ = std::fs::remove_file(&path);
111            Ok(None)
112        } else {
113            let value: T = serde_json::from_value(entry.data).map_err(|e| {
114                DiscoveryError::Cache(format!("Failed to deserialize cache data: {e}"))
115            })?;
116            Ok(Some(value))
117        }
118    }
119
120    /// Store a value in the cache.
121    pub fn set<T: Serialize>(&self, key: &str, data: T) -> Result<()> {
122        self.set_with_ttl(key, data, self.default_ttl.as_secs())
123    }
124
125    /// Store a value with a custom TTL.
126    pub fn set_with_ttl<T: Serialize>(&self, key: &str, data: T, ttl_secs: u64) -> Result<()> {
127        let path = self.cache_dir.join(format!("{}.json", key));
128        let entry = CacheEntry::new(data, ttl_secs)?;
129
130        let content = serde_json::to_string_pretty(&entry)
131            .map_err(|e| DiscoveryError::Cache(format!("Failed to serialize cache: {e}")))?;
132
133        std::fs::write(&path, content)
134            .map_err(|e| DiscoveryError::Cache(format!("Failed to write cache: {e}")))?;
135
136        Ok(())
137    }
138
139    /// Remove a cached value.
140    pub fn remove(&self, key: &str) -> Result<()> {
141        let path = self.cache_dir.join(format!("{}.json", key));
142        if path.exists() {
143            std::fs::remove_file(&path)
144                .map_err(|e| DiscoveryError::Cache(format!("Failed to remove cache: {e}")))?;
145        }
146        Ok(())
147    }
148
149    /// Clear all cached values.
150    pub fn clear(&self) -> Result<()> {
151        if self.cache_dir.exists() {
152            for entry in std::fs::read_dir(&self.cache_dir)
153                .map_err(|e| DiscoveryError::Cache(format!("Failed to read cache dir: {e}")))?
154            {
155                let entry = entry
156                    .map_err(|e| DiscoveryError::Cache(format!("Failed to read dir entry: {e}")))?;
157                if entry.path().extension().is_some_and(|ext| ext == "json") {
158                    let _ = std::fs::remove_file(entry.path());
159                }
160            }
161        }
162        Ok(())
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_cache_entry_new() {
172        let entry = CacheEntry::new("test_data", 3600).unwrap();
173        assert!(!entry.is_expired());
174        assert_eq!(
175            entry.data,
176            serde_json::Value::String("test_data".to_string())
177        );
178    }
179
180    #[test]
181    fn test_cache_entry_expired() {
182        let mut entry = CacheEntry::new("test_data", 1).unwrap();
183        entry.created_at = 0; // Long ago
184        assert!(entry.is_expired());
185    }
186
187    #[test]
188    fn test_cache_manager_crud() {
189        let dir = std::env::temp_dir().join("opm-test-cache");
190        let manager = CacheManager::with_dir(dir.clone()).unwrap();
191
192        manager.set("test_key", "test_value").unwrap();
193        let value: Option<String> = manager.get("test_key").unwrap();
194        assert_eq!(value, Some("test_value".to_string()));
195
196        manager.remove("test_key").unwrap();
197        let value: Option<String> = manager.get("test_key").unwrap();
198        assert_eq!(value, None);
199
200        // Cleanup
201        let _ = std::fs::remove_dir_all(&dir);
202    }
203}