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>>> {
111 if !self.payload_path().exists() {
112 return Ok(None);
113 }
114 self.read_payload().map(Some)
115 }
116
117 pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
123 let Some(age) = self.payload_age() else {
124 return Ok(None);
125 };
126 if age > max_stale {
127 return Ok(None);
128 }
129 self.read_payload().map(Some)
130 }
131
132 fn read_payload(&self) -> Result<Vec<u8>> {
133 let p = self.payload_path();
134 let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
135 let mut buf = Vec::new();
136 f.read_to_end(&mut buf)
137 .map_err(|e| AppError::io_at(&p, e))?;
138 Ok(buf)
139 }
140
141 pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
144 self.ensure_dir()?;
145 let mut tmp = tempfile::Builder::new()
146 .prefix(".usage.")
147 .tempfile_in(&self.dir)
148 .map_err(|e| AppError::io_at(&self.dir, e))?;
149 tmp.write_all(bytes)
150 .map_err(|e| AppError::io_at(tmp.path(), e))?;
151 tmp.as_file_mut()
152 .sync_all()
153 .map_err(|e| AppError::io_at(tmp.path(), e))?;
154 tmp.persist(self.payload_path())
155 .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
156 let _ = fs::remove_file(self.stale_path());
158 let _ = fs::remove_file(self.last_error_path());
159 Ok(())
160 }
161
162 pub fn mark_stale(&self) {
164 let _ = self.ensure_dir();
165 let _ = File::create(self.stale_path());
166 }
167
168 pub fn is_stale(&self) -> bool {
169 self.stale_path().exists()
170 }
171
172 pub fn write_last_error(&self, code: u16, msg: &str) {
176 let _ = self.ensure_dir();
177 let path = self.last_error_path();
178 let msg = crate::display::sanitize_untrusted_field(msg);
179 let body = format!("{code}\n{msg}");
180 let _ = atomic_write(&path, body.as_bytes());
181 }
182
183 pub fn clear_last_error(&self) {
185 let _ = fs::remove_file(self.last_error_path());
186 }
187
188 pub fn read_last_error(&self) -> Option<(u16, String)> {
189 let raw = fs::read_to_string(self.last_error_path()).ok()?;
190 let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
196 Some((code.parse::<u16>().ok()?, msg.to_string()))
197 }
198}
199
200pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
213 let path = path.to_path_buf();
214 tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
215 .await
216 .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
217}
218
219pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
220 if let Some(parent) = path.parent() {
221 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
222 }
223 let f = OpenOptions::new()
224 .create(true)
225 .read(true)
226 .write(true)
227 .truncate(false)
228 .open(path)
229 .map_err(|e| AppError::io_at(path, e))?;
230
231 let deadline = std::time::Instant::now() + timeout;
232 loop {
233 match f.try_lock_exclusive() {
234 Ok(()) => return Ok(LockGuard { file: f }),
235 Err(_) => {
236 if std::time::Instant::now() >= deadline {
237 return Err(AppError::Other(format!(
238 "cache lock timeout after {:?}",
239 timeout
240 )));
241 }
242 std::thread::sleep(Duration::from_millis(50));
243 }
244 }
245 }
246}
247
248pub struct LockGuard {
252 file: File,
253}
254
255impl Drop for LockGuard {
256 fn drop(&mut self) {
257 let _ = FileExt::unlock(&self.file);
258 }
259}
260
261pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
264 let dir = path.parent().ok_or_else(|| {
265 AppError::Other(format!(
266 "atomic_write: path has no parent: {}",
267 path.display()
268 ))
269 })?;
270 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
271 let mut tmp = tempfile::Builder::new()
272 .prefix(".tmp.")
273 .tempfile_in(dir)
274 .map_err(|e| AppError::io_at(dir, e))?;
275 tmp.write_all(bytes)
276 .map_err(|e| AppError::io_at(tmp.path(), e))?;
277 tmp.as_file_mut()
278 .sync_all()
279 .map_err(|e| AppError::io_at(tmp.path(), e))?;
280 tmp.persist(path)
281 .map_err(|e| AppError::io_at(path, e.error))?;
282 Ok(())
283}
284
285fn xdg_cache_dir() -> Result<PathBuf> {
286 directories::BaseDirs::new()
287 .map(|b| b.cache_dir().to_path_buf())
288 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
289}
290
291pub fn home_dir() -> Result<PathBuf> {
299 directories::BaseDirs::new()
300 .map(|b| b.home_dir().to_path_buf())
301 .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
302}
303
304#[cfg(test)]
311pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
312 let dir = tempfile::TempDir::new().unwrap();
313 let path = dir.path().join(name);
314 if let Some(c) = contents {
315 std::fs::write(&path, c).unwrap();
316 }
317 (dir, path)
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use tempfile::TempDir;
324
325 fn fixture() -> (TempDir, Cache) {
326 let td = TempDir::new().unwrap();
327 let cache = Cache::at(td.path().join("anthropic"));
328 cache.ensure_dir().unwrap();
329 (td, cache)
330 }
331
332 #[test]
333 fn ensure_dir_is_idempotent() {
334 let (_td, cache) = fixture();
335 cache.ensure_dir().unwrap();
336 cache.ensure_dir().unwrap();
337 assert!(cache.dir().is_dir());
338 }
339
340 #[test]
341 fn write_then_read_round_trip() {
342 let (_td, cache) = fixture();
343 cache.write_payload(b"hello world").unwrap();
344 let got = cache.maybe_payload().unwrap();
345 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
346 }
347
348 #[test]
349 fn maybe_payload_returns_none_when_missing() {
350 let (_td, cache) = fixture();
351 assert!(cache.maybe_payload().unwrap().is_none());
352 }
353
354 #[test]
355 fn fresh_payload_respects_ttl() {
356 let (_td, cache) = fixture();
357 cache.write_payload(b"x").unwrap();
358 assert!(
360 cache
361 .fresh_payload(Duration::from_secs(10))
362 .unwrap()
363 .is_some()
364 );
365 assert!(
367 cache
368 .fresh_payload(Duration::from_secs(0))
369 .unwrap()
370 .is_none()
371 );
372 }
373
374 #[test]
375 fn write_clears_stale_marker_and_last_error() {
376 let (_td, cache) = fixture();
377 cache.mark_stale();
378 cache.write_last_error(429, "rate limited");
379 assert!(cache.is_stale());
380 assert!(cache.read_last_error().is_some());
381
382 cache.write_payload(b"fresh").unwrap();
383 assert!(!cache.is_stale());
384 assert!(cache.read_last_error().is_none());
385 }
386
387 #[test]
388 fn fallback_payload_refuses_a_payload_older_than_the_limit() {
389 let (_td, cache) = fixture();
390 cache.write_payload(b"old").unwrap();
391
392 std::thread::sleep(Duration::from_millis(60));
398
399 assert!(cache.maybe_payload().unwrap().is_some());
402
403 assert!(
407 cache
408 .fallback_payload(Duration::from_millis(5))
409 .unwrap()
410 .is_none()
411 );
412
413 assert_eq!(
416 cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
417 Some(&b"old"[..])
418 );
419 }
420
421 #[test]
422 fn last_error_round_trip() {
423 let (_td, cache) = fixture();
424 cache.write_last_error(503, "service unavailable");
425 let (code, msg) = cache.read_last_error().unwrap();
426 assert_eq!(code, 503);
427 assert_eq!(msg, "service unavailable");
428 }
429
430 #[test]
431 fn last_error_with_empty_message_round_trips() {
432 let (_td, cache) = fixture();
433 cache.write_last_error(429, "");
434 let (code, msg) = cache.read_last_error().unwrap();
435 assert_eq!(code, 429);
436 assert_eq!(msg, "");
437 }
438
439 #[test]
443 fn last_error_round_trips_a_multi_line_message() {
444 let (_td, cache) = fixture();
445 let body = "{\n \"error\": \"quota exhausted\",\n \"retry_after\": 3600\n}";
446 cache.write_last_error(429, body);
447
448 let (code, msg) = cache.read_last_error().unwrap();
449 assert_eq!(code, 429);
450 assert_eq!(msg, body);
451 assert!(
452 msg.contains("quota exhausted"),
453 "message was truncated to its first line: {msg:?}"
454 );
455 }
456
457 #[test]
458 fn last_error_strips_terminal_controls_before_persisting() {
459 let (_td, cache) = fixture();
460 cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
461
462 let (code, msg) = cache.read_last_error().unwrap();
463 assert_eq!(code, 500);
464 assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
465 assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
466 }
467
468 #[test]
472 fn last_error_reads_files_written_by_the_previous_version() {
473 let (_td, cache) = fixture();
474
475 fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
476 assert_eq!(
477 cache.read_last_error(),
478 Some((503, "service unavailable".into()))
479 );
480
481 fs::write(cache.last_error_path(), "429").unwrap();
482 assert_eq!(cache.read_last_error(), Some((429, String::new())));
483
484 fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
486 assert!(cache.read_last_error().is_none());
487 }
488
489 #[test]
490 fn lock_serializes_concurrent_acquirers() {
491 let (_td, cache) = fixture();
494 let lock_path = cache.lock_path();
495 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
496
497 let res = acquire_lock(&lock_path, Duration::from_millis(100));
498 assert!(matches!(res, Err(AppError::Other(_))));
499 }
500
501 #[tokio::test(flavor = "current_thread")]
507 async fn async_lock_does_not_stall_the_runtime() {
508 let (_td, cache) = fixture();
509 let lock_path = cache.lock_path();
510 let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
511
512 let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
514
515 let mut ticks = 0usize;
517 let ticker = async {
518 let mut iv = tokio::time::interval(Duration::from_millis(20));
519 iv.tick().await;
520 loop {
521 iv.tick().await;
522 ticks += 1;
523 }
524 };
525
526 tokio::select! {
527 res = waiter => {
528 assert!(matches!(res, Err(AppError::Other(_))));
530 }
531 _ = ticker => unreachable!("the ticker loops forever"),
532 }
533 assert!(
534 ticks > 1,
535 "runtime was starved while the lock was contended ({ticks} ticks)"
536 );
537 }
538
539 #[test]
540 fn atomic_write_creates_parent_dirs() {
541 let td = TempDir::new().unwrap();
542 let nested = td.path().join("a/b/c/file.txt");
543 atomic_write(&nested, b"abc").unwrap();
544 assert_eq!(fs::read(&nested).unwrap(), b"abc");
545 }
546}