use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::LlmError;
const TTL_SECS: u64 = 86_400;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteModelInfo {
pub id: String,
pub display_name: String,
pub context_window: Option<usize>,
pub created_at: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEnvelope {
fetched_at: u64,
models: Vec<RemoteModelInfo>,
}
pub struct ModelCache {
path: PathBuf,
}
impl ModelCache {
#[must_use]
pub fn for_slug(slug: &str) -> Self {
let safe: String = slug
.chars()
.map(|c| if c == '-' { '_' } else { c })
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let safe = if safe.is_empty() {
"unknown".to_string()
} else {
safe
};
let path = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("zeph")
.join("models")
.join(format!("{safe}.json"));
Self { path }
}
pub fn load(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
let Ok(data) = std::fs::read(&self.path) else {
return Ok(None);
};
let envelope: CacheEnvelope =
serde_json::from_slice(&data).map_err(|e| LlmError::Other(e.to_string()))?;
Ok(Some(envelope.models))
}
#[must_use]
pub fn is_stale(&self) -> bool {
let Ok(data) = std::fs::read(&self.path) else {
return true;
};
let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
now.saturating_sub(envelope.fetched_at) > TTL_SECS
}
pub fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LlmError::Other(format!("cache dir: {e}")))?;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let envelope = CacheEnvelope {
fetched_at: now,
models: models.to_vec(),
};
let json =
serde_json::to_vec_pretty(&envelope).map_err(|e| LlmError::Other(e.to_string()))?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, &json).map_err(|e| LlmError::Other(format!("cache write: {e}")))?;
std::fs::rename(&tmp, &self.path)
.map_err(|e| LlmError::Other(format!("cache rename: {e}")))?;
Ok(())
}
pub fn invalidate(&self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_cache() -> ModelCache {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join("zeph-test-model-cache")
.join(format!("{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
ModelCache {
path: dir.join("test.json"),
}
}
#[test]
fn missing_file_is_stale() {
let c = tmp_cache();
assert!(c.is_stale());
}
#[test]
fn fresh_cache_is_not_stale() {
let c = tmp_cache();
let models = vec![RemoteModelInfo {
id: "m1".into(),
display_name: "Model 1".into(),
context_window: Some(4096),
created_at: None,
}];
c.save(&models).unwrap();
assert!(!c.is_stale());
}
#[test]
fn json_round_trip() {
let c = tmp_cache();
let models = vec![
RemoteModelInfo {
id: "a".into(),
display_name: "Alpha".into(),
context_window: Some(8192),
created_at: Some(1_700_000_000),
},
RemoteModelInfo {
id: "b".into(),
display_name: "Beta".into(),
context_window: None,
created_at: None,
},
];
c.save(&models).unwrap();
let loaded = c.load().unwrap().unwrap();
assert_eq!(loaded, models);
}
#[test]
fn stale_detection_on_old_timestamp() {
let c = tmp_cache();
let old_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(2 * 86_400 + 1);
let envelope = super::CacheEnvelope {
fetched_at: old_ts,
models: vec![],
};
let json = serde_json::to_vec_pretty(&envelope).unwrap();
std::fs::write(&c.path, &json).unwrap();
assert!(c.is_stale());
}
#[test]
fn invalidate_removes_file() {
let c = tmp_cache();
let models = vec![];
c.save(&models).unwrap();
assert!(c.path.exists());
c.invalidate();
assert!(!c.path.exists());
}
}