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 for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
50 let base = xdg_cache_dir()?
51 .join("ai-usagebar")
52 .join(vendor)
53 .join(label);
54 Ok(Self { dir: base })
55 }
56
57 pub fn at(path: PathBuf) -> Self {
59 Self { dir: path }
60 }
61
62 pub fn ensure_dir(&self) -> Result<()> {
64 fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
65 }
66
67 pub fn dir(&self) -> &Path {
68 &self.dir
69 }
70
71 pub fn payload_path(&self) -> PathBuf {
72 self.dir.join("usage.json")
73 }
74 pub fn stale_path(&self) -> PathBuf {
75 self.dir.join(".stale")
76 }
77 pub fn last_error_path(&self) -> PathBuf {
78 self.dir.join(".last_error")
79 }
80 pub fn lock_path(&self) -> PathBuf {
81 self.dir.join(".fetch.lock")
82 }
83
84 pub fn payload_age(&self) -> Option<Duration> {
87 let meta = fs::metadata(self.payload_path()).ok()?;
88 let mtime = meta.modified().ok()?;
89 SystemTime::now().duration_since(mtime).ok()
90 }
91
92 pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
95 let Some(age) = self.payload_age() else {
96 return Ok(None);
97 };
98 if age < ttl {
99 self.read_payload().map(Some)
100 } else {
101 Ok(None)
102 }
103 }
104
105 pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
108 if !self.payload_path().exists() {
109 return Ok(None);
110 }
111 self.read_payload().map(Some)
112 }
113
114 fn read_payload(&self) -> Result<Vec<u8>> {
115 let p = self.payload_path();
116 let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
117 let mut buf = Vec::new();
118 f.read_to_end(&mut buf)
119 .map_err(|e| AppError::io_at(&p, e))?;
120 Ok(buf)
121 }
122
123 pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
126 self.ensure_dir()?;
127 let mut tmp = tempfile::Builder::new()
128 .prefix(".usage.")
129 .tempfile_in(&self.dir)
130 .map_err(|e| AppError::io_at(&self.dir, e))?;
131 tmp.write_all(bytes)
132 .map_err(|e| AppError::io_at(tmp.path(), e))?;
133 tmp.as_file_mut()
134 .sync_all()
135 .map_err(|e| AppError::io_at(tmp.path(), e))?;
136 tmp.persist(self.payload_path())
137 .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
138 let _ = fs::remove_file(self.stale_path());
140 let _ = fs::remove_file(self.last_error_path());
141 Ok(())
142 }
143
144 pub fn mark_stale(&self) {
146 let _ = self.ensure_dir();
147 let _ = File::create(self.stale_path());
148 }
149
150 pub fn is_stale(&self) -> bool {
151 self.stale_path().exists()
152 }
153
154 pub fn write_last_error(&self, code: u16, msg: &str) {
158 let _ = self.ensure_dir();
159 let path = self.last_error_path();
160 let body = format!("{code}\n{msg}");
161 let _ = atomic_write(&path, body.as_bytes());
162 }
163
164 pub fn clear_last_error(&self) {
166 let _ = fs::remove_file(self.last_error_path());
167 }
168
169 pub fn read_last_error(&self) -> Option<(u16, String)> {
170 let raw = fs::read_to_string(self.last_error_path()).ok()?;
171 let mut lines = raw.lines();
172 let code = lines.next()?.parse::<u16>().ok()?;
173 let msg = lines.next().unwrap_or_default().to_string();
174 Some((code, msg))
175 }
176}
177
178pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
184 if let Some(parent) = path.parent() {
185 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
186 }
187 let f = OpenOptions::new()
188 .create(true)
189 .read(true)
190 .write(true)
191 .truncate(false)
192 .open(path)
193 .map_err(|e| AppError::io_at(path, e))?;
194
195 let deadline = std::time::Instant::now() + timeout;
196 loop {
197 match f.try_lock_exclusive() {
198 Ok(()) => return Ok(LockGuard { file: f }),
199 Err(_) => {
200 if std::time::Instant::now() >= deadline {
201 return Err(AppError::Other(format!(
202 "cache lock timeout after {:?}",
203 timeout
204 )));
205 }
206 std::thread::sleep(Duration::from_millis(50));
207 }
208 }
209 }
210}
211
212pub struct LockGuard {
216 file: File,
217}
218
219impl Drop for LockGuard {
220 fn drop(&mut self) {
221 let _ = FileExt::unlock(&self.file);
222 }
223}
224
225pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
228 let dir = path.parent().ok_or_else(|| {
229 AppError::Other(format!(
230 "atomic_write: path has no parent: {}",
231 path.display()
232 ))
233 })?;
234 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
235 let mut tmp = tempfile::Builder::new()
236 .prefix(".tmp.")
237 .tempfile_in(dir)
238 .map_err(|e| AppError::io_at(dir, e))?;
239 tmp.write_all(bytes)
240 .map_err(|e| AppError::io_at(tmp.path(), e))?;
241 tmp.as_file_mut()
242 .sync_all()
243 .map_err(|e| AppError::io_at(tmp.path(), e))?;
244 tmp.persist(path)
245 .map_err(|e| AppError::io_at(path, e.error))?;
246 Ok(())
247}
248
249fn xdg_cache_dir() -> Result<PathBuf> {
250 directories::BaseDirs::new()
251 .map(|b| b.cache_dir().to_path_buf())
252 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
253}
254
255pub fn home_dir() -> Result<PathBuf> {
263 directories::BaseDirs::new()
264 .map(|b| b.home_dir().to_path_buf())
265 .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
266}
267
268#[cfg(test)]
275pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
276 let dir = tempfile::TempDir::new().unwrap();
277 let path = dir.path().join(name);
278 if let Some(c) = contents {
279 std::fs::write(&path, c).unwrap();
280 }
281 (dir, path)
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use tempfile::TempDir;
288
289 fn fixture() -> (TempDir, Cache) {
290 let td = TempDir::new().unwrap();
291 let cache = Cache::at(td.path().join("anthropic"));
292 cache.ensure_dir().unwrap();
293 (td, cache)
294 }
295
296 #[test]
297 fn ensure_dir_is_idempotent() {
298 let (_td, cache) = fixture();
299 cache.ensure_dir().unwrap();
300 cache.ensure_dir().unwrap();
301 assert!(cache.dir().is_dir());
302 }
303
304 #[test]
305 fn write_then_read_round_trip() {
306 let (_td, cache) = fixture();
307 cache.write_payload(b"hello world").unwrap();
308 let got = cache.maybe_payload().unwrap();
309 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
310 }
311
312 #[test]
313 fn maybe_payload_returns_none_when_missing() {
314 let (_td, cache) = fixture();
315 assert!(cache.maybe_payload().unwrap().is_none());
316 }
317
318 #[test]
319 fn fresh_payload_respects_ttl() {
320 let (_td, cache) = fixture();
321 cache.write_payload(b"x").unwrap();
322 assert!(
324 cache
325 .fresh_payload(Duration::from_secs(10))
326 .unwrap()
327 .is_some()
328 );
329 assert!(
331 cache
332 .fresh_payload(Duration::from_secs(0))
333 .unwrap()
334 .is_none()
335 );
336 }
337
338 #[test]
339 fn write_clears_stale_marker_and_last_error() {
340 let (_td, cache) = fixture();
341 cache.mark_stale();
342 cache.write_last_error(429, "rate limited");
343 assert!(cache.is_stale());
344 assert!(cache.read_last_error().is_some());
345
346 cache.write_payload(b"fresh").unwrap();
347 assert!(!cache.is_stale());
348 assert!(cache.read_last_error().is_none());
349 }
350
351 #[test]
352 fn last_error_round_trip() {
353 let (_td, cache) = fixture();
354 cache.write_last_error(503, "service unavailable");
355 let (code, msg) = cache.read_last_error().unwrap();
356 assert_eq!(code, 503);
357 assert_eq!(msg, "service unavailable");
358 }
359
360 #[test]
361 fn last_error_with_empty_message_round_trips() {
362 let (_td, cache) = fixture();
363 cache.write_last_error(429, "");
364 let (code, msg) = cache.read_last_error().unwrap();
365 assert_eq!(code, 429);
366 assert_eq!(msg, "");
367 }
368
369 #[test]
370 fn lock_serializes_concurrent_acquirers() {
371 let (_td, cache) = fixture();
374 let lock_path = cache.lock_path();
375 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
376
377 let res = acquire_lock(&lock_path, Duration::from_millis(100));
378 assert!(matches!(res, Err(AppError::Other(_))));
379 }
380
381 #[test]
382 fn atomic_write_creates_parent_dirs() {
383 let td = TempDir::new().unwrap();
384 let nested = td.path().join("a/b/c/file.txt");
385 atomic_write(&nested, b"abc").unwrap();
386 assert_eq!(fs::read(&nested).unwrap(), b"abc");
387 }
388}