1use std::fs::{self, File, OpenOptions};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::time::{Duration, SystemTime};
17
18use fs2::FileExt;
19
20use crate::error::{AppError, Result};
21
22pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
24
25pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
28
29#[derive(Debug, Clone)]
33pub struct Cache {
34 dir: PathBuf,
35}
36
37impl Cache {
38 pub fn for_vendor(vendor: &str) -> Result<Self> {
41 let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
42 Ok(Self { dir: base })
43 }
44
45 pub fn at(path: PathBuf) -> Self {
47 Self { dir: path }
48 }
49
50 pub fn ensure_dir(&self) -> Result<()> {
52 fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
53 }
54
55 pub fn dir(&self) -> &Path {
56 &self.dir
57 }
58
59 pub fn payload_path(&self) -> PathBuf {
60 self.dir.join("usage.json")
61 }
62 pub fn stale_path(&self) -> PathBuf {
63 self.dir.join(".stale")
64 }
65 pub fn last_error_path(&self) -> PathBuf {
66 self.dir.join(".last_error")
67 }
68 pub fn lock_path(&self) -> PathBuf {
69 self.dir.join(".fetch.lock")
70 }
71
72 pub fn payload_age(&self) -> Option<Duration> {
75 let meta = fs::metadata(self.payload_path()).ok()?;
76 let mtime = meta.modified().ok()?;
77 SystemTime::now().duration_since(mtime).ok()
78 }
79
80 pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
83 let Some(age) = self.payload_age() else {
84 return Ok(None);
85 };
86 if age < ttl {
87 self.read_payload().map(Some)
88 } else {
89 Ok(None)
90 }
91 }
92
93 pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
96 if !self.payload_path().exists() {
97 return Ok(None);
98 }
99 self.read_payload().map(Some)
100 }
101
102 fn read_payload(&self) -> Result<Vec<u8>> {
103 let p = self.payload_path();
104 let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
105 let mut buf = Vec::new();
106 f.read_to_end(&mut buf)
107 .map_err(|e| AppError::io_at(&p, e))?;
108 Ok(buf)
109 }
110
111 pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
114 self.ensure_dir()?;
115 let mut tmp = tempfile::Builder::new()
116 .prefix(".usage.")
117 .tempfile_in(&self.dir)
118 .map_err(|e| AppError::io_at(&self.dir, e))?;
119 tmp.write_all(bytes)
120 .map_err(|e| AppError::io_at(tmp.path(), e))?;
121 tmp.as_file_mut()
122 .sync_all()
123 .map_err(|e| AppError::io_at(tmp.path(), e))?;
124 tmp.persist(self.payload_path())
125 .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
126 let _ = fs::remove_file(self.stale_path());
128 let _ = fs::remove_file(self.last_error_path());
129 Ok(())
130 }
131
132 pub fn mark_stale(&self) {
134 let _ = self.ensure_dir();
135 let _ = File::create(self.stale_path());
136 }
137
138 pub fn is_stale(&self) -> bool {
139 self.stale_path().exists()
140 }
141
142 pub fn write_last_error(&self, code: u16, msg: &str) {
146 let _ = self.ensure_dir();
147 let path = self.last_error_path();
148 let body = format!("{code}\n{msg}");
149 let _ = atomic_write(&path, body.as_bytes());
150 }
151
152 pub fn read_last_error(&self) -> Option<(u16, String)> {
153 let raw = fs::read_to_string(self.last_error_path()).ok()?;
154 let mut lines = raw.lines();
155 let code = lines.next()?.parse::<u16>().ok()?;
156 let msg = lines.next().unwrap_or_default().to_string();
157 Some((code, msg))
158 }
159}
160
161pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
167 if let Some(parent) = path.parent() {
168 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
169 }
170 let f = OpenOptions::new()
171 .create(true)
172 .read(true)
173 .write(true)
174 .truncate(false)
175 .open(path)
176 .map_err(|e| AppError::io_at(path, e))?;
177
178 let deadline = std::time::Instant::now() + timeout;
179 loop {
180 match f.try_lock_exclusive() {
181 Ok(()) => return Ok(LockGuard { file: f }),
182 Err(_) => {
183 if std::time::Instant::now() >= deadline {
184 return Err(AppError::Other(format!(
185 "cache lock timeout after {:?}",
186 timeout
187 )));
188 }
189 std::thread::sleep(Duration::from_millis(50));
190 }
191 }
192 }
193}
194
195pub struct LockGuard {
199 file: File,
200}
201
202impl Drop for LockGuard {
203 fn drop(&mut self) {
204 let _ = FileExt::unlock(&self.file);
205 }
206}
207
208pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
211 let dir = path.parent().ok_or_else(|| {
212 AppError::Other(format!(
213 "atomic_write: path has no parent: {}",
214 path.display()
215 ))
216 })?;
217 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
218 let mut tmp = tempfile::Builder::new()
219 .prefix(".tmp.")
220 .tempfile_in(dir)
221 .map_err(|e| AppError::io_at(dir, e))?;
222 tmp.write_all(bytes)
223 .map_err(|e| AppError::io_at(tmp.path(), e))?;
224 tmp.as_file_mut()
225 .sync_all()
226 .map_err(|e| AppError::io_at(tmp.path(), e))?;
227 tmp.persist(path)
228 .map_err(|e| AppError::io_at(path, e.error))?;
229 Ok(())
230}
231
232fn xdg_cache_dir() -> Result<PathBuf> {
233 directories::BaseDirs::new()
234 .map(|b| b.cache_dir().to_path_buf())
235 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use tempfile::TempDir;
242
243 fn fixture() -> (TempDir, Cache) {
244 let td = TempDir::new().unwrap();
245 let cache = Cache::at(td.path().join("anthropic"));
246 cache.ensure_dir().unwrap();
247 (td, cache)
248 }
249
250 #[test]
251 fn ensure_dir_is_idempotent() {
252 let (_td, cache) = fixture();
253 cache.ensure_dir().unwrap();
254 cache.ensure_dir().unwrap();
255 assert!(cache.dir().is_dir());
256 }
257
258 #[test]
259 fn write_then_read_round_trip() {
260 let (_td, cache) = fixture();
261 cache.write_payload(b"hello world").unwrap();
262 let got = cache.maybe_payload().unwrap();
263 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
264 }
265
266 #[test]
267 fn maybe_payload_returns_none_when_missing() {
268 let (_td, cache) = fixture();
269 assert!(cache.maybe_payload().unwrap().is_none());
270 }
271
272 #[test]
273 fn fresh_payload_respects_ttl() {
274 let (_td, cache) = fixture();
275 cache.write_payload(b"x").unwrap();
276 assert!(
278 cache
279 .fresh_payload(Duration::from_secs(10))
280 .unwrap()
281 .is_some()
282 );
283 assert!(
285 cache
286 .fresh_payload(Duration::from_secs(0))
287 .unwrap()
288 .is_none()
289 );
290 }
291
292 #[test]
293 fn write_clears_stale_marker_and_last_error() {
294 let (_td, cache) = fixture();
295 cache.mark_stale();
296 cache.write_last_error(429, "rate limited");
297 assert!(cache.is_stale());
298 assert!(cache.read_last_error().is_some());
299
300 cache.write_payload(b"fresh").unwrap();
301 assert!(!cache.is_stale());
302 assert!(cache.read_last_error().is_none());
303 }
304
305 #[test]
306 fn last_error_round_trip() {
307 let (_td, cache) = fixture();
308 cache.write_last_error(503, "service unavailable");
309 let (code, msg) = cache.read_last_error().unwrap();
310 assert_eq!(code, 503);
311 assert_eq!(msg, "service unavailable");
312 }
313
314 #[test]
315 fn last_error_with_empty_message_round_trips() {
316 let (_td, cache) = fixture();
317 cache.write_last_error(429, "");
318 let (code, msg) = cache.read_last_error().unwrap();
319 assert_eq!(code, 429);
320 assert_eq!(msg, "");
321 }
322
323 #[test]
324 fn lock_serializes_concurrent_acquirers() {
325 let (_td, cache) = fixture();
328 let lock_path = cache.lock_path();
329 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
330
331 let res = acquire_lock(&lock_path, Duration::from_millis(100));
332 assert!(matches!(res, Err(AppError::Other(_))));
333 }
334
335 #[test]
336 fn atomic_write_creates_parent_dirs() {
337 let td = TempDir::new().unwrap();
338 let nested = td.path().join("a/b/c/file.txt");
339 atomic_write(&nested, b"abc").unwrap();
340 assert_eq!(fs::read(&nested).unwrap(), b"abc");
341 }
342}