ralph_workflow/agents/opencode_api/
cache.rs1use 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#[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
35trait CacheEnvironment: Send + Sync {
44 fn cache_dir(&self) -> Option<PathBuf>;
49
50 fn read_file(&self, path: &Path) -> io::Result<String>;
52
53 fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
55
56 fn create_dir_all(&self, path: &Path) -> io::Result<()>;
58}
59
60#[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
85fn cache_file_path_with_env(env: &dyn CacheEnvironment) -> Result<PathBuf, CacheError> {
87 let cache_dir = env.cache_dir().ok_or(CacheError::CacheDirNotFound)?;
88
89 env.create_dir_all(&cache_dir)?;
91
92 Ok(cache_dir.join("opencode-api-cache.json"))
93}
94
95pub fn load_api_catalog() -> Result<ApiCatalog, CacheError> {
110 load_api_catalog_with_env(&RealCacheEnvironment)
111}
112
113fn 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 if let Ok(cached) = load_cached_catalog_with_env(env, &cache_path, ttl_seconds) {
124 return Ok(cached);
125 }
126
127 fetch_api_catalog()
129}
130
131fn 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 catalog.ttl_seconds = ttl_seconds;
145
146 if catalog.is_expired() {
148 match fetch_api_catalog() {
150 Ok(fresh) => return Ok(fresh),
151 Err(e) => {
152 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
172pub fn save_catalog(catalog: &ApiCatalog) -> Result<(), CacheError> {
177 save_catalog_with_env(catalog, &RealCacheEnvironment)
178}
179
180fn 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 #[derive(Debug, Clone, Default)]
214 struct MemoryCacheEnvironment {
215 cache_dir: Option<PathBuf>,
216 files: Arc<RwLock<HashMap<PathBuf, String>>>,
218 dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
220 }
221
222 impl MemoryCacheEnvironment {
223 fn new() -> Self {
225 Self::default()
226 }
227
228 #[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 #[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 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 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 let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
329
330 let path = Path::new("/test/file.txt");
331
332 env.write_file(path, "test content").unwrap();
334
335 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 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 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 let env = MemoryCacheEnvironment::new(); 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 let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
375
376 let catalog = create_test_catalog();
377
378 save_catalog_with_env(&catalog, &env).unwrap();
380
381 let cache_path = Path::new("/test/cache/opencode-api-cache.json");
383 assert!(env.was_written(cache_path));
384
385 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 let catalog = create_test_catalog();
404
405 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 let mut catalog = create_test_catalog();
421
422 assert!(!catalog.is_expired());
424
425 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 let env = RealCacheEnvironment;
437 let cache_dir = env.cache_dir();
438
439 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 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}