1use std::path::{Path, PathBuf};
15
16use crate::auth::AuthProvider;
17
18const CATALOG_FILE: &str = "catalog.json";
20const CATALOG_META_FILE: &str = "catalog.meta.json";
22
23pub const HERMES_HOME_ENV: &str = "HERMES_HOME";
25
26pub fn cache_dir_for_account(provider: &AuthProvider) -> PathBuf {
32 let home = std::env::var(HERMES_HOME_ENV)
33 .ok()
34 .filter(|v| !v.trim().is_empty())
35 .unwrap_or_else(|| format!("{}/.hermes", std::env::var("HOME").unwrap_or_else(|_| "~".to_string())));
36 PathBuf::from(home)
37 .join("cache")
38 .join("auth-cloudflare")
39 .join(provider.cache_slug())
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub struct CatalogCacheMeta {
47 pub schema_version: u32,
49 pub fetched_at: String,
51 pub source: String,
53 pub model_count: usize,
55 pub account_fingerprint: String,
57}
58
59pub fn read_catalog_cache(
66 dir: &Path,
67) -> Result<Option<(CatalogCacheMeta, serde_json::Value)>, crate::error::CloudflareError> {
68 let meta_path = dir.join(CATALOG_META_FILE);
69 let payload_path = dir.join(CATALOG_FILE);
70 if !meta_path.exists() || !payload_path.exists() {
71 return Ok(None);
72 }
73 let meta_raw = std::fs::read_to_string(&meta_path)
74 .map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", meta_path.display())))?;
75 let payload_raw = std::fs::read_to_string(&payload_path)
76 .map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", payload_path.display())))?;
77 let meta: CatalogCacheMeta = serde_json::from_str(&meta_raw)
78 .map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", meta_path.display())))?;
79 let payload: serde_json::Value = serde_json::from_str(&payload_raw)
80 .map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", payload_path.display())))?;
81 Ok(Some((meta, payload)))
82}
83
84pub fn write_catalog_cache(
91 dir: &Path,
92 meta: &CatalogCacheMeta,
93 payload: &serde_json::Value,
94) -> Result<(), crate::error::CloudflareError> {
95 std::fs::create_dir_all(dir)
96 .map_err(|e| crate::error::CloudflareError::Http(format!("create {}: {e}", dir.display())))?;
97 let payload_json = serde_json::to_string(payload)
98 .map_err(|e| crate::error::CloudflareError::Http(format!("serialize payload: {e}")))?;
99 let meta_json =
100 serde_json::to_string(meta).map_err(|e| crate::error::CloudflareError::Http(format!("serialize meta: {e}")))?;
101 atomic_write(&dir.join(CATALOG_FILE), &payload_json)?;
102 atomic_write(&dir.join(CATALOG_META_FILE), &meta_json)
103}
104
105pub fn cache_is_stale(meta: &CatalogCacheMeta, max_age: std::time::Duration) -> bool {
111 let fetched_at = match chrono::DateTime::parse_from_rfc3339(&meta.fetched_at) {
112 Ok(parsed) => parsed,
113 Err(_) => return true,
114 };
115 let max = match chrono::TimeDelta::from_std(max_age) {
116 Ok(d) => d,
117 Err(_) => chrono::TimeDelta::MAX,
119 };
120 chrono::Utc::now().signed_duration_since(fetched_at) > max
121}
122
123pub fn atomic_write(destination: &std::path::Path, contents: &str) -> Result<(), crate::error::CloudflareError> {
126 use std::io::Write;
127 if let Some(parent) = destination.parent() {
128 std::fs::create_dir_all(parent).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
129 }
130 let temp = destination.with_extension("tmp");
131 {
132 let mut file = std::fs::File::create(&temp).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
133 file.write_all(contents.as_bytes())
134 .map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
135 file.write_all(b"\n")
136 .map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
137 }
138 std::fs::rename(&temp, destination).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
139 #[cfg(unix)]
141 {
142 use std::os::unix::fs::PermissionsExt;
143 let _ = std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o600));
144 }
145 Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::schema::CATALOG_SCHEMA_VERSION;
152
153 fn scratch_dir(name: &str) -> PathBuf {
155 std::env::temp_dir().join(format!("auth-cloudflare-cache-test-{}-{name}", std::process::id()))
156 }
157
158 #[test]
159 fn cache_dir_uses_auth_cloudflare_root_and_slug() {
160 let provider = AuthProvider::new("test-account");
161 let dir = cache_dir_for_account(&provider);
162 assert!(dir.ends_with(provider.cache_slug()));
163 let rendered = dir.to_string_lossy();
164 assert!(rendered.contains("cache/auth-cloudflare"));
165 assert!(!rendered.contains("cache/cloudflare/"));
166 }
167
168 #[test]
169 fn atomic_write_renames_over() {
170 let dir = scratch_dir("atomic-rename");
171 std::fs::create_dir_all(&dir).expect("create scratch dir");
172 let destination = dir.join("catalog.json");
173 atomic_write(&destination, "{}").expect("first write");
174 atomic_write(&destination, "{\"v\":2}").expect("overwrite");
175 let contents = std::fs::read_to_string(&destination).expect("read");
176 assert!(contents.trim() == "{\"v\":2}");
177 assert!(!destination.with_extension("tmp").exists());
179 let _ = std::fs::remove_dir_all(&dir);
180 }
181
182 #[test]
183 fn write_read_roundtrip_preserves_meta_and_payload() {
184 let dir = scratch_dir("roundtrip");
185 let meta = CatalogCacheMeta {
186 schema_version: CATALOG_SCHEMA_VERSION,
187 fetched_at: "2026-09-09T15:30:00Z".to_string(),
188 source: "cloudflare-workers-ai".to_string(),
189 model_count: 27,
190 account_fingerprint: "0123456789abcdef".to_string(),
191 };
192 let payload = serde_json::json!({
193 "schema_version": 1,
194 "models": [{"id": "@cf/deepseek-ai/deepseek-v4-flash-0731"}]
195 });
196 write_catalog_cache(&dir, &meta, &payload).expect("write");
197 let (read_meta, read_payload) = read_catalog_cache(&dir).expect("read").expect("cache present");
198 assert_eq!(read_meta, meta);
199 assert_eq!(read_payload, payload);
200 assert!(dir.join(CATALOG_META_FILE).exists());
202 assert!(dir.join(CATALOG_FILE).exists());
203 assert!(!dir.join("catalog.tmp").exists());
204 assert!(!dir.join("catalog.meta.tmp").exists());
205 let _ = std::fs::remove_dir_all(&dir);
206 }
207
208 #[test]
209 fn read_missing_cache_returns_none() {
210 let dir = scratch_dir("missing");
211 let _ = std::fs::remove_dir_all(&dir);
212 assert!(read_catalog_cache(&dir).expect("read").is_none());
213 std::fs::create_dir_all(&dir).expect("create");
215 atomic_write(&dir.join(CATALOG_META_FILE), "{}").expect("meta write");
216 assert!(read_catalog_cache(&dir).expect("read").is_none());
217 let _ = std::fs::remove_dir_all(&dir);
218 }
219
220 #[test]
221 fn corrupt_cache_file_returns_err_without_panic() {
222 let dir = scratch_dir("corrupt");
223 let meta = CatalogCacheMeta {
224 schema_version: CATALOG_SCHEMA_VERSION,
225 fetched_at: "2026-09-09T15:30:00Z".to_string(),
226 source: "cloudflare-workers-ai".to_string(),
227 model_count: 27,
228 account_fingerprint: "0123456789abcdef".to_string(),
229 };
230 write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("write");
231
232 std::fs::write(dir.join(CATALOG_FILE), b"{\"models\": [truncated").expect("corrupt payload");
234 assert!(read_catalog_cache(&dir).is_err());
235
236 write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("rewrite");
238 std::fs::write(dir.join(CATALOG_META_FILE), b"not json at all{").expect("corrupt meta");
239 assert!(read_catalog_cache(&dir).is_err());
240
241 let _ = std::fs::remove_dir_all(&dir);
242 }
243
244 #[test]
245 fn stale_detection_fresh_vs_old() {
246 let max_age = std::time::Duration::from_secs(3600);
247 let fresh = CatalogCacheMeta {
248 schema_version: CATALOG_SCHEMA_VERSION,
249 fetched_at: chrono::Utc::now().to_rfc3339(),
250 source: "cloudflare-workers-ai".to_string(),
251 model_count: 27,
252 account_fingerprint: "0123456789abcdef".to_string(),
253 };
254 assert!(!cache_is_stale(&fresh, max_age));
255
256 let ten_year_old = CatalogCacheMeta { fetched_at: "2016-01-01T00:00:00Z".to_string(), ..fresh.clone() };
257 assert!(cache_is_stale(&ten_year_old, max_age));
258
259 let broken = CatalogCacheMeta { fetched_at: "yesterday-ish".to_string(), ..fresh.clone() };
261 assert!(cache_is_stale(&broken, max_age));
262 }
263}