1use std::path::PathBuf;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde::{Deserialize, Serialize};
10
11use crate::LlmError;
12
13const TTL_SECS: u64 = 86_400; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct RemoteModelInfo {
18 pub id: String,
20 pub display_name: String,
22 pub context_window: Option<usize>,
24 pub created_at: Option<i64>,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
30struct CacheEnvelope {
31 fetched_at: u64,
33 models: Vec<RemoteModelInfo>,
34}
35
36pub struct ModelCache {
38 path: PathBuf,
39}
40
41impl ModelCache {
42 #[must_use]
47 pub fn for_slug(slug: &str) -> Self {
48 let safe: String = slug
49 .chars()
50 .map(|c| if c == '-' { '_' } else { c })
51 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
52 .collect();
53 let safe = if safe.is_empty() {
54 "unknown".to_string()
55 } else {
56 safe
57 };
58 let path = dirs::cache_dir()
59 .unwrap_or_else(|| PathBuf::from(".cache"))
60 .join("zeph")
61 .join("models")
62 .join(format!("{safe}.json"));
63 Self { path }
64 }
65
66 pub fn load(&self) -> Result<Option<Vec<RemoteModelInfo>>, LlmError> {
72 let Ok(data) = std::fs::read(&self.path) else {
73 return Ok(None);
74 };
75 let envelope: CacheEnvelope =
76 serde_json::from_slice(&data).map_err(|e| LlmError::Other(e.to_string()))?;
77 Ok(Some(envelope.models))
78 }
79
80 #[must_use]
82 pub fn is_stale(&self) -> bool {
83 let Ok(data) = std::fs::read(&self.path) else {
84 return true;
85 };
86 let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
87 return true;
88 };
89 let now = SystemTime::now()
90 .duration_since(UNIX_EPOCH)
91 .unwrap_or(Duration::ZERO)
92 .as_secs();
93 now.saturating_sub(envelope.fetched_at) > TTL_SECS
94 }
95
96 pub fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
102 if let Some(parent) = self.path.parent() {
103 std::fs::create_dir_all(parent)
104 .map_err(|e| LlmError::Other(format!("cache dir: {e}")))?;
105 }
106 let now = SystemTime::now()
107 .duration_since(UNIX_EPOCH)
108 .unwrap_or(Duration::ZERO)
109 .as_secs();
110 let envelope = CacheEnvelope {
111 fetched_at: now,
112 models: models.to_vec(),
113 };
114 let json =
115 serde_json::to_vec_pretty(&envelope).map_err(|e| LlmError::Other(e.to_string()))?;
116 let tmp = self.path.with_extension("json.tmp");
117 std::fs::write(&tmp, &json).map_err(|e| LlmError::Other(format!("cache write: {e}")))?;
118 std::fs::rename(&tmp, &self.path)
119 .map_err(|e| LlmError::Other(format!("cache rename: {e}")))?;
120 Ok(())
121 }
122
123 pub fn invalidate(&self) {
125 let _ = std::fs::remove_file(&self.path);
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn tmp_cache() -> ModelCache {
134 use std::sync::atomic::{AtomicU64, Ordering};
135 static COUNTER: AtomicU64 = AtomicU64::new(0);
136 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
137 let dir = std::env::temp_dir()
138 .join("zeph-test-model-cache")
139 .join(format!("{}-{id}", std::process::id()));
140 std::fs::create_dir_all(&dir).unwrap();
141 ModelCache {
142 path: dir.join("test.json"),
143 }
144 }
145
146 #[test]
147 fn missing_file_is_stale() {
148 let c = tmp_cache();
149 assert!(c.is_stale());
150 }
151
152 #[test]
153 fn fresh_cache_is_not_stale() {
154 let c = tmp_cache();
155 let models = vec![RemoteModelInfo {
156 id: "m1".into(),
157 display_name: "Model 1".into(),
158 context_window: Some(4096),
159 created_at: None,
160 }];
161 c.save(&models).unwrap();
162 assert!(!c.is_stale());
163 }
164
165 #[test]
166 fn json_round_trip() {
167 let c = tmp_cache();
168 let models = vec![
169 RemoteModelInfo {
170 id: "a".into(),
171 display_name: "Alpha".into(),
172 context_window: Some(8192),
173 created_at: Some(1_700_000_000),
174 },
175 RemoteModelInfo {
176 id: "b".into(),
177 display_name: "Beta".into(),
178 context_window: None,
179 created_at: None,
180 },
181 ];
182 c.save(&models).unwrap();
183 let loaded = c.load().unwrap().unwrap();
184 assert_eq!(loaded, models);
185 }
186
187 #[test]
188 fn stale_detection_on_old_timestamp() {
189 let c = tmp_cache();
190 let old_ts = SystemTime::now()
192 .duration_since(UNIX_EPOCH)
193 .unwrap()
194 .as_secs()
195 .saturating_sub(2 * 86_400 + 1);
196 let envelope = super::CacheEnvelope {
197 fetched_at: old_ts,
198 models: vec![],
199 };
200 let json = serde_json::to_vec_pretty(&envelope).unwrap();
201 std::fs::write(&c.path, &json).unwrap();
202 assert!(c.is_stale());
203 }
204
205 #[test]
206 fn invalidate_removes_file() {
207 let c = tmp_cache();
208 let models = vec![];
209 c.save(&models).unwrap();
210 assert!(c.path.exists());
211 c.invalidate();
212 assert!(!c.path.exists());
213 }
214}