Skip to main content

ralph_workflow/agents/opencode_api/
cache.rs

1//! `OpenCode` API catalog caching.
2//!
3//! This module handles file-based caching of the `OpenCode` model catalog
4//! with TTL-based expiration.
5//!
6//! # Dependency Injection
7//!
8//! The [`CacheEnvironment`] trait abstracts filesystem operations for caching,
9//! enabling pure unit tests without real filesystem access. Production code
10//! uses [`RealCacheEnvironment`], tests use [`MemoryCacheEnvironment`].
11
12use crate::agents::opencode_api::fetch::fetch_api_catalog;
13use crate::agents::opencode_api::types::ApiCatalog;
14use crate::agents::opencode_api::{CACHE_TTL_ENV_VAR, DEFAULT_CACHE_TTL_SECONDS};
15use std::io;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18
19/// Errors that can occur when loading the API catalog.
20#[derive(Debug, Error)]
21pub enum CacheError {
22    #[error("Failed to read cache file: {0}")]
23    ReadError(#[from] std::io::Error),
24
25    #[error("Failed to parse cache JSON: {0}")]
26    ParseError(#[from] serde_json::Error),
27
28    #[error("Failed to fetch API catalog: {0}")]
29    FetchError(String),
30
31    #[error("Cache directory not found")]
32    CacheDirNotFound,
33}
34
35/// Trait for cache environment access.
36///
37/// This trait abstracts filesystem operations needed for caching:
38/// - Cache directory resolution
39/// - File reading and writing
40/// - Directory creation
41///
42/// By injecting this trait, cache code becomes pure and testable.
43trait CacheEnvironment: Send + Sync {
44    /// Get the cache directory for ralph-workflow.
45    ///
46    /// In production, returns `~/.cache/ralph-workflow` or equivalent.
47    /// Returns `None` if the cache directory cannot be determined.
48    fn cache_dir(&self) -> Option<PathBuf>;
49
50    /// Read the contents of a file.
51    fn read_file(&self, path: &Path) -> io::Result<String>;
52
53    /// Write content to a file.
54    fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
55
56    /// Create directories recursively.
57    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
58}
59
60/// Production implementation of [`CacheEnvironment`].
61///
62/// Uses the `dirs` crate for cache directory resolution and `std::fs` for
63/// all file operations.
64#[derive(Debug, Default, Clone, Copy)]
65struct RealCacheEnvironment;
66
67impl CacheEnvironment for RealCacheEnvironment {
68    fn cache_dir(&self) -> Option<PathBuf> {
69        dirs::cache_dir().map(|d| d.join("ralph-workflow"))
70    }
71
72    fn read_file(&self, path: &Path) -> io::Result<String> {
73        std::fs::read_to_string(path)
74    }
75
76    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
77        std::fs::write(path, content)
78    }
79
80    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
81        std::fs::create_dir_all(path)
82    }
83}
84
85/// Get the cache file path using a custom environment.
86fn cache_file_path_with_env(env: &dyn CacheEnvironment) -> Result<PathBuf, CacheError> {
87    let cache_dir = env.cache_dir().ok_or(CacheError::CacheDirNotFound)?;
88
89    // Ensure cache directory exists
90    env.create_dir_all(&cache_dir)?;
91
92    Ok(cache_dir.join("opencode-api-cache.json"))
93}
94
95/// Load the API catalog from cache or fetch if expired.
96///
97/// This function:
98/// 1. Checks if a cached catalog exists
99/// 2. If cached and not expired, returns the cached version
100/// 3. If expired or missing, fetches a fresh catalog from the API
101/// 4. Saves the fetched catalog to disk for future use
102///
103/// Gracefully degrades on network errors: if fetching fails but a stale
104/// cache exists (< 7 days old), it will be used with a warning.
105///
106/// # Errors
107///
108/// Returns error if the operation fails.
109pub fn load_api_catalog() -> Result<ApiCatalog, CacheError> {
110    load_api_catalog_with_env(&RealCacheEnvironment)
111}
112
113/// Load the API catalog using a custom environment.
114fn load_api_catalog_with_env(env: &dyn CacheEnvironment) -> Result<ApiCatalog, CacheError> {
115    let ttl_seconds = std::env::var(CACHE_TTL_ENV_VAR)
116        .ok()
117        .and_then(|v| v.parse().ok())
118        .unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
119
120    let cache_path = cache_file_path_with_env(env)?;
121
122    // Try to load from cache
123    if let Ok(cached) = load_cached_catalog_with_env(env, &cache_path, ttl_seconds) {
124        return Ok(cached);
125    }
126
127    // Cache miss or expired, fetch from API
128    fetch_api_catalog()
129}
130
131/// Load a cached catalog from disk.
132///
133/// Returns an error if the cache file doesn't exist, is invalid, or is expired.
134fn load_cached_catalog_with_env(
135    env: &dyn CacheEnvironment,
136    path: &Path,
137    ttl_seconds: u64,
138) -> Result<ApiCatalog, CacheError> {
139    let content = env.read_file(path)?;
140
141    let mut catalog: ApiCatalog = serde_json::from_str(&content)?;
142
143    // Set the TTL for expiration checking
144    catalog.ttl_seconds = ttl_seconds;
145
146    // Check if expired
147    if catalog.is_expired() {
148        // Try to fetch fresh catalog, but use stale cache if fetch fails
149        match fetch_api_catalog() {
150            Ok(fresh) => return Ok(fresh),
151            Err(e) => {
152                // Use stale cache if it's less than 7 days old
153                if let Some(cached_at) = catalog.cached_at {
154                    let now = chrono::Utc::now();
155                    let stale_days =
156                        (now.signed_duration_since(cached_at).num_seconds() / 86400).abs();
157                    if stale_days < 7 {
158                        eprintln!(
159                            "Warning: Failed to fetch fresh OpenCode API catalog ({e}), using stale cache from {stale_days} days ago"
160                        );
161                        return Ok(catalog);
162                    }
163                }
164                return Err(CacheError::FetchError(e.to_string()));
165            }
166        }
167    }
168
169    Ok(catalog)
170}
171
172/// Save the API catalog to disk.
173///
174/// Note: Only serializes the providers and models data from the API.
175/// The `cached_at` timestamp and `ttl_seconds` are not persisted.
176pub fn save_catalog(catalog: &ApiCatalog) -> Result<(), CacheError> {
177    save_catalog_with_env(catalog, &RealCacheEnvironment)
178}
179
180/// Save the API catalog using a custom environment.
181fn save_catalog_with_env(
182    catalog: &ApiCatalog,
183    env: &dyn CacheEnvironment,
184) -> Result<(), CacheError> {
185    #[derive(serde::Serialize)]
186    struct SerializableCatalog<'a> {
187        providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
188        models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
189    }
190
191    let cache_path = cache_file_path_with_env(env)?;
192    let serializable = SerializableCatalog {
193        providers: &catalog.providers,
194        models: &catalog.models,
195    };
196    let content = serde_json::to_string_pretty(&serializable)?;
197    env.write_file(&cache_path, &content)?;
198    Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::agents::opencode_api::types::{Model, Provider};
205    use std::collections::HashMap;
206    use std::sync::{Arc, RwLock};
207
208    /// In-memory implementation of [`CacheEnvironment`] for testing.
209    ///
210    /// Provides complete isolation from the real filesystem:
211    /// - Configurable cache directory path
212    /// - In-memory file storage
213    #[derive(Debug, Clone, Default)]
214    struct MemoryCacheEnvironment {
215        cache_dir: Option<PathBuf>,
216        /// In-memory file storage.
217        files: Arc<RwLock<HashMap<PathBuf, String>>>,
218        /// Directories that have been created.
219        dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
220    }
221
222    impl MemoryCacheEnvironment {
223        /// Create a new memory environment with no paths configured.
224        fn new() -> Self {
225            Self::default()
226        }
227
228        /// Set the cache directory path.
229        #[must_use]
230        fn with_cache_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
231            self.cache_dir = Some(path.into());
232            self
233        }
234
235        /// Pre-populate a file in memory.
236        #[must_use]
237        fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
238            let path = path.into();
239            self.files.write()
240                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
241                .insert(path, content.into());
242            self
243        }
244
245        /// Get the contents of a file (for test assertions).
246        fn get_file(&self, path: &Path) -> Option<String> {
247            self.files.read()
248                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
249                .get(path).cloned()
250        }
251
252        /// Check if a file was written (for test assertions).
253        fn was_written(&self, path: &Path) -> bool {
254            self.files.read()
255                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
256                .contains_key(path)
257        }
258    }
259
260    impl CacheEnvironment for MemoryCacheEnvironment {
261        fn cache_dir(&self) -> Option<PathBuf> {
262            self.cache_dir.clone()
263        }
264
265        fn read_file(&self, path: &Path) -> io::Result<String> {
266            self.files
267                .read()
268                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
269                .get(path)
270                .cloned()
271                .ok_or_else(|| {
272                    io::Error::new(
273                        io::ErrorKind::NotFound,
274                        format!("File not found: {}", path.display()),
275                    )
276                })
277        }
278
279        fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
280            self.files
281                .write()
282                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
283                .insert(path.to_path_buf(), content.to_string());
284            Ok(())
285        }
286
287        fn create_dir_all(&self, path: &Path) -> io::Result<()> {
288            self.dirs.write()
289                .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment dirs lock")
290                .insert(path.to_path_buf());
291            Ok(())
292        }
293    }
294
295    fn create_test_catalog() -> ApiCatalog {
296        let mut providers = HashMap::new();
297        providers.insert(
298            "test".to_string(),
299            Provider {
300                id: "test".to_string(),
301                name: "Test Provider".to_string(),
302                description: "Test".to_string(),
303            },
304        );
305
306        let mut models = HashMap::new();
307        models.insert(
308            "test".to_string(),
309            vec![Model {
310                id: "test-model".to_string(),
311                name: "Test Model".to_string(),
312                description: "Test".to_string(),
313                context_length: None,
314            }],
315        );
316
317        ApiCatalog {
318            providers,
319            models,
320            cached_at: Some(chrono::Utc::now()),
321            ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
322        }
323    }
324
325    #[test]
326    fn test_memory_environment_file_operations() {
327        // Test that MemoryCacheEnvironment correctly implements file operations
328        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
329
330        let path = Path::new("/test/file.txt");
331
332        // Write file
333        env.write_file(path, "test content").unwrap();
334
335        // File can be read
336        assert_eq!(env.read_file(path).unwrap(), "test content");
337        assert!(env.was_written(path));
338    }
339
340    #[test]
341    fn test_memory_environment_with_prepopulated_file() {
342        // Test that files can be prepopulated for testing
343        let env = MemoryCacheEnvironment::new()
344            .with_cache_dir("/test/cache")
345            .with_file("/test/existing.txt", "existing content");
346
347        assert_eq!(
348            env.read_file(Path::new("/test/existing.txt")).unwrap(),
349            "existing content"
350        );
351    }
352
353    #[test]
354    fn test_cache_file_path_with_memory_env() {
355        // Test that cache_file_path_with_env returns correct path
356        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
357
358        let path = cache_file_path_with_env(&env).unwrap();
359        assert_eq!(path, PathBuf::from("/test/cache/opencode-api-cache.json"));
360    }
361
362    #[test]
363    fn test_cache_file_path_without_cache_dir() {
364        // Test that cache_file_path_with_env returns error without cache dir
365        let env = MemoryCacheEnvironment::new(); // No cache dir set
366
367        let result = cache_file_path_with_env(&env);
368        assert!(matches!(result, Err(CacheError::CacheDirNotFound)));
369    }
370
371    #[test]
372    fn test_save_and_load_catalog_with_memory_env() {
373        // Test save and load using MemoryCacheEnvironment
374        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
375
376        let catalog = create_test_catalog();
377
378        // Save catalog
379        save_catalog_with_env(&catalog, &env).unwrap();
380
381        // Verify file was written
382        let cache_path = Path::new("/test/cache/opencode-api-cache.json");
383        assert!(env.was_written(cache_path));
384
385        // Verify content is valid JSON that can be parsed
386        let content = env.get_file(cache_path).unwrap();
387        let loaded: ApiCatalog = serde_json::from_str(&content).unwrap();
388
389        assert_eq!(loaded.providers.len(), catalog.providers.len());
390        assert!(loaded.has_provider("test"));
391        assert!(loaded.has_model("test", "test-model"));
392    }
393
394    #[test]
395    fn test_catalog_serialization() {
396        #[derive(serde::Serialize)]
397        struct SerializableCatalog<'a> {
398            providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
399            models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
400        }
401
402        // Test that catalog serialization produces valid JSON
403        let catalog = create_test_catalog();
404
405        // Serialize using the same method as save_catalog
406        let serializable = SerializableCatalog {
407            providers: &catalog.providers,
408            models: &catalog.models,
409        };
410        let json = serde_json::to_string(&serializable).unwrap();
411        let deserialized: ApiCatalog = serde_json::from_str(&json).unwrap();
412
413        assert_eq!(deserialized.providers.len(), catalog.providers.len());
414        assert_eq!(deserialized.models.len(), catalog.models.len());
415    }
416
417    #[test]
418    fn test_expired_catalog_detection() {
419        // Test that expiration detection works correctly
420        let mut catalog = create_test_catalog();
421
422        // Fresh catalog should not be expired
423        assert!(!catalog.is_expired());
424
425        // Old catalog should be expired
426        catalog.cached_at = Some(
427            chrono::Utc::now()
428                - chrono::Duration::seconds(DEFAULT_CACHE_TTL_SECONDS.cast_signed() + 1),
429        );
430        assert!(catalog.is_expired());
431    }
432
433    #[test]
434    fn test_real_environment_returns_path() {
435        // Test that RealCacheEnvironment returns a valid path
436        let env = RealCacheEnvironment;
437        let cache_dir = env.cache_dir();
438
439        // Should return Some path (unless running in weird environment)
440        if let Some(dir) = cache_dir {
441            assert!(dir.to_string_lossy().contains("ralph-workflow"));
442        }
443    }
444
445    #[test]
446    fn test_production_cache_file_path_returns_correct_filename() {
447        // Test that the production cache_file_path returns a path ending in the expected filename
448        let env = RealCacheEnvironment;
449        let path = cache_file_path_with_env(&env).unwrap();
450        assert!(
451            path.ends_with("opencode-api-cache.json"),
452            "cache file should end with opencode-api-cache.json"
453        );
454    }
455}