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> {
106 load_api_catalog_with_env(&RealCacheEnvironment)
107}
108
109fn load_api_catalog_with_env(env: &dyn CacheEnvironment) -> Result<ApiCatalog, CacheError> {
111 let ttl_seconds = std::env::var(CACHE_TTL_ENV_VAR)
112 .ok()
113 .and_then(|v| v.parse().ok())
114 .unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
115
116 let cache_path = cache_file_path_with_env(env)?;
117
118 if let Ok(cached) = load_cached_catalog_with_env(env, &cache_path, ttl_seconds) {
120 return Ok(cached);
121 }
122
123 fetch_api_catalog()
125}
126
127fn load_cached_catalog_with_env(
131 env: &dyn CacheEnvironment,
132 path: &Path,
133 ttl_seconds: u64,
134) -> Result<ApiCatalog, CacheError> {
135 let content = env.read_file(path)?;
136
137 let mut catalog: ApiCatalog = serde_json::from_str(&content)?;
138
139 catalog.ttl_seconds = ttl_seconds;
141
142 if catalog.is_expired() {
144 match fetch_api_catalog() {
146 Ok(fresh) => return Ok(fresh),
147 Err(e) => {
148 if let Some(cached_at) = catalog.cached_at {
150 let now = chrono::Utc::now();
151 let stale_days =
152 (now.signed_duration_since(cached_at).num_seconds() / 86400).abs();
153 if stale_days < 7 {
154 eprintln!(
155 "Warning: Failed to fetch fresh OpenCode API catalog ({}), using stale cache from {} days ago",
156 e, stale_days
157 );
158 return Ok(catalog);
159 }
160 }
161 return Err(CacheError::FetchError(e.to_string()));
162 }
163 }
164 }
165
166 Ok(catalog)
167}
168
169pub fn save_catalog(catalog: &ApiCatalog) -> Result<(), CacheError> {
174 save_catalog_with_env(catalog, &RealCacheEnvironment)
175}
176
177fn 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::sync::{Arc, RwLock};
204
205 #[derive(Debug, Clone, Default)]
211 struct MemoryCacheEnvironment {
212 cache_dir: Option<PathBuf>,
213 files: Arc<RwLock<HashMap<PathBuf, String>>>,
215 dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
217 }
218
219 impl MemoryCacheEnvironment {
220 fn new() -> Self {
222 Self::default()
223 }
224
225 #[must_use]
227 fn with_cache_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
228 self.cache_dir = Some(path.into());
229 self
230 }
231
232 #[must_use]
234 fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
235 let path = path.into();
236 self.files.write()
237 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
238 .insert(path, content.into());
239 self
240 }
241
242 fn get_file(&self, path: &Path) -> Option<String> {
244 self.files.read()
245 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
246 .get(path).cloned()
247 }
248
249 fn was_written(&self, path: &Path) -> bool {
251 self.files.read()
252 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
253 .contains_key(path)
254 }
255 }
256
257 impl CacheEnvironment for MemoryCacheEnvironment {
258 fn cache_dir(&self) -> Option<PathBuf> {
259 self.cache_dir.clone()
260 }
261
262 fn read_file(&self, path: &Path) -> io::Result<String> {
263 self.files
264 .read()
265 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
266 .get(path)
267 .cloned()
268 .ok_or_else(|| {
269 io::Error::new(
270 io::ErrorKind::NotFound,
271 format!("File not found: {}", path.display()),
272 )
273 })
274 }
275
276 fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
277 self.files
278 .write()
279 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment files lock")
280 .insert(path.to_path_buf(), content.to_string());
281 Ok(())
282 }
283
284 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
285 self.dirs.write()
286 .expect("RwLock poisoned - indicates panic in another thread holding MemoryCacheEnvironment dirs lock")
287 .insert(path.to_path_buf());
288 Ok(())
289 }
290 }
291
292 fn create_test_catalog() -> ApiCatalog {
293 let mut providers = HashMap::new();
294 providers.insert(
295 "test".to_string(),
296 Provider {
297 id: "test".to_string(),
298 name: "Test Provider".to_string(),
299 description: "Test".to_string(),
300 },
301 );
302
303 let mut models = HashMap::new();
304 models.insert(
305 "test".to_string(),
306 vec![Model {
307 id: "test-model".to_string(),
308 name: "Test Model".to_string(),
309 description: "Test".to_string(),
310 context_length: None,
311 }],
312 );
313
314 ApiCatalog {
315 providers,
316 models,
317 cached_at: Some(chrono::Utc::now()),
318 ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
319 }
320 }
321
322 #[test]
323 fn test_memory_environment_file_operations() {
324 let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
326
327 let path = Path::new("/test/file.txt");
328
329 env.write_file(path, "test content").unwrap();
331
332 assert_eq!(env.read_file(path).unwrap(), "test content");
334 assert!(env.was_written(path));
335 }
336
337 #[test]
338 fn test_memory_environment_with_prepopulated_file() {
339 let env = MemoryCacheEnvironment::new()
341 .with_cache_dir("/test/cache")
342 .with_file("/test/existing.txt", "existing content");
343
344 assert_eq!(
345 env.read_file(Path::new("/test/existing.txt")).unwrap(),
346 "existing content"
347 );
348 }
349
350 #[test]
351 fn test_cache_file_path_with_memory_env() {
352 let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
354
355 let path = cache_file_path_with_env(&env).unwrap();
356 assert_eq!(path, PathBuf::from("/test/cache/opencode-api-cache.json"));
357 }
358
359 #[test]
360 fn test_cache_file_path_without_cache_dir() {
361 let env = MemoryCacheEnvironment::new(); let result = cache_file_path_with_env(&env);
365 assert!(matches!(result, Err(CacheError::CacheDirNotFound)));
366 }
367
368 #[test]
369 fn test_save_and_load_catalog_with_memory_env() {
370 let env = MemoryCacheEnvironment::new().with_cache_dir("/test/cache");
372
373 let catalog = create_test_catalog();
374
375 save_catalog_with_env(&catalog, &env).unwrap();
377
378 let cache_path = Path::new("/test/cache/opencode-api-cache.json");
380 assert!(env.was_written(cache_path));
381
382 let content = env.get_file(cache_path).unwrap();
384 let loaded: ApiCatalog = serde_json::from_str(&content).unwrap();
385
386 assert_eq!(loaded.providers.len(), catalog.providers.len());
387 assert!(loaded.has_provider("test"));
388 assert!(loaded.has_model("test", "test-model"));
389 }
390
391 #[test]
392 fn test_catalog_serialization() {
393 let catalog = create_test_catalog();
395
396 #[derive(serde::Serialize)]
398 struct SerializableCatalog<'a> {
399 providers: &'a std::collections::HashMap<String, crate::agents::opencode_api::Provider>,
400 models: &'a std::collections::HashMap<String, Vec<crate::agents::opencode_api::Model>>,
401 }
402 let serializable = SerializableCatalog {
403 providers: &catalog.providers,
404 models: &catalog.models,
405 };
406 let json = serde_json::to_string(&serializable).unwrap();
407 let deserialized: ApiCatalog = serde_json::from_str(&json).unwrap();
408
409 assert_eq!(deserialized.providers.len(), catalog.providers.len());
410 assert_eq!(deserialized.models.len(), catalog.models.len());
411 }
412
413 #[test]
414 fn test_expired_catalog_detection() {
415 let mut catalog = create_test_catalog();
417
418 assert!(!catalog.is_expired());
420
421 catalog.cached_at = Some(
423 chrono::Utc::now() - chrono::Duration::seconds(DEFAULT_CACHE_TTL_SECONDS as i64 + 1),
424 );
425 assert!(catalog.is_expired());
426 }
427
428 #[test]
429 fn test_real_environment_returns_path() {
430 let env = RealCacheEnvironment;
432 let cache_dir = env.cache_dir();
433
434 if let Some(dir) = cache_dir {
436 assert!(dir.to_string_lossy().contains("ralph-workflow"));
437 }
438 }
439
440 #[test]
441 fn test_production_cache_file_path_returns_correct_filename() {
442 let env = RealCacheEnvironment;
444 let path = cache_file_path_with_env(&env).unwrap();
445 assert!(
446 path.ends_with("opencode-api-cache.json"),
447 "cache file should end with opencode-api-cache.json"
448 );
449 }
450}