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 clear_last_error(&self) {
154 let _ = fs::remove_file(self.last_error_path());
155 }
156
157 pub fn read_last_error(&self) -> Option<(u16, String)> {
158 let raw = fs::read_to_string(self.last_error_path()).ok()?;
159 let mut lines = raw.lines();
160 let code = lines.next()?.parse::<u16>().ok()?;
161 let msg = lines.next().unwrap_or_default().to_string();
162 Some((code, msg))
163 }
164}
165
166pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
172 if let Some(parent) = path.parent() {
173 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
174 }
175 let f = OpenOptions::new()
176 .create(true)
177 .read(true)
178 .write(true)
179 .truncate(false)
180 .open(path)
181 .map_err(|e| AppError::io_at(path, e))?;
182
183 let deadline = std::time::Instant::now() + timeout;
184 loop {
185 match f.try_lock_exclusive() {
186 Ok(()) => return Ok(LockGuard { file: f }),
187 Err(_) => {
188 if std::time::Instant::now() >= deadline {
189 return Err(AppError::Other(format!(
190 "cache lock timeout after {:?}",
191 timeout
192 )));
193 }
194 std::thread::sleep(Duration::from_millis(50));
195 }
196 }
197 }
198}
199
200pub struct LockGuard {
204 file: File,
205}
206
207impl Drop for LockGuard {
208 fn drop(&mut self) {
209 let _ = FileExt::unlock(&self.file);
210 }
211}
212
213pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
216 let dir = path.parent().ok_or_else(|| {
217 AppError::Other(format!(
218 "atomic_write: path has no parent: {}",
219 path.display()
220 ))
221 })?;
222 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
223 let mut tmp = tempfile::Builder::new()
224 .prefix(".tmp.")
225 .tempfile_in(dir)
226 .map_err(|e| AppError::io_at(dir, e))?;
227 tmp.write_all(bytes)
228 .map_err(|e| AppError::io_at(tmp.path(), e))?;
229 tmp.as_file_mut()
230 .sync_all()
231 .map_err(|e| AppError::io_at(tmp.path(), e))?;
232 tmp.persist(path)
233 .map_err(|e| AppError::io_at(path, e.error))?;
234 Ok(())
235}
236
237fn xdg_cache_dir() -> Result<PathBuf> {
238 directories::BaseDirs::new()
239 .map(|b| b.cache_dir().to_path_buf())
240 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
241}
242
243pub fn home_dir() -> Result<PathBuf> {
251 directories::BaseDirs::new()
252 .map(|b| b.home_dir().to_path_buf())
253 .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
254}
255
256#[cfg(test)]
263pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
264 let dir = tempfile::TempDir::new().unwrap();
265 let path = dir.path().join(name);
266 if let Some(c) = contents {
267 std::fs::write(&path, c).unwrap();
268 }
269 (dir, path)
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use tempfile::TempDir;
276
277 fn fixture() -> (TempDir, Cache) {
278 let td = TempDir::new().unwrap();
279 let cache = Cache::at(td.path().join("anthropic"));
280 cache.ensure_dir().unwrap();
281 (td, cache)
282 }
283
284 #[test]
285 fn ensure_dir_is_idempotent() {
286 let (_td, cache) = fixture();
287 cache.ensure_dir().unwrap();
288 cache.ensure_dir().unwrap();
289 assert!(cache.dir().is_dir());
290 }
291
292 #[test]
293 fn write_then_read_round_trip() {
294 let (_td, cache) = fixture();
295 cache.write_payload(b"hello world").unwrap();
296 let got = cache.maybe_payload().unwrap();
297 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
298 }
299
300 #[test]
301 fn maybe_payload_returns_none_when_missing() {
302 let (_td, cache) = fixture();
303 assert!(cache.maybe_payload().unwrap().is_none());
304 }
305
306 #[test]
307 fn fresh_payload_respects_ttl() {
308 let (_td, cache) = fixture();
309 cache.write_payload(b"x").unwrap();
310 assert!(
312 cache
313 .fresh_payload(Duration::from_secs(10))
314 .unwrap()
315 .is_some()
316 );
317 assert!(
319 cache
320 .fresh_payload(Duration::from_secs(0))
321 .unwrap()
322 .is_none()
323 );
324 }
325
326 #[test]
327 fn write_clears_stale_marker_and_last_error() {
328 let (_td, cache) = fixture();
329 cache.mark_stale();
330 cache.write_last_error(429, "rate limited");
331 assert!(cache.is_stale());
332 assert!(cache.read_last_error().is_some());
333
334 cache.write_payload(b"fresh").unwrap();
335 assert!(!cache.is_stale());
336 assert!(cache.read_last_error().is_none());
337 }
338
339 #[test]
340 fn last_error_round_trip() {
341 let (_td, cache) = fixture();
342 cache.write_last_error(503, "service unavailable");
343 let (code, msg) = cache.read_last_error().unwrap();
344 assert_eq!(code, 503);
345 assert_eq!(msg, "service unavailable");
346 }
347
348 #[test]
349 fn last_error_with_empty_message_round_trips() {
350 let (_td, cache) = fixture();
351 cache.write_last_error(429, "");
352 let (code, msg) = cache.read_last_error().unwrap();
353 assert_eq!(code, 429);
354 assert_eq!(msg, "");
355 }
356
357 #[test]
358 fn lock_serializes_concurrent_acquirers() {
359 let (_td, cache) = fixture();
362 let lock_path = cache.lock_path();
363 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
364
365 let res = acquire_lock(&lock_path, Duration::from_millis(100));
366 assert!(matches!(res, Err(AppError::Other(_))));
367 }
368
369 #[test]
370 fn atomic_write_creates_parent_dirs() {
371 let td = TempDir::new().unwrap();
372 let nested = td.path().join("a/b/c/file.txt");
373 atomic_write(&nested, b"abc").unwrap();
374 assert_eq!(fs::read(&nested).unwrap(), b"abc");
375 }
376}