Skip to main content

ai_usagebar/
cache.rs

1//! Per-vendor on-disk cache with atomic writes, TTL checks, and inter-process
2//! locking.
3//!
4//! Mirrors claudebar's cache layout but per-vendor:
5//!   `~/.cache/ai-usagebar/<vendor>/usage.json`         payload
6//!   `~/.cache/ai-usagebar/<vendor>/.stale`             marker (cache is stale)
7//!   `~/.cache/ai-usagebar/<vendor>/.last_error`        HTTP code\nmessage
8//!   `~/.cache/ai-usagebar/<vendor>/.fetch.lock`        flock target
9//!
10//! Multi-monitor safety: callers should `acquire_lock()` before the refresh+
11//! fetch window, mirroring claudebar:402-407's `exec 9>"$_lockfile" / flock`.
12
13use 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
22/// Default TTL — claudebar's `CACHE_TTL=60`.
23pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
24
25/// Maximum staleness before we refuse to serve cached data even on failure.
26/// Mirrors claudebar's `WEEKLY_WINDOW` (7 days).
27pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
28
29/// Per-vendor cache directory and helper API.
30///
31/// Construct with [`Cache::for_vendor`]; the directory is created lazily.
32#[derive(Debug, Clone)]
33pub struct Cache {
34    dir: PathBuf,
35}
36
37impl Cache {
38    /// Build a cache rooted at `~/.cache/ai-usagebar/<vendor>` (or under
39    /// `$XDG_CACHE_HOME` when set).
40    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    /// Cache for a specific named account of a vendor, rooted at
46    /// `~/.cache/ai-usagebar/<vendor>/<label>`. Only *extra* accounts use
47    /// this; the default account keeps [`Cache::for_vendor`] so its path never
48    /// moves (issue #14, back-compat rule 2).
49    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    /// Cache rooted at an arbitrary directory — for tests.
58    pub fn at(path: PathBuf) -> Self {
59        Self { dir: path }
60    }
61
62    /// Ensure the directory exists. Safe to call repeatedly.
63    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    /// Age of the payload (`None` if it doesn't exist). Used by the widget to
85    /// decide whether the 60s cache window applies.
86    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    /// Returns the cached payload only if it is younger than `ttl`. Used as
93    /// the fast path in `_fetch_usage` (claudebar:343-349).
94    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    /// Read the payload regardless of age. `Err` if the file exists but is
106    /// unreadable; `Ok(None)` if it just doesn't exist.
107    ///
108    /// Prefer [`Cache::fallback_payload`] on failure paths — this one imposes
109    /// no age limit, so it will happily hand back a month-old figure.
110    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    /// Payload for the *failure* path: the last good value, but only while it
118    /// is still worth showing. Beyond `max_stale` this returns `Ok(None)` so
119    /// the caller surfaces the real error instead of presenting week-old
120    /// numbers as if they were current — a bar that silently freezes on
121    /// history is worse than one that says it cannot reach the API.
122    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    /// Atomically write a new payload. Uses `tempfile + persist` (POSIX
142    /// rename), matching claudebar's `mktemp + mv` invariant.
143    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        // A successful write clears any stale marker.
157        let _ = fs::remove_file(self.stale_path());
158        let _ = fs::remove_file(self.last_error_path());
159        Ok(())
160    }
161
162    /// Mark the cache as stale. Idempotent.
163    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    /// Write the `.last_error` marker — first line `code`, everything after it
173    /// `msg`. Best-effort, never errors (matches claudebar:478-486 which
174    /// silently continues if the cache dir isn't writable).
175    pub fn write_last_error(&self, code: u16, msg: &str) {
176        let _ = self.ensure_dir();
177        let path = self.last_error_path();
178        let body = format!("{code}\n{msg}");
179        let _ = atomic_write(&path, body.as_bytes());
180    }
181
182    /// Best-effort removal of the `.last_error` marker.
183    pub fn clear_last_error(&self) {
184        let _ = fs::remove_file(self.last_error_path());
185    }
186
187    pub fn read_last_error(&self) -> Option<(u16, String)> {
188        let raw = fs::read_to_string(self.last_error_path()).ok()?;
189        // The message is *everything* past the first newline, not just the next
190        // line: vendors store the raw HTTP body here and those are routinely
191        // multi-line JSON, so taking one line truncated the user's diagnostic.
192        // Files from before this fix parse unchanged — the writer always framed
193        // them this way, only the reader threw the tail away.
194        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
195        Some((code.parse::<u16>().ok()?, msg.to_string()))
196    }
197}
198
199/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
200/// Returned guard releases the lock on drop.
201///
202/// The flock file is created if missing, but its content is unused — only
203/// the lock matters.
204/// Async wrapper around [`acquire_lock`].
205///
206/// The blocking version parks the calling thread in a sleep loop for up to
207/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
208/// stalls *everything*: keyboard input, the refresh timer, and every other
209/// vendor's in-flight request. Running the wait on the blocking pool keeps the
210/// reactor free while a contended lock is waited on.
211pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
212    let path = path.to_path_buf();
213    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
214        .await
215        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
216}
217
218pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
219    if let Some(parent) = path.parent() {
220        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
221    }
222    let f = OpenOptions::new()
223        .create(true)
224        .read(true)
225        .write(true)
226        .truncate(false)
227        .open(path)
228        .map_err(|e| AppError::io_at(path, e))?;
229
230    let deadline = std::time::Instant::now() + timeout;
231    loop {
232        match f.try_lock_exclusive() {
233            Ok(()) => return Ok(LockGuard { file: f }),
234            Err(_) => {
235                if std::time::Instant::now() >= deadline {
236                    return Err(AppError::Other(format!(
237                        "cache lock timeout after {:?}",
238                        timeout
239                    )));
240                }
241                std::thread::sleep(Duration::from_millis(50));
242            }
243        }
244    }
245}
246
247/// Releases the flock on drop. Holding this across an `.await` is fine as
248/// long as you don't move it across tasks (we always use it in `tokio::main`
249/// on a single thread).
250pub struct LockGuard {
251    file: File,
252}
253
254impl Drop for LockGuard {
255    fn drop(&mut self) {
256        let _ = FileExt::unlock(&self.file);
257    }
258}
259
260/// Atomic write helper used by `write_last_error`. Public for vendors that
261/// need to write small sidecar files (credentials, etc.).
262pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
263    let dir = path.parent().ok_or_else(|| {
264        AppError::Other(format!(
265            "atomic_write: path has no parent: {}",
266            path.display()
267        ))
268    })?;
269    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
270    let mut tmp = tempfile::Builder::new()
271        .prefix(".tmp.")
272        .tempfile_in(dir)
273        .map_err(|e| AppError::io_at(dir, e))?;
274    tmp.write_all(bytes)
275        .map_err(|e| AppError::io_at(tmp.path(), e))?;
276    tmp.as_file_mut()
277        .sync_all()
278        .map_err(|e| AppError::io_at(tmp.path(), e))?;
279    tmp.persist(path)
280        .map_err(|e| AppError::io_at(path, e.error))?;
281    Ok(())
282}
283
284fn xdg_cache_dir() -> Result<PathBuf> {
285    directories::BaseDirs::new()
286        .map(|b| b.cache_dir().to_path_buf())
287        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
288}
289
290/// The user's home directory, resolved cross-platform via `directories`
291/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
292///
293/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
294/// files from fixed dotfiles under `$HOME`; they share this resolver the same
295/// way they already share [`atomic_write`], so home resolution lives in one
296/// place rather than being reimplemented per vendor.
297pub fn home_dir() -> Result<PathBuf> {
298    directories::BaseDirs::new()
299        .map(|b| b.home_dir().to_path_buf())
300        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
301}
302
303/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
304/// it. [`atomic_write`] replaces its destination via rename, which on Windows
305/// fails while the destination is held open (as a live `NamedTempFile` handle
306/// would be) — so tests that exercise a write-back must target a closed file.
307/// Returns the dir (the caller keeps it alive) and the file's path; the file
308/// exists only when `contents` is given.
309#[cfg(test)]
310pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
311    let dir = tempfile::TempDir::new().unwrap();
312    let path = dir.path().join(name);
313    if let Some(c) = contents {
314        std::fs::write(&path, c).unwrap();
315    }
316    (dir, path)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use tempfile::TempDir;
323
324    fn fixture() -> (TempDir, Cache) {
325        let td = TempDir::new().unwrap();
326        let cache = Cache::at(td.path().join("anthropic"));
327        cache.ensure_dir().unwrap();
328        (td, cache)
329    }
330
331    #[test]
332    fn ensure_dir_is_idempotent() {
333        let (_td, cache) = fixture();
334        cache.ensure_dir().unwrap();
335        cache.ensure_dir().unwrap();
336        assert!(cache.dir().is_dir());
337    }
338
339    #[test]
340    fn write_then_read_round_trip() {
341        let (_td, cache) = fixture();
342        cache.write_payload(b"hello world").unwrap();
343        let got = cache.maybe_payload().unwrap();
344        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
345    }
346
347    #[test]
348    fn maybe_payload_returns_none_when_missing() {
349        let (_td, cache) = fixture();
350        assert!(cache.maybe_payload().unwrap().is_none());
351    }
352
353    #[test]
354    fn fresh_payload_respects_ttl() {
355        let (_td, cache) = fixture();
356        cache.write_payload(b"x").unwrap();
357        // Fresh = within a generous TTL.
358        assert!(
359            cache
360                .fresh_payload(Duration::from_secs(10))
361                .unwrap()
362                .is_some()
363        );
364        // Force "stale" by passing a zero TTL — payload is older than 0s.
365        assert!(
366            cache
367                .fresh_payload(Duration::from_secs(0))
368                .unwrap()
369                .is_none()
370        );
371    }
372
373    #[test]
374    fn write_clears_stale_marker_and_last_error() {
375        let (_td, cache) = fixture();
376        cache.mark_stale();
377        cache.write_last_error(429, "rate limited");
378        assert!(cache.is_stale());
379        assert!(cache.read_last_error().is_some());
380
381        cache.write_payload(b"fresh").unwrap();
382        assert!(!cache.is_stale());
383        assert!(cache.read_last_error().is_none());
384    }
385
386    #[test]
387    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
388        let (_td, cache) = fixture();
389        cache.write_payload(b"old").unwrap();
390
391        // Let the payload acquire real age rather than rewriting its mtime:
392        // Windows denies reopening the just-persisted file for an attribute
393        // write, and the boundary being tested is the same either way. The
394        // margin is ~12x the threshold so filesystem timestamp granularity
395        // cannot make this flaky.
396        std::thread::sleep(Duration::from_millis(60));
397
398        // Still readable when age is not considered — `maybe_payload` is the
399        // unbounded reader, which is exactly why failure paths must not use it.
400        assert!(cache.maybe_payload().unwrap().is_some());
401
402        // Past the limit, the failure path gets nothing and the caller has to
403        // surface the real error. `MAX_STALE` was dead code before this:
404        // every fallback served history forever.
405        assert!(
406            cache
407                .fallback_payload(Duration::from_millis(5))
408                .unwrap()
409                .is_none()
410        );
411
412        // Inside the window it is still served, so the guard is a limit and
413        // not a blanket refusal.
414        assert_eq!(
415            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
416            Some(&b"old"[..])
417        );
418    }
419
420    #[test]
421    fn last_error_round_trip() {
422        let (_td, cache) = fixture();
423        cache.write_last_error(503, "service unavailable");
424        let (code, msg) = cache.read_last_error().unwrap();
425        assert_eq!(code, 503);
426        assert_eq!(msg, "service unavailable");
427    }
428
429    #[test]
430    fn last_error_with_empty_message_round_trips() {
431        let (_td, cache) = fixture();
432        cache.write_last_error(429, "");
433        let (code, msg) = cache.read_last_error().unwrap();
434        assert_eq!(code, 429);
435        assert_eq!(msg, "");
436    }
437
438    /// The regression this guards: vendors write the raw HTTP body, which is
439    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
440    /// showed `{` and dropped the actual API explanation.
441    #[test]
442    fn last_error_round_trips_a_multi_line_message() {
443        let (_td, cache) = fixture();
444        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
445        cache.write_last_error(429, body);
446
447        let (code, msg) = cache.read_last_error().unwrap();
448        assert_eq!(code, 429);
449        assert_eq!(msg, body);
450        assert!(
451            msg.contains("quota exhausted"),
452            "message was truncated to its first line: {msg:?}"
453        );
454    }
455
456    /// A user upgrades with a `.last_error` already on disk; it must still
457    /// parse. Trailing-newline-free files (the whole marker being just a code)
458    /// count too — that is the one shape the old `lines()` reader tolerated.
459    #[test]
460    fn last_error_reads_files_written_by_the_previous_version() {
461        let (_td, cache) = fixture();
462
463        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
464        assert_eq!(
465            cache.read_last_error(),
466            Some((503, "service unavailable".into()))
467        );
468
469        fs::write(cache.last_error_path(), "429").unwrap();
470        assert_eq!(cache.read_last_error(), Some((429, String::new())));
471
472        // A non-numeric first line is still no error at all, never a fake 0.
473        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
474        assert!(cache.read_last_error().is_none());
475    }
476
477    #[test]
478    fn lock_serializes_concurrent_acquirers() {
479        // First lock succeeds; while held, a second non-blocking attempt
480        // should time out quickly.
481        let (_td, cache) = fixture();
482        let lock_path = cache.lock_path();
483        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
484
485        let res = acquire_lock(&lock_path, Duration::from_millis(100));
486        assert!(matches!(res, Err(AppError::Other(_))));
487    }
488
489    /// The regression this guards: `acquire_lock` parks the thread in a sleep
490    /// loop, so on the TUI's current-thread runtime a contended lock froze
491    /// keyboard input, the refresh timer and every other vendor's fetch until
492    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
493    /// so unrelated timers must keep firing while the lock is held elsewhere.
494    #[tokio::test(flavor = "current_thread")]
495    async fn async_lock_does_not_stall_the_runtime() {
496        let (_td, cache) = fixture();
497        let lock_path = cache.lock_path();
498        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
499
500        // This will wait the full timeout — it can never win the lock.
501        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
502
503        // Meanwhile the runtime must still be able to make progress.
504        let mut ticks = 0usize;
505        let ticker = async {
506            let mut iv = tokio::time::interval(Duration::from_millis(20));
507            iv.tick().await;
508            loop {
509                iv.tick().await;
510                ticks += 1;
511            }
512        };
513
514        tokio::select! {
515            res = waiter => {
516                // The lock attempt is expected to time out.
517                assert!(matches!(res, Err(AppError::Other(_))));
518            }
519            _ = ticker => unreachable!("the ticker loops forever"),
520        }
521        assert!(
522            ticks > 1,
523            "runtime was starved while the lock was contended ({ticks} ticks)"
524        );
525    }
526
527    #[test]
528    fn atomic_write_creates_parent_dirs() {
529        let td = TempDir::new().unwrap();
530        let nested = td.path().join("a/b/c/file.txt");
531        atomic_write(&nested, b"abc").unwrap();
532        assert_eq!(fs::read(&nested).unwrap(), b"abc");
533    }
534}