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 = serde_json::from_slice(&data).map_err(LlmError::Json)?;
76 Ok(Some(envelope.models))
77 }
78
79 #[must_use]
81 pub fn is_stale(&self) -> bool {
82 let Ok(data) = std::fs::read(&self.path) else {
83 return true;
84 };
85 let Ok(envelope) = serde_json::from_slice::<CacheEnvelope>(&data) else {
86 return true;
87 };
88 let now = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .unwrap_or(Duration::ZERO)
91 .as_secs();
92 now.saturating_sub(envelope.fetched_at) > TTL_SECS
93 }
94
95 pub fn save(&self, models: &[RemoteModelInfo]) -> Result<(), LlmError> {
101 if let Some(parent) = self.path.parent() {
102 std::fs::create_dir_all(parent).map_err(LlmError::Io)?;
103 }
104 let now = SystemTime::now()
105 .duration_since(UNIX_EPOCH)
106 .unwrap_or(Duration::ZERO)
107 .as_secs();
108 let envelope = CacheEnvelope {
109 fetched_at: now,
110 models: models.to_vec(),
111 };
112 let json = serde_json::to_vec_pretty(&envelope).map_err(LlmError::Json)?;
113 zeph_common::fs_secure::atomic_write_private(&self.path, &json).map_err(LlmError::Io)?;
114 Ok(())
115 }
116
117 pub fn invalidate(&self) {
119 let _ = std::fs::remove_file(&self.path);
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn tmp_cache() -> ModelCache {
128 use std::sync::atomic::{AtomicU64, Ordering};
129 static COUNTER: AtomicU64 = AtomicU64::new(0);
130 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
131 let dir = std::env::temp_dir()
132 .join("zeph-test-model-cache")
133 .join(format!("{}-{id}", std::process::id()));
134 std::fs::create_dir_all(&dir).unwrap();
135 ModelCache {
136 path: dir.join("test.json"),
137 }
138 }
139
140 #[test]
141 fn missing_file_is_stale() {
142 let c = tmp_cache();
143 assert!(c.is_stale());
144 }
145
146 #[test]
147 fn fresh_cache_is_not_stale() {
148 let c = tmp_cache();
149 let models = vec![RemoteModelInfo {
150 id: "m1".into(),
151 display_name: "Model 1".into(),
152 context_window: Some(4096),
153 created_at: None,
154 }];
155 c.save(&models).unwrap();
156 assert!(!c.is_stale());
157 }
158
159 #[test]
160 fn json_round_trip() {
161 let c = tmp_cache();
162 let models = vec![
163 RemoteModelInfo {
164 id: "a".into(),
165 display_name: "Alpha".into(),
166 context_window: Some(8192),
167 created_at: Some(1_700_000_000),
168 },
169 RemoteModelInfo {
170 id: "b".into(),
171 display_name: "Beta".into(),
172 context_window: None,
173 created_at: None,
174 },
175 ];
176 c.save(&models).unwrap();
177 let loaded = c.load().unwrap().unwrap();
178 assert_eq!(loaded, models);
179 }
180
181 #[test]
182 fn stale_detection_on_old_timestamp() {
183 let c = tmp_cache();
184 let old_ts = SystemTime::now()
186 .duration_since(UNIX_EPOCH)
187 .unwrap()
188 .as_secs()
189 .saturating_sub(2 * 86_400 + 1);
190 let envelope = super::CacheEnvelope {
191 fetched_at: old_ts,
192 models: vec![],
193 };
194 let json = serde_json::to_vec_pretty(&envelope).unwrap();
195 std::fs::write(&c.path, &json).unwrap();
196 assert!(c.is_stale());
197 }
198
199 #[test]
200 fn invalidate_removes_file() {
201 let c = tmp_cache();
202 let models = vec![];
203 c.save(&models).unwrap();
204 assert!(c.path.exists());
205 c.invalidate();
206 assert!(!c.path.exists());
207 }
208
209 #[test]
210 fn cache_save_uses_json_tmp_atomic_suffix() {
211 let path = std::path::PathBuf::from("/tmp/models.json");
212 let tmp = path.with_added_extension("tmp");
213 assert_eq!(tmp.file_name().unwrap(), "models.json.tmp");
214 }
215}