ralph_workflow/agents/opencode_api/
cache.rs1use 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#[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
28pub 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 std::fs::create_dir_all(&cache_dir)?;
38
39 Ok(cache_dir.join("opencode-api-cache.json"))
40}
41
42pub 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 if let Ok(cached) = load_cached_catalog(&cache_path, ttl_seconds) {
62 return Ok(cached);
63 }
64
65 fetch_api_catalog()
67}
68
69fn 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 catalog.ttl_seconds = ttl_seconds;
79
80 if catalog.is_expired() {
82 match fetch_api_catalog() {
84 Ok(fresh) => return Ok(fresh),
85 Err(e) => {
86 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
107pub 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 #[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 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 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 #[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 assert!(!catalog.is_expired());
228
229 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}