use std::path::{Path, PathBuf};
use crate::auth::AuthProvider;
const CATALOG_FILE: &str = "catalog.json";
const CATALOG_META_FILE: &str = "catalog.meta.json";
pub const HERMES_HOME_ENV: &str = "HERMES_HOME";
pub fn cache_dir_for_account(provider: &AuthProvider) -> PathBuf {
let home = std::env::var(HERMES_HOME_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| format!("{}/.hermes", std::env::var("HOME").unwrap_or_else(|_| "~".to_string())));
PathBuf::from(home)
.join("cache")
.join("auth-cloudflare")
.join(provider.cache_slug())
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CatalogCacheMeta {
pub schema_version: u32,
pub fetched_at: String,
pub source: String,
pub model_count: usize,
pub account_fingerprint: String,
}
pub fn read_catalog_cache(
dir: &Path,
) -> Result<Option<(CatalogCacheMeta, serde_json::Value)>, crate::error::CloudflareError> {
let meta_path = dir.join(CATALOG_META_FILE);
let payload_path = dir.join(CATALOG_FILE);
if !meta_path.exists() || !payload_path.exists() {
return Ok(None);
}
let meta_raw = std::fs::read_to_string(&meta_path)
.map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", meta_path.display())))?;
let payload_raw = std::fs::read_to_string(&payload_path)
.map_err(|e| crate::error::CloudflareError::Http(format!("read {}: {e}", payload_path.display())))?;
let meta: CatalogCacheMeta = serde_json::from_str(&meta_raw)
.map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", meta_path.display())))?;
let payload: serde_json::Value = serde_json::from_str(&payload_raw)
.map_err(|e| crate::error::CloudflareError::Http(format!("parse {}: {e}", payload_path.display())))?;
Ok(Some((meta, payload)))
}
pub fn write_catalog_cache(
dir: &Path,
meta: &CatalogCacheMeta,
payload: &serde_json::Value,
) -> Result<(), crate::error::CloudflareError> {
std::fs::create_dir_all(dir)
.map_err(|e| crate::error::CloudflareError::Http(format!("create {}: {e}", dir.display())))?;
let payload_json = serde_json::to_string(payload)
.map_err(|e| crate::error::CloudflareError::Http(format!("serialize payload: {e}")))?;
let meta_json =
serde_json::to_string(meta).map_err(|e| crate::error::CloudflareError::Http(format!("serialize meta: {e}")))?;
atomic_write(&dir.join(CATALOG_FILE), &payload_json)?;
atomic_write(&dir.join(CATALOG_META_FILE), &meta_json)
}
pub fn cache_is_stale(meta: &CatalogCacheMeta, max_age: std::time::Duration) -> bool {
let fetched_at = match chrono::DateTime::parse_from_rfc3339(&meta.fetched_at) {
Ok(parsed) => parsed,
Err(_) => return true,
};
let max = match chrono::TimeDelta::from_std(max_age) {
Ok(d) => d,
Err(_) => chrono::TimeDelta::MAX,
};
chrono::Utc::now().signed_duration_since(fetched_at) > max
}
pub fn atomic_write(destination: &std::path::Path, contents: &str) -> Result<(), crate::error::CloudflareError> {
use std::io::Write;
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
}
let temp = destination.with_extension("tmp");
{
let mut file = std::fs::File::create(&temp).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
file.write_all(contents.as_bytes())
.map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
file.write_all(b"\n")
.map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
}
std::fs::rename(&temp, destination).map_err(|e| crate::error::CloudflareError::Http(e.to_string()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::CATALOG_SCHEMA_VERSION;
fn scratch_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("auth-cloudflare-cache-test-{}-{name}", std::process::id()))
}
#[test]
fn cache_dir_uses_auth_cloudflare_root_and_slug() {
let provider = AuthProvider::new("test-account");
let dir = cache_dir_for_account(&provider);
assert!(dir.ends_with(provider.cache_slug()));
let rendered = dir.to_string_lossy();
assert!(rendered.contains("cache/auth-cloudflare"));
assert!(!rendered.contains("cache/cloudflare/"));
}
#[test]
fn atomic_write_renames_over() {
let dir = scratch_dir("atomic-rename");
std::fs::create_dir_all(&dir).expect("create scratch dir");
let destination = dir.join("catalog.json");
atomic_write(&destination, "{}").expect("first write");
atomic_write(&destination, "{\"v\":2}").expect("overwrite");
let contents = std::fs::read_to_string(&destination).expect("read");
assert!(contents.trim() == "{\"v\":2}");
assert!(!destination.with_extension("tmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_read_roundtrip_preserves_meta_and_payload() {
let dir = scratch_dir("roundtrip");
let meta = CatalogCacheMeta {
schema_version: CATALOG_SCHEMA_VERSION,
fetched_at: "2026-09-09T15:30:00Z".to_string(),
source: "cloudflare-workers-ai".to_string(),
model_count: 27,
account_fingerprint: "0123456789abcdef".to_string(),
};
let payload = serde_json::json!({
"schema_version": 1,
"models": [{"id": "@cf/deepseek-ai/deepseek-v4-flash-0731"}]
});
write_catalog_cache(&dir, &meta, &payload).expect("write");
let (read_meta, read_payload) = read_catalog_cache(&dir).expect("read").expect("cache present");
assert_eq!(read_meta, meta);
assert_eq!(read_payload, payload);
assert!(dir.join(CATALOG_META_FILE).exists());
assert!(dir.join(CATALOG_FILE).exists());
assert!(!dir.join("catalog.tmp").exists());
assert!(!dir.join("catalog.meta.tmp").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_missing_cache_returns_none() {
let dir = scratch_dir("missing");
let _ = std::fs::remove_dir_all(&dir);
assert!(read_catalog_cache(&dir).expect("read").is_none());
std::fs::create_dir_all(&dir).expect("create");
atomic_write(&dir.join(CATALOG_META_FILE), "{}").expect("meta write");
assert!(read_catalog_cache(&dir).expect("read").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_cache_file_returns_err_without_panic() {
let dir = scratch_dir("corrupt");
let meta = CatalogCacheMeta {
schema_version: CATALOG_SCHEMA_VERSION,
fetched_at: "2026-09-09T15:30:00Z".to_string(),
source: "cloudflare-workers-ai".to_string(),
model_count: 27,
account_fingerprint: "0123456789abcdef".to_string(),
};
write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("write");
std::fs::write(dir.join(CATALOG_FILE), b"{\"models\": [truncated").expect("corrupt payload");
assert!(read_catalog_cache(&dir).is_err());
write_catalog_cache(&dir, &meta, &serde_json::json!({"models": []})).expect("rewrite");
std::fs::write(dir.join(CATALOG_META_FILE), b"not json at all{").expect("corrupt meta");
assert!(read_catalog_cache(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stale_detection_fresh_vs_old() {
let max_age = std::time::Duration::from_secs(3600);
let fresh = CatalogCacheMeta {
schema_version: CATALOG_SCHEMA_VERSION,
fetched_at: chrono::Utc::now().to_rfc3339(),
source: "cloudflare-workers-ai".to_string(),
model_count: 27,
account_fingerprint: "0123456789abcdef".to_string(),
};
assert!(!cache_is_stale(&fresh, max_age));
let ten_year_old = CatalogCacheMeta { fetched_at: "2016-01-01T00:00:00Z".to_string(), ..fresh.clone() };
assert!(cache_is_stale(&ten_year_old, max_age));
let broken = CatalogCacheMeta { fetched_at: "yesterday-ish".to_string(), ..fresh.clone() };
assert!(cache_is_stale(&broken, max_age));
}
}