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    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    /// Atomically write a new payload. Uses `tempfile + persist` (POSIX
124    /// rename), matching claudebar's `mktemp + mv` invariant.
125    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        // A successful write clears any stale marker.
139        let _ = fs::remove_file(self.stale_path());
140        let _ = fs::remove_file(self.last_error_path());
141        Ok(())
142    }
143
144    /// Mark the cache as stale. Idempotent.
145    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    /// Write the `.last_error` marker — first line `code`, second line `msg`.
155    /// Best-effort, never errors (matches claudebar:478-486 which silently
156    /// continues if the cache dir isn't writable).
157    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    /// Best-effort removal of the `.last_error` marker.
165    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
178/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
179/// Returned guard releases the lock on drop.
180///
181/// The flock file is created if missing, but its content is unused — only
182/// the lock matters.
183pub 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
212/// Releases the flock on drop. Holding this across an `.await` is fine as
213/// long as you don't move it across tasks (we always use it in `tokio::main`
214/// on a single thread).
215pub 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
225/// Atomic write helper used by `write_last_error`. Public for vendors that
226/// need to write small sidecar files (credentials, etc.).
227pub 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
255/// The user's home directory, resolved cross-platform via `directories`
256/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
257///
258/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
259/// files from fixed dotfiles under `$HOME`; they share this resolver the same
260/// way they already share [`atomic_write`], so home resolution lives in one
261/// place rather than being reimplemented per vendor.
262pub 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/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
269/// it. [`atomic_write`] replaces its destination via rename, which on Windows
270/// fails while the destination is held open (as a live `NamedTempFile` handle
271/// would be) — so tests that exercise a write-back must target a closed file.
272/// Returns the dir (the caller keeps it alive) and the file's path; the file
273/// exists only when `contents` is given.
274#[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        // Fresh = within a generous TTL.
323        assert!(
324            cache
325                .fresh_payload(Duration::from_secs(10))
326                .unwrap()
327                .is_some()
328        );
329        // Force "stale" by passing a zero TTL — payload is older than 0s.
330        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        // First lock succeeds; while held, a second non-blocking attempt
372        // should time out quickly.
373        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}