ralph_workflow/agents/opencode_api/
cache.rs1use 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#[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
35fn 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
44pub 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
65pub 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
80fn 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#[derive(Debug, Clone)]
99pub enum CacheWarning {
100 StaleCacheUsed { stale_days: i64, error: String },
102 CacheSaveFailed { error: String },
104}
105
106#[derive(Debug, Clone)]
108pub struct LoadCatalogResult {
109 pub catalog: ApiCatalog,
110 pub warnings: Vec<CacheWarning>,
111}
112
113fn 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
124fn 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
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::io;
204 use std::sync::{Arc, RwLock};
205
206 #[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}