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
6use crate::agents::opencode_api::fetch::fetch_api_catalog;
7use crate::agents::opencode_api::types::ApiCatalog;
8use crate::agents::opencode_api::{CACHE_TTL_ENV_VAR, DEFAULT_CACHE_TTL_SECONDS};
9use std::path::PathBuf;
10use thiserror::Error;
11
12/// Errors that can occur when loading the API catalog.
13#[derive(Debug, Error)]
14pub enum CacheError {
15    #[error("Failed to read cache file: {0}")]
16    ReadError(#[from] std::io::Error),
17
18    #[error("Failed to parse cache JSON: {0}")]
19    ParseError(#[from] serde_json::Error),
20
21    #[error("Failed to fetch API catalog: {0}")]
22    FetchError(String),
23
24    #[error("Cache directory not found")]
25    CacheDirNotFound,
26}
27
28/// Get the cache file path for the OpenCode API catalog.
29///
30/// Cache location: `~/.cache/ralph-workflow/opencode-api-cache.json`
31pub fn cache_file_path() -> Result<PathBuf, CacheError> {
32    let cache_dir = dirs::cache_dir()
33        .ok_or(CacheError::CacheDirNotFound)?
34        .join("ralph-workflow");
35
36    // Ensure cache directory exists
37    std::fs::create_dir_all(&cache_dir)?;
38
39    Ok(cache_dir.join("opencode-api-cache.json"))
40}
41
42/// Load the API catalog from cache or fetch if expired.
43///
44/// This function:
45/// 1. Checks if a cached catalog exists
46/// 2. If cached and not expired, returns the cached version
47/// 3. If expired or missing, fetches a fresh catalog from the API
48/// 4. Saves the fetched catalog to disk for future use
49///
50/// Gracefully degrades on network errors: if fetching fails but a stale
51/// cache exists (< 7 days old), it will be used with a warning.
52pub fn load_api_catalog() -> Result<ApiCatalog, CacheError> {
53    let ttl_seconds = std::env::var(CACHE_TTL_ENV_VAR)
54        .ok()
55        .and_then(|v| v.parse().ok())
56        .unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
57
58    let cache_path = cache_file_path()?;
59
60    // Try to load from cache
61    if let Ok(cached) = load_cached_catalog(&cache_path, ttl_seconds) {
62        return Ok(cached);
63    }
64
65    // Cache miss or expired, fetch from API
66    fetch_api_catalog()
67}
68
69/// Load a cached catalog from disk.
70///
71/// Returns None if the cache file doesn't exist, is invalid, or is expired.
72fn load_cached_catalog(path: &PathBuf, ttl_seconds: u64) -> Result<ApiCatalog, CacheError> {
73    let content = std::fs::read_to_string(path)?;
74
75    let mut catalog: ApiCatalog = serde_json::from_str(&content)?;
76
77    // Set the TTL for expiration checking
78    catalog.ttl_seconds = ttl_seconds;
79
80    // Check if expired
81    if catalog.is_expired() {
82        // Try to fetch fresh catalog, but use stale cache if fetch fails
83        match fetch_api_catalog() {
84            Ok(fresh) => return Ok(fresh),
85            Err(e) => {
86                // Use stale cache if it's less than 7 days old
87                if let Some(cached_at) = catalog.cached_at {
88                    let now = chrono::Utc::now();
89                    let stale_days =
90                        (now.signed_duration_since(cached_at).num_seconds() / 86400).abs();
91                    if stale_days < 7 {
92                        eprintln!(
93                            "Warning: Failed to fetch fresh OpenCode API catalog ({}), using stale cache from {} days ago",
94                            e, stale_days
95                        );
96                        return Ok(catalog);
97                    }
98                }
99                return Err(CacheError::FetchError(e.to_string()));
100            }
101        }
102    }
103
104    Ok(catalog)
105}
106
107/// Save the API catalog to disk.
108///
109/// Note: Only serializes the providers and models data from the API.
110/// The cached_at timestamp and ttl_seconds are not persisted.
111pub fn save_catalog(catalog: &ApiCatalog) -> Result<(), CacheError> {
112    #[derive(serde::Serialize)]
113    struct SerializableCatalog<'a> {
114        providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
115        models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
116    }
117
118    let cache_path = cache_file_path()?;
119    let serializable = SerializableCatalog {
120        providers: &catalog.providers,
121        models: &catalog.models,
122    };
123    let content = serde_json::to_string_pretty(&serializable)?;
124    std::fs::write(&cache_path, content)?;
125    Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::agents::opencode_api::types::{Model, Provider};
132    use std::collections::HashMap;
133    use tempfile::TempDir;
134
135    fn create_test_catalog() -> ApiCatalog {
136        let mut providers = HashMap::new();
137        providers.insert(
138            "test".to_string(),
139            Provider {
140                id: "test".to_string(),
141                name: "Test Provider".to_string(),
142                description: "Test".to_string(),
143            },
144        );
145
146        let mut models = HashMap::new();
147        models.insert(
148            "test".to_string(),
149            vec![Model {
150                id: "test-model".to_string(),
151                name: "Test Model".to_string(),
152                description: "Test".to_string(),
153                context_length: None,
154            }],
155        );
156
157        ApiCatalog {
158            providers,
159            models,
160            cached_at: Some(chrono::Utc::now()),
161            ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
162        }
163    }
164
165    #[test]
166    fn test_save_and_load_catalog() {
167        let temp_dir = TempDir::new().unwrap();
168        let cache_path = temp_dir.path().join("test-cache.json");
169
170        let catalog = create_test_catalog();
171
172        // Save catalog using SerializableCatalog wrapper
173        #[derive(serde::Serialize)]
174        struct SerializableCatalog<'a> {
175            providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
176            models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
177        }
178        let serializable = SerializableCatalog {
179            providers: &catalog.providers,
180            models: &catalog.models,
181        };
182        let content = serde_json::to_string_pretty(&serializable).unwrap();
183        std::fs::write(&cache_path, content).unwrap();
184
185        // Load catalog
186        let loaded_content = std::fs::read_to_string(&cache_path).unwrap();
187        let loaded: ApiCatalog = serde_json::from_str(&loaded_content).unwrap();
188
189        assert_eq!(loaded.providers.len(), catalog.providers.len());
190        assert!(loaded.has_provider("test"));
191        assert!(loaded.has_model("test", "test-model"));
192
193        // Verify cache file path ends correctly
194        let original_path = cache_file_path().unwrap();
195        assert!(
196            original_path.ends_with("opencode-api-cache.json"),
197            "cache file should end with opencode-api-cache.json"
198        );
199    }
200
201    #[test]
202    fn test_catalog_serialization() {
203        let catalog = create_test_catalog();
204
205        // Serialize using the same method as save_catalog
206        #[derive(serde::Serialize)]
207        struct SerializableCatalog<'a> {
208            providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
209            models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
210        }
211        let serializable = SerializableCatalog {
212            providers: &catalog.providers,
213            models: &catalog.models,
214        };
215        let json = serde_json::to_string(&serializable).unwrap();
216        let deserialized: ApiCatalog = serde_json::from_str(&json).unwrap();
217
218        assert_eq!(deserialized.providers.len(), catalog.providers.len());
219        assert_eq!(deserialized.models.len(), catalog.models.len());
220    }
221
222    #[test]
223    fn test_expired_catalog_detection() {
224        let mut catalog = create_test_catalog();
225
226        // Fresh catalog should not be expired
227        assert!(!catalog.is_expired());
228
229        // Old catalog should be expired
230        catalog.cached_at = Some(
231            chrono::Utc::now() - chrono::Duration::seconds(DEFAULT_CACHE_TTL_SECONDS as i64 + 1),
232        );
233        assert!(catalog.is_expired());
234    }
235}