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::CatalogHttpClient;
13use crate::agents::opencode_api::types::ApiCatalog;
14use crate::agents::opencode_api::DEFAULT_CACHE_TTL_SECONDS;
15use crate::agents::{CacheEnvironment, RealCacheEnvironment};
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/// Get the cache file path using a custom environment.
36fn cache_file_path_with_env(env: &dyn CacheEnvironment) -> Result<PathBuf, CacheError> {
37    let cache_dir = env.cache_dir().ok_or(CacheError::CacheDirNotFound)?;
38
39    env.create_dir_all(&cache_dir)?;
40
41    Ok(cache_dir.join("opencode-api-cache.json"))
42}
43
44/// Load the API catalog from cache or fetch if expired.
45///
46/// This function:
47/// 1. Checks if a cached catalog exists
48/// 2. If cached and not expired, returns the cached version
49/// 3. If expired or missing, fetches a fresh catalog from the API
50/// 4. Saves the fetched catalog to disk for future use
51///
52/// Gracefully degrades on network errors: if fetching fails but a stale
53/// cache exists (< 7 days old), it will be used with a warning.
54///
55/// # Returns
56///
57/// Returns the catalog along with any warnings encountered during loading.
58/// Warnings should be emitted by the caller at the I/O boundary.
59pub fn load_api_catalog(
60    fetcher: &dyn CatalogHttpClient,
61) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError> {
62    load_api_catalog_with_ttl(fetcher, DEFAULT_CACHE_TTL_SECONDS)
63}
64
65/// Load the API catalog with a custom TTL.
66///
67/// This is the boundary entry point that accepts TTL as a parameter
68/// (obtained from environment at the call site).
69///
70/// # Returns
71///
72/// Returns the catalog along with any warnings encountered during loading.
73pub fn load_api_catalog_with_ttl(
74    fetcher: &dyn CatalogHttpClient,
75    ttl_seconds: u64,
76) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError> {
77    load_api_catalog_with_env(&RealCacheEnvironment, ttl_seconds, fetcher)
78}
79
80/// Load the API catalog using a custom environment.
81fn load_api_catalog_with_env(
82    env: &dyn CacheEnvironment,
83    ttl_seconds: u64,
84    fetcher: &dyn CatalogHttpClient,
85) -> Result<(ApiCatalog, Vec<CacheWarning>), CacheError> {
86    let cache_path = cache_file_path_with_env(env)?;
87
88    match load_cached_catalog_with_env(env, &cache_path, ttl_seconds, fetcher) {
89        Ok(result) => Ok((result.catalog, result.warnings)),
90        Err(_) => {
91            let (catalog, warnings) = fetcher.fetch_api_catalog(ttl_seconds)?;
92            Ok((catalog, warnings))
93        }
94    }
95}
96
97/// Warnings that can occur during catalog loading.
98#[derive(Debug, Clone)]
99pub enum CacheWarning {
100    /// Used stale cache because fresh fetch failed.
101    StaleCacheUsed { stale_days: i64, error: String },
102    /// Catalog was fetched but could not be saved to cache.
103    CacheSaveFailed { error: String },
104}
105
106/// Result of loading catalog with associated warnings.
107#[derive(Debug, Clone)]
108pub struct LoadCatalogResult {
109    pub catalog: ApiCatalog,
110    pub warnings: Vec<CacheWarning>,
111}
112
113/// Pure function to check if stale cache should be used and compute warning.
114fn compute_stale_cache_warning(catalog: &ApiCatalog, fetch_error: String) -> Option<CacheWarning> {
115    let cached_at = catalog.cached_at?;
116    let now = chrono::Utc::now();
117    let stale_days = (now.signed_duration_since(cached_at).num_seconds() / 86400).abs();
118    (stale_days < 7).then_some(CacheWarning::StaleCacheUsed {
119        stale_days,
120        error: fetch_error,
121    })
122}
123
124/// Load a cached catalog from disk.
125fn load_cached_catalog_with_env(
126    env: &dyn CacheEnvironment,
127    path: &Path,
128    ttl_seconds: u64,
129    fetcher: &dyn CatalogHttpClient,
130) -> Result<LoadCatalogResult, CacheError> {
131    let content = env.read_file(path)?;
132
133    let catalog: ApiCatalog =
134        serde_json::from_str::<ApiCatalog>(&content).map(|c| ApiCatalog { ttl_seconds, ..c })?;
135
136    if catalog.is_expired() {
137        match fetcher.fetch_api_catalog(ttl_seconds) {
138            Ok((fresh, fetch_warnings)) => {
139                if let Some(warning) = fetch_warnings.into_iter().last() {
140                    return Ok(LoadCatalogResult {
141                        catalog: fresh,
142                        warnings: vec![warning],
143                    });
144                }
145                return Ok(LoadCatalogResult {
146                    catalog: fresh,
147                    warnings: vec![],
148                });
149            }
150            Err(e) => {
151                let error_str = e.to_string();
152                if let Some(warning) = compute_stale_cache_warning(&catalog, error_str.clone()) {
153                    return Ok(LoadCatalogResult {
154                        catalog,
155                        warnings: vec![warning],
156                    });
157                }
158                return Err(CacheError::FetchError(error_str));
159            }
160        }
161    }
162
163    Ok(LoadCatalogResult {
164        catalog,
165        warnings: vec![],
166    })
167}
168
169/// Save the API catalog to disk.
170///
171/// Note: Only serializes the providers and models data from the API.
172/// The `cached_at` timestamp and `ttl_seconds` are not persisted.
173pub fn save_catalog(catalog: &ApiCatalog) -> Result<(), CacheError> {
174    save_catalog_with_env(catalog, &RealCacheEnvironment)
175}
176
177/// Save the API catalog using a custom environment.
178fn save_catalog_with_env(
179    catalog: &ApiCatalog,
180    env: &dyn CacheEnvironment,
181) -> Result<(), CacheError> {
182    #[derive(serde::Serialize)]
183    struct SerializableCatalog<'a> {
184        providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
185        models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
186    }
187
188    let cache_path = cache_file_path_with_env(env)?;
189    let serializable = SerializableCatalog {
190        providers: &catalog.providers,
191        models: &catalog.models,
192    };
193    let content = serde_json::to_string_pretty(&serializable)?;
194    env.write_file(&cache_path, &content)?;
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::agents::opencode_api::types::{Model, Provider};
202    use std::collections::HashMap;
203    use std::io;
204    use std::sync::{Arc, RwLock};
205
206    /// In-memory implementation of [`CacheEnvironment`] for testing.
207    #[derive(Debug, Clone, Default)]
208    struct MemoryCacheEnvironment {
209        cache_dir: Option<PathBuf>,
210        files: Arc<RwLock<HashMap<PathBuf, String>>>,
211        dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
212    }
213
214    impl MemoryCacheEnvironment {
215        fn new() -> Self {
216            Self::default()
217        }
218
219        #[must_use]
220        fn with_cache_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
221            self.cache_dir = Some(path.into());
222            self
223        }
224
225        #[must_use]
226        fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
227            let path = path.into();
228            self.files
229                .write()
230                .expect("RwLock poisoned")
231                .insert(path, content.into());
232            self
233        }
234
235        fn get_file(&self, path: &Path) -> Option<String> {
236            self.files
237                .read()
238                .expect("RwLock poisoned")
239                .get(path)
240                .cloned()
241        }
242
243        fn was_written(&self, path: &Path) -> bool {
244            self.files
245                .read()
246                .expect("RwLock poisoned")
247                .contains_key(path)
248        }
249    }
250
251    impl CacheEnvironment for MemoryCacheEnvironment {
252        fn cache_dir(&self) -> Option<PathBuf> {
253            self.cache_dir.clone()
254        }
255
256        fn read_file(&self, path: &Path) -> io::Result<String> {
257            self.files
258                .read()
259                .expect("RwLock poisoned")
260                .get(path)
261                .cloned()
262                .ok_or_else(|| {
263                    io::Error::new(
264                        io::ErrorKind::NotFound,
265                        format!("File not found: {}", path.display()),
266                    )
267                })
268        }
269
270        fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
271            self.files
272                .write()
273                .expect("RwLock poisoned")
274                .insert(path.to_path_buf(), content.to_string());
275            Ok(())
276        }
277
278        fn create_dir_all(&self, path: &Path) -> io::Result<()> {
279            self.dirs
280                .write()
281                .expect("RwLock poisoned")
282                .insert(path.to_path_buf());
283            Ok(())
284        }
285    }
286
287    fn create_test_catalog() -> ApiCatalog {
288        let providers = HashMap::from([(
289            "test".to_string(),
290            Provider {
291                id: "test".to_string(),
292                name: "Test Provider".to_string(),
293                description: "Test".to_string(),
294            },
295        )]);
296
297        let models = HashMap::from([(
298            "test".to_string(),
299            vec![Model {
300                id: "test-model".to_string(),
301                name: "Test Model".to_string(),
302                description: "Test".to_string(),
303                context_length: None,
304            }],
305        )]);
306
307        ApiCatalog {
308            providers,
309            models,
310            cached_at: Some(chrono::Utc::now()),
311            ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
312        }
313    }
314
315    #[test]
316    fn test_memory_environment_file_operations() {
317        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
318
319        let path = Path::new("/test/file.txt");
320
321        env.write_file(path, "test content").unwrap();
322
323        assert_eq!(env.read_file(path).unwrap(), "test content");
324        assert!(env.was_written(path));
325    }
326
327    #[test]
328    fn test_memory_environment_with_prepopulated_file() {
329        let env = MemoryCacheEnvironment::new()
330            .with_cache_dir("/test/cache")
331            .with_file("/test/existing.txt", "existing content");
332
333        assert_eq!(
334            env.read_file(Path::new("/test/existing.txt")).unwrap(),
335            "existing content"
336        );
337    }
338
339    #[test]
340    fn test_cache_file_path_with_memory_env() {
341        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
342
343        let path = cache_file_path_with_env(&env).unwrap();
344        assert_eq!(path, PathBuf::from("/test/cache/opencode-api-cache.json"));
345    }
346
347    #[test]
348    fn test_cache_file_path_without_cache_dir() {
349        let env = MemoryCacheEnvironment::new();
350
351        let result = cache_file_path_with_env(&env);
352        assert!(matches!(result, Err(CacheError::CacheDirNotFound)));
353    }
354
355    #[test]
356    fn test_save_and_load_catalog_with_memory_env() {
357        let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
358
359        let catalog = create_test_catalog();
360
361        save_catalog_with_env(&catalog, &env).unwrap();
362
363        let cache_path = Path::new("/test/cache/opencode-api-cache.json");
364        assert!(env.was_written(cache_path));
365
366        let content = env.get_file(cache_path).unwrap();
367        let loaded: ApiCatalog = serde_json::from_str(&content).unwrap();
368
369        assert_eq!(loaded.providers.len(), catalog.providers.len());
370        assert!(loaded.has_provider("test"));
371        assert!(loaded.has_model("test", "test-model"));
372    }
373
374    #[test]
375    fn test_catalog_serialization() {
376        #[derive(serde::Serialize)]
377        struct SerializableCatalog<'a> {
378            providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
379            models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
380        }
381
382        let catalog = create_test_catalog();
383
384        let serializable = SerializableCatalog {
385            providers: &catalog.providers,
386            models: &catalog.models,
387        };
388        let json = serde_json::to_string(&serializable).unwrap();
389        let deserialized: ApiCatalog = serde_json::from_str(&json).unwrap();
390
391        assert_eq!(deserialized.providers.len(), catalog.providers.len());
392        assert_eq!(deserialized.models.len(), catalog.models.len());
393    }
394
395    #[test]
396    fn test_expired_catalog_detection() {
397        let catalog = create_test_catalog();
398
399        assert!(!catalog.is_expired());
400
401        let expired_catalog = ApiCatalog {
402            cached_at: Some(
403                chrono::Utc::now()
404                    - chrono::Duration::seconds(DEFAULT_CACHE_TTL_SECONDS.cast_signed() + 1),
405            ),
406            ..catalog
407        };
408        assert!(expired_catalog.is_expired());
409    }
410
411    #[test]
412    fn test_real_environment_returns_path() {
413        let env = RealCacheEnvironment;
414        let cache_dir = env.cache_dir();
415
416        if let Some(dir) = cache_dir {
417            assert!(dir.to_string_lossy().contains("ralph-workflow"));
418        }
419    }
420
421    #[test]
422    fn test_production_cache_file_path_returns_correct_filename() {
423        let env = RealCacheEnvironment;
424        let path = cache_file_path_with_env(&env).unwrap();
425        assert!(
426            path.ends_with("opencode-api-cache.json"),
427            "cache file should end with opencode-api-cache.json"
428        );
429    }
430}