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 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    /// Best-effort removal of the `.last_error` marker.
184    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        // The message is *everything* past the first newline, not just the next
191        // line: vendors store the raw HTTP body here and those are routinely
192        // multi-line JSON, so taking one line truncated the user's diagnostic.
193        // Files from before this fix parse unchanged — the writer always framed
194        // them this way, only the reader threw the tail away.
195        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
196        Some((code.parse::<u16>().ok()?, msg.to_string()))
197    }
198}
199
200/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
201/// Returned guard releases the lock on drop.
202///
203/// The flock file is created if missing, but its content is unused — only
204/// the lock matters.
205/// Async wrapper around [`acquire_lock`].
206///
207/// The blocking version parks the calling thread in a sleep loop for up to
208/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
209/// stalls *everything*: keyboard input, the refresh timer, and every other
210/// vendor's in-flight request. Running the wait on the blocking pool keeps the
211/// reactor free while a contended lock is waited on.
212pub 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
248/// Releases the flock on drop. Holding this across an `.await` is fine as
249/// long as you don't move it across tasks (we always use it in `tokio::main`
250/// on a single thread).
251pub 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
261/// Atomic write helper used by `write_last_error`. Public for vendors that
262/// need to write small sidecar files (credentials, etc.).
263pub 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
291/// The user's home directory, resolved cross-platform via `directories`
292/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
293///
294/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
295/// files from fixed dotfiles under `$HOME`; they share this resolver the same
296/// way they already share [`atomic_write`], so home resolution lives in one
297/// place rather than being reimplemented per vendor.
298pub 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/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
305/// it. [`atomic_write`] replaces its destination via rename, which on Windows
306/// fails while the destination is held open (as a live `NamedTempFile` handle
307/// would be) — so tests that exercise a write-back must target a closed file.
308/// Returns the dir (the caller keeps it alive) and the file's path; the file
309/// exists only when `contents` is given.
310#[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        // Fresh = within a generous TTL.
359        assert!(
360            cache
361                .fresh_payload(Duration::from_secs(10))
362                .unwrap()
363                .is_some()
364        );
365        // Force "stale" by passing a zero TTL — payload is older than 0s.
366        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        // Let the payload acquire real age rather than rewriting its mtime:
393        // Windows denies reopening the just-persisted file for an attribute
394        // write, and the boundary being tested is the same either way. The
395        // margin is ~12x the threshold so filesystem timestamp granularity
396        // cannot make this flaky.
397        std::thread::sleep(Duration::from_millis(60));
398
399        // Still readable when age is not considered — `maybe_payload` is the
400        // unbounded reader, which is exactly why failure paths must not use it.
401        assert!(cache.maybe_payload().unwrap().is_some());
402
403        // Past the limit, the failure path gets nothing and the caller has to
404        // surface the real error. `MAX_STALE` was dead code before this:
405        // every fallback served history forever.
406        assert!(
407            cache
408                .fallback_payload(Duration::from_millis(5))
409                .unwrap()
410                .is_none()
411        );
412
413        // Inside the window it is still served, so the guard is a limit and
414        // not a blanket refusal.
415        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    /// The regression this guards: vendors write the raw HTTP body, which is
440    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
441    /// showed `{` and dropped the actual API explanation.
442    #[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    /// A user upgrades with a `.last_error` already on disk; it must still
469    /// parse. Trailing-newline-free files (the whole marker being just a code)
470    /// count too — that is the one shape the old `lines()` reader tolerated.
471    #[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        // A non-numeric first line is still no error at all, never a fake 0.
485        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        // First lock succeeds; while held, a second non-blocking attempt
492        // should time out quickly.
493        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    /// The regression this guards: `acquire_lock` parks the thread in a sleep
502    /// loop, so on the TUI's current-thread runtime a contended lock froze
503    /// keyboard input, the refresh timer and every other vendor's fetch until
504    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
505    /// so unrelated timers must keep firing while the lock is held elsewhere.
506    #[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        // This will wait the full timeout — it can never win the lock.
513        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
514
515        // Meanwhile the runtime must still be able to make progress.
516        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                // The lock attempt is expected to time out.
529                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}