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::{AUTH_FAILURE_MESSAGE, 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        // Authentication failure bodies routinely include account identifiers or
179        // partial credential details. Do not persist them; other status bodies
180        // remain useful diagnostics after their usual control-char cleanup.
181        let msg = if matches!(code, 401 | 403) {
182            AUTH_FAILURE_MESSAGE
183        } else {
184            msg
185        };
186        let msg = crate::display::sanitize_untrusted_field(msg);
187        let body = format!("{code}\n{msg}");
188        let _ = atomic_write(&path, body.as_bytes());
189    }
190
191    /// Best-effort removal of the `.last_error` marker.
192    pub fn clear_last_error(&self) {
193        let _ = fs::remove_file(self.last_error_path());
194    }
195
196    pub fn read_last_error(&self) -> Option<(u16, String)> {
197        let raw = fs::read_to_string(self.last_error_path()).ok()?;
198        // The message is *everything* past the first newline, not just the next
199        // line: vendors store the raw HTTP body here and those are routinely
200        // multi-line JSON, so taking one line truncated the user's diagnostic.
201        // Files from before this fix parse unchanged — the writer always framed
202        // them this way, only the reader threw the tail away.
203        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
204        Some((code.parse::<u16>().ok()?, msg.to_string()))
205    }
206}
207
208/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
209/// Returned guard releases the lock on drop.
210///
211/// The flock file is created if missing, but its content is unused — only
212/// the lock matters.
213/// Async wrapper around [`acquire_lock`].
214///
215/// The blocking version parks the calling thread in a sleep loop for up to
216/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
217/// stalls *everything*: keyboard input, the refresh timer, and every other
218/// vendor's in-flight request. Running the wait on the blocking pool keeps the
219/// reactor free while a contended lock is waited on.
220pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
221    let path = path.to_path_buf();
222    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
223        .await
224        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
225}
226
227pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
228    if let Some(parent) = path.parent() {
229        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
230    }
231    let f = OpenOptions::new()
232        .create(true)
233        .read(true)
234        .write(true)
235        .truncate(false)
236        .open(path)
237        .map_err(|e| AppError::io_at(path, e))?;
238
239    let deadline = std::time::Instant::now() + timeout;
240    loop {
241        match f.try_lock_exclusive() {
242            Ok(()) => return Ok(LockGuard { file: f }),
243            Err(_) => {
244                if std::time::Instant::now() >= deadline {
245                    return Err(AppError::Other(format!(
246                        "cache lock timeout after {:?}",
247                        timeout
248                    )));
249                }
250                std::thread::sleep(Duration::from_millis(50));
251            }
252        }
253    }
254}
255
256/// Releases the flock on drop. Holding this across an `.await` is fine as
257/// long as you don't move it across tasks (we always use it in `tokio::main`
258/// on a single thread).
259pub struct LockGuard {
260    file: File,
261}
262
263impl Drop for LockGuard {
264    fn drop(&mut self) {
265        let _ = FileExt::unlock(&self.file);
266    }
267}
268
269/// Atomic write helper used by `write_last_error`. Public for vendors that
270/// need to write small sidecar files (credentials, etc.).
271pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
272    let dir = path.parent().ok_or_else(|| {
273        AppError::Other(format!(
274            "atomic_write: path has no parent: {}",
275            path.display()
276        ))
277    })?;
278    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
279    let mut tmp = tempfile::Builder::new()
280        .prefix(".tmp.")
281        .tempfile_in(dir)
282        .map_err(|e| AppError::io_at(dir, e))?;
283    tmp.write_all(bytes)
284        .map_err(|e| AppError::io_at(tmp.path(), e))?;
285    tmp.as_file_mut()
286        .sync_all()
287        .map_err(|e| AppError::io_at(tmp.path(), e))?;
288    tmp.persist(path)
289        .map_err(|e| AppError::io_at(path, e.error))?;
290    Ok(())
291}
292
293fn xdg_cache_dir() -> Result<PathBuf> {
294    directories::BaseDirs::new()
295        .map(|b| b.cache_dir().to_path_buf())
296        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
297}
298
299/// The user's home directory, resolved cross-platform via `directories`
300/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
301///
302/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
303/// files from fixed dotfiles under `$HOME`; they share this resolver the same
304/// way they already share [`atomic_write`], so home resolution lives in one
305/// place rather than being reimplemented per vendor.
306pub fn home_dir() -> Result<PathBuf> {
307    directories::BaseDirs::new()
308        .map(|b| b.home_dir().to_path_buf())
309        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
310}
311
312/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
313/// it. [`atomic_write`] replaces its destination via rename, which on Windows
314/// fails while the destination is held open (as a live `NamedTempFile` handle
315/// would be) — so tests that exercise a write-back must target a closed file.
316/// Returns the dir (the caller keeps it alive) and the file's path; the file
317/// exists only when `contents` is given.
318#[cfg(test)]
319pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
320    let dir = tempfile::TempDir::new().unwrap();
321    let path = dir.path().join(name);
322    if let Some(c) = contents {
323        std::fs::write(&path, c).unwrap();
324    }
325    (dir, path)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use tempfile::TempDir;
332
333    fn fixture() -> (TempDir, Cache) {
334        let td = TempDir::new().unwrap();
335        let cache = Cache::at(td.path().join("anthropic"));
336        cache.ensure_dir().unwrap();
337        (td, cache)
338    }
339
340    #[test]
341    fn ensure_dir_is_idempotent() {
342        let (_td, cache) = fixture();
343        cache.ensure_dir().unwrap();
344        cache.ensure_dir().unwrap();
345        assert!(cache.dir().is_dir());
346    }
347
348    #[test]
349    fn write_then_read_round_trip() {
350        let (_td, cache) = fixture();
351        cache.write_payload(b"hello world").unwrap();
352        let got = cache.maybe_payload().unwrap();
353        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
354    }
355
356    #[test]
357    fn maybe_payload_returns_none_when_missing() {
358        let (_td, cache) = fixture();
359        assert!(cache.maybe_payload().unwrap().is_none());
360    }
361
362    #[test]
363    fn fresh_payload_respects_ttl() {
364        let (_td, cache) = fixture();
365        cache.write_payload(b"x").unwrap();
366        // Fresh = within a generous TTL.
367        assert!(
368            cache
369                .fresh_payload(Duration::from_secs(10))
370                .unwrap()
371                .is_some()
372        );
373        // Force "stale" by passing a zero TTL — payload is older than 0s.
374        assert!(
375            cache
376                .fresh_payload(Duration::from_secs(0))
377                .unwrap()
378                .is_none()
379        );
380    }
381
382    #[test]
383    fn write_clears_stale_marker_and_last_error() {
384        let (_td, cache) = fixture();
385        cache.mark_stale();
386        cache.write_last_error(429, "rate limited");
387        assert!(cache.is_stale());
388        assert!(cache.read_last_error().is_some());
389
390        cache.write_payload(b"fresh").unwrap();
391        assert!(!cache.is_stale());
392        assert!(cache.read_last_error().is_none());
393    }
394
395    #[test]
396    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
397        let (_td, cache) = fixture();
398        cache.write_payload(b"old").unwrap();
399
400        // Let the payload acquire real age rather than rewriting its mtime:
401        // Windows denies reopening the just-persisted file for an attribute
402        // write, and the boundary being tested is the same either way. The
403        // margin is ~12x the threshold so filesystem timestamp granularity
404        // cannot make this flaky.
405        std::thread::sleep(Duration::from_millis(60));
406
407        // Still readable when age is not considered — `maybe_payload` is the
408        // unbounded reader, which is exactly why failure paths must not use it.
409        assert!(cache.maybe_payload().unwrap().is_some());
410
411        // Past the limit, the failure path gets nothing and the caller has to
412        // surface the real error. `MAX_STALE` was dead code before this:
413        // every fallback served history forever.
414        assert!(
415            cache
416                .fallback_payload(Duration::from_millis(5))
417                .unwrap()
418                .is_none()
419        );
420
421        // Inside the window it is still served, so the guard is a limit and
422        // not a blanket refusal.
423        assert_eq!(
424            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
425            Some(&b"old"[..])
426        );
427    }
428
429    #[test]
430    fn last_error_round_trip() {
431        let (_td, cache) = fixture();
432        cache.write_last_error(503, "service unavailable");
433        let (code, msg) = cache.read_last_error().unwrap();
434        assert_eq!(code, 503);
435        assert_eq!(msg, "service unavailable");
436    }
437
438    #[test]
439    fn last_error_with_empty_message_round_trips() {
440        let (_td, cache) = fixture();
441        cache.write_last_error(429, "");
442        let (code, msg) = cache.read_last_error().unwrap();
443        assert_eq!(code, 429);
444        assert_eq!(msg, "");
445    }
446
447    #[test]
448    fn last_error_replaces_401_body_with_credential_neutral_message() {
449        let (_td, cache) = fixture();
450        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
451
452        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
453        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
454        assert!(!persisted.contains("PANCEA"));
455        assert!(!persisted.contains("<credential>"));
456    }
457
458    #[test]
459    fn last_error_replaces_403_body_with_credential_neutral_message() {
460        let (_td, cache) = fixture();
461        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
462
463        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
464        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
465        assert!(!persisted.contains("PANCEA"));
466        assert!(!persisted.contains("<credential>"));
467    }
468
469    /// The regression this guards: vendors write the raw HTTP body, which is
470    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
471    /// showed `{` and dropped the actual API explanation.
472    #[test]
473    fn last_error_round_trips_a_multi_line_message() {
474        let (_td, cache) = fixture();
475        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
476        cache.write_last_error(429, body);
477
478        let (code, msg) = cache.read_last_error().unwrap();
479        assert_eq!(code, 429);
480        assert_eq!(msg, body);
481        assert!(
482            msg.contains("quota exhausted"),
483            "message was truncated to its first line: {msg:?}"
484        );
485    }
486
487    #[test]
488    fn last_error_strips_terminal_controls_before_persisting() {
489        let (_td, cache) = fixture();
490        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
491
492        let (code, msg) = cache.read_last_error().unwrap();
493        assert_eq!(code, 500);
494        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
495        assert!(
496            msg.contains("Y2FuYXJ5"),
497            "non-auth diagnostic was not preserved"
498        );
499        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
500    }
501
502    /// A user upgrades with a `.last_error` already on disk; it must still
503    /// parse. Trailing-newline-free files (the whole marker being just a code)
504    /// count too — that is the one shape the old `lines()` reader tolerated.
505    #[test]
506    fn last_error_reads_files_written_by_the_previous_version() {
507        let (_td, cache) = fixture();
508
509        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
510        assert_eq!(
511            cache.read_last_error(),
512            Some((503, "service unavailable".into()))
513        );
514
515        fs::write(cache.last_error_path(), "429").unwrap();
516        assert_eq!(cache.read_last_error(), Some((429, String::new())));
517
518        // A non-numeric first line is still no error at all, never a fake 0.
519        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
520        assert!(cache.read_last_error().is_none());
521    }
522
523    #[test]
524    fn lock_serializes_concurrent_acquirers() {
525        // First lock succeeds; while held, a second non-blocking attempt
526        // should time out quickly.
527        let (_td, cache) = fixture();
528        let lock_path = cache.lock_path();
529        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
530
531        let res = acquire_lock(&lock_path, Duration::from_millis(100));
532        assert!(matches!(res, Err(AppError::Other(_))));
533    }
534
535    /// The regression this guards: `acquire_lock` parks the thread in a sleep
536    /// loop, so on the TUI's current-thread runtime a contended lock froze
537    /// keyboard input, the refresh timer and every other vendor's fetch until
538    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
539    /// so unrelated timers must keep firing while the lock is held elsewhere.
540    #[tokio::test(flavor = "current_thread")]
541    async fn async_lock_does_not_stall_the_runtime() {
542        let (_td, cache) = fixture();
543        let lock_path = cache.lock_path();
544        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
545
546        // This will wait the full timeout — it can never win the lock.
547        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
548
549        // Meanwhile the runtime must still be able to make progress.
550        let mut ticks = 0usize;
551        let ticker = async {
552            let mut iv = tokio::time::interval(Duration::from_millis(20));
553            iv.tick().await;
554            loop {
555                iv.tick().await;
556                ticks += 1;
557            }
558        };
559
560        tokio::select! {
561            res = waiter => {
562                // The lock attempt is expected to time out.
563                assert!(matches!(res, Err(AppError::Other(_))));
564            }
565            _ = ticker => unreachable!("the ticker loops forever"),
566        }
567        assert!(
568            ticks > 1,
569            "runtime was starved while the lock was contended ({ticks} ticks)"
570        );
571    }
572
573    #[test]
574    fn atomic_write_creates_parent_dirs() {
575        let td = TempDir::new().unwrap();
576        let nested = td.path().join("a/b/c/file.txt");
577        atomic_write(&nested, b"abc").unwrap();
578        assert_eq!(fs::read(&nested).unwrap(), b"abc");
579    }
580}