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>/.retry_after`       unix seconds; no network before this
9//!   `~/.cache/ai-usagebar/<vendor>/.fetch.lock`        flock target
10//!
11//! Multi-monitor safety: callers should `acquire_lock()` before the refresh+
12//! fetch window, mirroring claudebar:402-407's `exec 9>"$_lockfile" / flock`.
13
14use std::fs::{self, File, OpenOptions};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::time::{Duration, SystemTime};
18
19use fs2::FileExt;
20
21use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
22
23/// Default TTL — claudebar's `CACHE_TTL=60`.
24pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
25
26/// Maximum staleness before we refuse to serve cached data even on failure.
27/// Mirrors claudebar's `WEEKLY_WINDOW` (7 days).
28pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
29
30/// How long a vendor is left alone after it answered HTTP 429. Without this
31/// every 60 s poll re-hit a rate-limited endpoint, which only extends the
32/// limit; five minutes is long enough for the common per-minute windows to
33/// roll over and short enough that the bar recovers unattended.
34pub const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60);
35
36/// Per-vendor cache directory and helper API.
37///
38/// Construct with [`Cache::for_vendor`]; the directory is created lazily.
39#[derive(Debug, Clone)]
40pub struct Cache {
41    dir: PathBuf,
42}
43
44impl Cache {
45    /// Build a cache rooted at `~/.cache/ai-usagebar/<vendor>` (or under
46    /// `$XDG_CACHE_HOME` when set).
47    pub fn for_vendor(vendor: &str) -> Result<Self> {
48        let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
49        Ok(Self { dir: base })
50    }
51
52    /// Cache for a specific named account of a vendor, rooted at
53    /// `~/.cache/ai-usagebar/<vendor>/<label>`. Only *extra* accounts use
54    /// this; the default account keeps [`Cache::for_vendor`] so its path never
55    /// moves (issue #14, back-compat rule 2).
56    pub fn for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
57        let base = xdg_cache_dir()?
58            .join("ai-usagebar")
59            .join(vendor)
60            .join(label);
61        Ok(Self { dir: base })
62    }
63
64    /// Cache rooted at an arbitrary directory — for tests.
65    pub fn at(path: PathBuf) -> Self {
66        Self { dir: path }
67    }
68
69    /// Ensure the directory exists. Safe to call repeatedly.
70    pub fn ensure_dir(&self) -> Result<()> {
71        fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
72    }
73
74    pub fn dir(&self) -> &Path {
75        &self.dir
76    }
77
78    pub fn payload_path(&self) -> PathBuf {
79        self.dir.join("usage.json")
80    }
81    pub fn stale_path(&self) -> PathBuf {
82        self.dir.join(".stale")
83    }
84    pub fn last_error_path(&self) -> PathBuf {
85        self.dir.join(".last_error")
86    }
87    pub fn lock_path(&self) -> PathBuf {
88        self.dir.join(".fetch.lock")
89    }
90    /// Rate-limit backoff marker: unix epoch seconds (plain decimal text)
91    /// before which no request should be made to this vendor.
92    pub fn retry_after_path(&self) -> PathBuf {
93        self.dir.join(".retry_after")
94    }
95
96    /// Age of the payload (`None` if it doesn't exist). Used by the widget to
97    /// decide whether the 60s cache window applies.
98    pub fn payload_age(&self) -> Option<Duration> {
99        let meta = fs::metadata(self.payload_path()).ok()?;
100        let mtime = meta.modified().ok()?;
101        SystemTime::now().duration_since(mtime).ok()
102    }
103
104    /// Returns the cached payload only if it is younger than `ttl`. Used as
105    /// the fast path in `_fetch_usage` (claudebar:343-349).
106    ///
107    /// This is the **one pre-network hook shared by every vendor**: each
108    /// `fetch_snapshot` calls it before opening a connection and records HTTP
109    /// failures through [`Cache::write_last_error`]. That makes this the single
110    /// place a cross-vendor request policy can live without nineteen private
111    /// copies drifting apart — which is why the rate-limit backoff is applied
112    /// here rather than in each vendor.
113    ///
114    /// Policy, in order:
115    /// 1. While a 429 backoff is armed ([`Cache::backoff_remaining_at`]), no
116    ///    request is made. A payload still inside [`MAX_STALE`] is served as
117    ///    the answer, TTL notwithstanding, so the bar keeps its last good figure
118    ///    without touching the network. With nothing worth showing this returns
119    ///    an [`AppError::Http`] with status 429 whose body names the time until
120    ///    the next attempt; the vendor's `?` propagates it and the network is
121    ///    never reached. `.last_error` is left as the vendor wrote it.
122    /// 2. Otherwise the ordinary TTL check runs unchanged.
123    pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
124        self.fresh_payload_at(ttl, SystemTime::now())
125    }
126
127    /// [`Cache::fresh_payload`] with an injected clock for the backoff check.
128    /// The TTL comparison still reads the payload's mtime against the real
129    /// clock via [`Cache::payload_age`].
130    pub fn fresh_payload_at(&self, ttl: Duration, now: SystemTime) -> Result<Option<Vec<u8>>> {
131        if let Some(remaining) = self.backoff_remaining_at(now) {
132            if self.payload_age().is_some_and(|age| age <= MAX_STALE) {
133                return self.read_payload().map(Some);
134            }
135            return Err(AppError::Http {
136                status: 429,
137                body: format!("rate limited; next attempt in {}", human_backoff(remaining)),
138            });
139        }
140        let Some(age) = self.payload_age() else {
141            return Ok(None);
142        };
143        if age < ttl {
144            self.read_payload().map(Some)
145        } else {
146            Ok(None)
147        }
148    }
149
150    /// Arm the rate-limit backoff: no request until `now + RATE_LIMIT_BACKOFF`.
151    /// Best-effort, never errors — a cache dir that cannot be written costs a
152    /// retry, not a crash.
153    pub fn note_rate_limit_at(&self, now: SystemTime) {
154        let until = now + RATE_LIMIT_BACKOFF;
155        let secs = until
156            .duration_since(SystemTime::UNIX_EPOCH)
157            .map(|d| d.as_secs())
158            .unwrap_or(0);
159        let _ = atomic_write(&self.retry_after_path(), secs.to_string().as_bytes());
160    }
161
162    /// Best-effort removal of the backoff marker. A successful payload write
163    /// and an explicit `clear_last_error` both end the backoff.
164    pub fn clear_backoff(&self) {
165        let _ = fs::remove_file(self.retry_after_path());
166    }
167
168    /// Time left on an armed backoff, as of `now`. `None` when the marker is
169    /// missing, unparseable, or already in the past — a corrupt marker must
170    /// never pin a vendor offline.
171    pub fn backoff_remaining_at(&self, now: SystemTime) -> Option<Duration> {
172        let raw = fs::read_to_string(self.retry_after_path()).ok()?;
173        let secs = raw.trim().parse::<u64>().ok()?;
174        let until = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs))?;
175        let remaining = until.duration_since(now).ok()?;
176        if remaining.is_zero() {
177            None
178        } else {
179            Some(remaining)
180        }
181    }
182
183    /// [`Cache::backoff_remaining_at`] against the real clock.
184    pub fn backoff_remaining(&self) -> Option<Duration> {
185        self.backoff_remaining_at(SystemTime::now())
186    }
187
188    /// Read the payload regardless of age. `Err` if the file exists but is
189    /// unreadable; `Ok(None)` if it just doesn't exist.
190    ///
191    /// Prefer [`Cache::fallback_payload`] on failure paths — this one imposes
192    /// no age limit, so it will happily hand back a month-old figure.
193    pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
194        if !self.payload_path().exists() {
195            return Ok(None);
196        }
197        self.read_payload().map(Some)
198    }
199
200    /// Payload for the *failure* path: the last good value, but only while it
201    /// is still worth showing. Beyond `max_stale` this returns `Ok(None)` so
202    /// the caller surfaces the real error instead of presenting week-old
203    /// numbers as if they were current — a bar that silently freezes on
204    /// history is worse than one that says it cannot reach the API.
205    pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
206        let Some(age) = self.payload_age() else {
207            return Ok(None);
208        };
209        if age > max_stale {
210            return Ok(None);
211        }
212        self.read_payload().map(Some)
213    }
214
215    fn read_payload(&self) -> Result<Vec<u8>> {
216        let p = self.payload_path();
217        let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
218        let mut buf = Vec::new();
219        f.read_to_end(&mut buf)
220            .map_err(|e| AppError::io_at(&p, e))?;
221        Ok(buf)
222    }
223
224    /// Atomically write a new payload. Uses `tempfile + persist` (POSIX
225    /// rename), matching claudebar's `mktemp + mv` invariant.
226    pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
227        self.ensure_dir()?;
228        let mut tmp = tempfile::Builder::new()
229            .prefix(".usage.")
230            .tempfile_in(&self.dir)
231            .map_err(|e| AppError::io_at(&self.dir, e))?;
232        tmp.write_all(bytes)
233            .map_err(|e| AppError::io_at(tmp.path(), e))?;
234        tmp.as_file_mut()
235            .sync_all()
236            .map_err(|e| AppError::io_at(tmp.path(), e))?;
237        tmp.persist(self.payload_path())
238            .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
239        // A successful write clears any stale marker, and a successful
240        // response is proof the rate limit has lifted.
241        let _ = fs::remove_file(self.stale_path());
242        let _ = fs::remove_file(self.last_error_path());
243        self.clear_backoff();
244        Ok(())
245    }
246
247    /// Mark the cache as stale. Idempotent.
248    pub fn mark_stale(&self) {
249        let _ = self.ensure_dir();
250        let _ = File::create(self.stale_path());
251    }
252
253    pub fn is_stale(&self) -> bool {
254        self.stale_path().exists()
255    }
256
257    /// Drop the payload and its sidecars when the credential behind this cache
258    /// changed hands: they describe the previous login, and a fresh payload
259    /// would be served as the new one's. Best-effort, like the other markers.
260    pub fn forget(&self) {
261        let _ = fs::remove_file(self.payload_path());
262        let _ = fs::remove_file(self.stale_path());
263        let _ = fs::remove_file(self.last_error_path());
264        self.clear_backoff();
265    }
266
267    /// Write the `.last_error` marker — first line `code`, everything after it
268    /// `msg`. Best-effort, never errors (matches claudebar:478-486 which
269    /// silently continues if the cache dir isn't writable).
270    ///
271    /// **Returns exactly what was written**, so a caller that also puts the
272    /// failure in its [`crate::vendor::VendorOutcome`] can hand over this pair
273    /// instead of deriving a second one from the raw body. The two must not be
274    /// computed separately: persisting a redacted message while the in-memory
275    /// copy kept the original is how a `401` body reached the widget tooltip on
276    /// the one run that had a warm cache to fall back on. Callers that only
277    /// persist can keep ignoring the return.
278    pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
279        let _ = self.ensure_dir();
280        let path = self.last_error_path();
281        // Authentication failure bodies routinely include account identifiers or
282        // partial credential details. Do not persist them; other status bodies
283        // remain useful diagnostics after their usual control-char cleanup.
284        let msg = if matches!(code, 401 | 403) {
285            AUTH_FAILURE_MESSAGE
286        } else {
287            msg
288        };
289        let msg = crate::display::sanitize_untrusted_field(msg);
290        let body = format!("{code}\n{msg}");
291        let _ = atomic_write(&path, body.as_bytes());
292        if code == 429 {
293            self.note_rate_limit_at(SystemTime::now());
294        }
295        (code, msg)
296    }
297
298    /// Best-effort removal of the `.last_error` marker, and of the backoff
299    /// that a 429 among those errors may have armed.
300    pub fn clear_last_error(&self) {
301        let _ = fs::remove_file(self.last_error_path());
302        self.clear_backoff();
303    }
304
305    pub fn read_last_error(&self) -> Option<(u16, String)> {
306        let raw = fs::read_to_string(self.last_error_path()).ok()?;
307        // The message is *everything* past the first newline, not just the next
308        // line: vendors store the raw HTTP body here and those are routinely
309        // multi-line JSON, so taking one line truncated the user's diagnostic.
310        // Files from before this fix parse unchanged — the writer always framed
311        // them this way, only the reader threw the tail away.
312        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
313        Some((code.parse::<u16>().ok()?, msg.to_string()))
314    }
315}
316
317/// Render a backoff remainder for a tooltip: seconds below a minute, whole
318/// minutes rounded *up* above it (`4m01s` reads as `5m` — promising less
319/// wait than the real one would make the next poll look broken), and hours
320/// once the minutes pass sixty (`1h 2m`, `1h`).
321fn human_backoff(remaining: Duration) -> String {
322    let secs = remaining.as_secs();
323    if secs < 60 {
324        return format!("{secs}s");
325    }
326    let minutes = secs.div_ceil(60);
327    let (hours, minutes) = (minutes / 60, minutes % 60);
328    match (hours, minutes) {
329        (0, m) => format!("{m}m"),
330        (h, 0) => format!("{h}h"),
331        (h, m) => format!("{h}h {m}m"),
332    }
333}
334
335/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
336/// Returned guard releases the lock on drop.
337///
338/// The flock file is created if missing, but its content is unused — only
339/// the lock matters.
340/// Async wrapper around [`acquire_lock`].
341///
342/// The blocking version parks the calling thread in a sleep loop for up to
343/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
344/// stalls *everything*: keyboard input, the refresh timer, and every other
345/// vendor's in-flight request. Running the wait on the blocking pool keeps the
346/// reactor free while a contended lock is waited on.
347pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
348    let path = path.to_path_buf();
349    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
350        .await
351        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
352}
353
354pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
355    if let Some(parent) = path.parent() {
356        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
357    }
358    let f = OpenOptions::new()
359        .create(true)
360        .read(true)
361        .write(true)
362        .truncate(false)
363        .open(path)
364        .map_err(|e| AppError::io_at(path, e))?;
365
366    let deadline = std::time::Instant::now() + timeout;
367    loop {
368        match f.try_lock_exclusive() {
369            Ok(()) => return Ok(LockGuard { file: f }),
370            Err(_) => {
371                if std::time::Instant::now() >= deadline {
372                    return Err(AppError::Other(format!(
373                        "cache lock timeout after {:?}",
374                        timeout
375                    )));
376                }
377                std::thread::sleep(Duration::from_millis(50));
378            }
379        }
380    }
381}
382
383/// Releases the flock on drop. Holding this across an `.await` is fine as
384/// long as you don't move it across tasks (we always use it in `tokio::main`
385/// on a single thread).
386pub struct LockGuard {
387    file: File,
388}
389
390impl Drop for LockGuard {
391    fn drop(&mut self) {
392        let _ = FileExt::unlock(&self.file);
393    }
394}
395
396/// Atomic write helper used by `write_last_error`. Public for vendors that
397/// need to write small sidecar files (credentials, etc.).
398pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
399    let dir = path.parent().ok_or_else(|| {
400        AppError::Other(format!(
401            "atomic_write: path has no parent: {}",
402            path.display()
403        ))
404    })?;
405    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
406    let mut tmp = tempfile::Builder::new()
407        .prefix(".tmp.")
408        .tempfile_in(dir)
409        .map_err(|e| AppError::io_at(dir, e))?;
410    tmp.write_all(bytes)
411        .map_err(|e| AppError::io_at(tmp.path(), e))?;
412    tmp.as_file_mut()
413        .sync_all()
414        .map_err(|e| AppError::io_at(tmp.path(), e))?;
415    tmp.persist(path)
416        .map_err(|e| AppError::io_at(path, e.error))?;
417    Ok(())
418}
419
420pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
421    directories::BaseDirs::new()
422        .map(|b| b.cache_dir().to_path_buf())
423        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
424}
425
426/// The user's home directory, resolved cross-platform via `directories`
427/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
428///
429/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
430/// files from fixed dotfiles under `$HOME`; they share this resolver the same
431/// way they already share [`atomic_write`], so home resolution lives in one
432/// place rather than being reimplemented per vendor.
433pub fn home_dir() -> Result<PathBuf> {
434    directories::BaseDirs::new()
435        .map(|b| b.home_dir().to_path_buf())
436        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
437}
438
439/// The identity a vendor cache is scoped to: the first 8 bytes (16 hex chars)
440/// of the SHA-256 of the credential. A re-login changes it, so one login's
441/// figures are never served as another's; the secret itself is never stored.
442/// Existing cache files carry this exact value, so the truncation is a
443/// compatibility contract, not a tunable.
444pub fn fingerprint_of(secret: &str) -> String {
445    use sha2::{Digest, Sha256};
446    use std::fmt::Write as _;
447
448    let digest = Sha256::digest(secret.as_bytes());
449    let mut hex = String::with_capacity(16);
450    for byte in digest.iter().take(8) {
451        let _ = write!(hex, "{byte:02x}");
452    }
453    hex
454}
455
456/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
457/// it. [`atomic_write`] replaces its destination via rename, which on Windows
458/// fails while the destination is held open (as a live `NamedTempFile` handle
459/// would be) — so tests that exercise a write-back must target a closed file.
460/// Returns the dir (the caller keeps it alive) and the file's path; the file
461/// exists only when `contents` is given.
462#[cfg(test)]
463pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
464    let dir = tempfile::TempDir::new().unwrap();
465    let path = dir.path().join(name);
466    if let Some(c) = contents {
467        std::fs::write(&path, c).unwrap();
468    }
469    (dir, path)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use tempfile::TempDir;
476
477    fn fixture() -> (TempDir, Cache) {
478        let td = TempDir::new().unwrap();
479        let cache = Cache::at(td.path().join("anthropic"));
480        cache.ensure_dir().unwrap();
481        (td, cache)
482    }
483
484    #[test]
485    fn ensure_dir_is_idempotent() {
486        let (_td, cache) = fixture();
487        cache.ensure_dir().unwrap();
488        cache.ensure_dir().unwrap();
489        assert!(cache.dir().is_dir());
490    }
491
492    #[test]
493    fn write_then_read_round_trip() {
494        let (_td, cache) = fixture();
495        cache.write_payload(b"hello world").unwrap();
496        let got = cache.maybe_payload().unwrap();
497        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
498    }
499
500    #[test]
501    fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
502        // SHA-256("abc") = ba7816bf 8f01cfea 414140de ... — pinned because
503        // existing on-disk caches are keyed by exactly this value.
504        assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
505        assert_eq!(fingerprint_of("").len(), 16);
506        assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
507    }
508
509    #[test]
510    fn forget_drops_the_payload_and_its_sidecars() {
511        let (_td, cache) = fixture();
512        cache.write_payload(b"previous login").unwrap();
513        cache.mark_stale();
514        cache.write_last_error(401, "expired");
515        cache.note_rate_limit_at(SystemTime::now());
516        cache.forget();
517        assert!(cache.maybe_payload().unwrap().is_none());
518        assert!(!cache.is_stale());
519        assert!(cache.read_last_error().is_none());
520        assert!(cache.backoff_remaining().is_none());
521        assert!(cache.dir().is_dir());
522    }
523
524    #[test]
525    fn maybe_payload_returns_none_when_missing() {
526        let (_td, cache) = fixture();
527        assert!(cache.maybe_payload().unwrap().is_none());
528    }
529
530    #[test]
531    fn fresh_payload_respects_ttl() {
532        let (_td, cache) = fixture();
533        cache.write_payload(b"x").unwrap();
534        // Fresh = within a generous TTL.
535        assert!(
536            cache
537                .fresh_payload(Duration::from_secs(10))
538                .unwrap()
539                .is_some()
540        );
541        // Force "stale" by passing a zero TTL — payload is older than 0s.
542        assert!(
543            cache
544                .fresh_payload(Duration::from_secs(0))
545                .unwrap()
546                .is_none()
547        );
548    }
549
550    #[test]
551    fn write_clears_stale_marker_and_last_error() {
552        let (_td, cache) = fixture();
553        cache.mark_stale();
554        cache.write_last_error(429, "rate limited");
555        assert!(cache.is_stale());
556        assert!(cache.read_last_error().is_some());
557
558        cache.write_payload(b"fresh").unwrap();
559        assert!(!cache.is_stale());
560        assert!(cache.read_last_error().is_none());
561    }
562
563    // ---- rate-limit backoff ------------------------------------------------
564
565    /// A fixed instant well past the epoch so the arithmetic never underflows.
566    fn t0() -> SystemTime {
567        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
568    }
569
570    fn arm_backoff_at(cache: &Cache, now: SystemTime) {
571        cache.note_rate_limit_at(now);
572        assert!(cache.retry_after_path().exists());
573    }
574
575    #[test]
576    fn a_429_arms_the_backoff_and_other_statuses_do_not() {
577        let (_td, cache) = fixture();
578        cache.write_last_error(500, "upstream down");
579        assert!(cache.backoff_remaining().is_none());
580        assert!(!cache.retry_after_path().exists());
581
582        cache.write_last_error(429, "slow down");
583        let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
584        // Written against the real clock a moment ago: within a few seconds of
585        // the full window, never above it.
586        assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
587        assert!(
588            remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
589            "{remaining:?}"
590        );
591        // The persisted `.last_error` is untouched by the backoff bookkeeping.
592        assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
593    }
594
595    #[test]
596    fn backoff_remaining_counts_down_from_the_injected_clock() {
597        let (_td, cache) = fixture();
598        arm_backoff_at(&cache, t0());
599
600        assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
601        assert_eq!(
602            cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
603            Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
604        );
605        // Exactly at expiry and beyond: no backoff.
606        assert!(
607            cache
608                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
609                .is_none()
610        );
611        assert!(
612            cache
613                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
614                .is_none()
615        );
616    }
617
618    #[test]
619    fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
620        let (_td, cache) = fixture();
621        arm_backoff_at(&cache, t0());
622
623        let err = cache
624            .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
625            .expect_err("no payload during backoff must be an error, not a fetch");
626        match err {
627            AppError::Http { status, body } => {
628                assert_eq!(status, 429);
629                assert!(body.contains("next attempt in"), "{body}");
630                // 5m − 19s = 4m41s, rounded up to the next minute.
631                assert!(body.ends_with("5m"), "{body}");
632            }
633            other => panic!("expected Http 429, got {other:?}"),
634        }
635    }
636
637    /// The whole point of the backoff: a vendor that still has a figure keeps
638    /// showing it instead of re-hitting the limit every poll. The payload is
639    /// written now with a zero TTL, so the ordinary fast path would reject it
640    /// as expired; only the backoff branch can be the one returning it.
641    #[test]
642    fn during_backoff_an_expired_but_not_stale_payload_is_served() {
643        let (_td, cache) = fixture();
644        cache.write_payload(b"last good").unwrap();
645        arm_backoff_at(&cache, t0());
646
647        // Control: without a backoff, TTL 0 means "not fresh".
648        assert!(
649            cache
650                .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
651                .unwrap()
652                .is_none()
653        );
654        // Under backoff the same payload is the answer.
655        assert_eq!(
656            cache
657                .fresh_payload_at(Duration::ZERO, t0())
658                .unwrap()
659                .as_deref(),
660            Some(&b"last good"[..])
661        );
662    }
663
664    #[test]
665    fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
666        let (_td, cache) = fixture();
667        cache.write_payload(b"x").unwrap();
668        arm_backoff_at(&cache, t0());
669        let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
670
671        assert!(
672            cache
673                .fresh_payload_at(Duration::from_secs(10), later)
674                .unwrap()
675                .is_some()
676        );
677        assert!(
678            cache
679                .fresh_payload_at(Duration::ZERO, later)
680                .unwrap()
681                .is_none()
682        );
683        // And with no payload at all, expiry means a plain "go fetch".
684        fs::remove_file(cache.payload_path()).unwrap();
685        assert!(
686            cache
687                .fresh_payload_at(DEFAULT_TTL, later)
688                .unwrap()
689                .is_none()
690        );
691    }
692
693    #[test]
694    fn a_successful_payload_write_clears_the_backoff() {
695        let (_td, cache) = fixture();
696        arm_backoff_at(&cache, t0());
697        assert!(cache.backoff_remaining_at(t0()).is_some());
698
699        cache.write_payload(b"fresh").unwrap();
700        assert!(cache.backoff_remaining_at(t0()).is_none());
701        assert!(!cache.retry_after_path().exists());
702    }
703
704    #[test]
705    fn clear_last_error_also_clears_the_backoff() {
706        let (_td, cache) = fixture();
707        cache.write_last_error(429, "slow down");
708        assert!(cache.backoff_remaining().is_some());
709
710        cache.clear_last_error();
711        assert!(cache.backoff_remaining().is_none());
712        assert!(!cache.retry_after_path().exists());
713    }
714
715    #[test]
716    fn a_corrupt_retry_after_marker_is_no_backoff() {
717        let (_td, cache) = fixture();
718        for raw in ["", "soon", "-5", "1e9", "12 34"] {
719            fs::write(cache.retry_after_path(), raw).unwrap();
720            assert!(
721                cache.backoff_remaining_at(t0()).is_none(),
722                "{raw:?} must not pin the vendor offline"
723            );
724            assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
725        }
726        // Surrounding whitespace is tolerated, though — a trailing newline is
727        // the sort of thing a hand edit leaves behind.
728        let until = t0() + Duration::from_secs(90);
729        let secs = until
730            .duration_since(SystemTime::UNIX_EPOCH)
731            .unwrap()
732            .as_secs();
733        fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
734        assert_eq!(
735            cache.backoff_remaining_at(t0()),
736            Some(Duration::from_secs(90))
737        );
738    }
739
740    #[test]
741    fn human_backoff_formats_seconds_minutes_and_hours() {
742        let s = Duration::from_secs;
743        assert_eq!(human_backoff(s(0)), "0s");
744        assert_eq!(human_backoff(s(45)), "45s");
745        assert_eq!(human_backoff(s(59)), "59s");
746        assert_eq!(human_backoff(s(60)), "1m");
747        assert_eq!(human_backoff(s(4 * 60)), "4m");
748        assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
749        assert_eq!(human_backoff(s(5 * 60)), "5m");
750        assert_eq!(human_backoff(s(60 * 60)), "1h");
751        assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
752        assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
753        assert_eq!(human_backoff(s(2 * 3600)), "2h");
754    }
755
756    #[test]
757    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
758        let (_td, cache) = fixture();
759        cache.write_payload(b"old").unwrap();
760
761        // Let the payload acquire real age rather than rewriting its mtime:
762        // Windows denies reopening the just-persisted file for an attribute
763        // write, and the boundary being tested is the same either way. The
764        // margin is ~12x the threshold so filesystem timestamp granularity
765        // cannot make this flaky.
766        std::thread::sleep(Duration::from_millis(60));
767
768        // Still readable when age is not considered — `maybe_payload` is the
769        // unbounded reader, which is exactly why failure paths must not use it.
770        assert!(cache.maybe_payload().unwrap().is_some());
771
772        // Past the limit, the failure path gets nothing and the caller has to
773        // surface the real error. `MAX_STALE` was dead code before this:
774        // every fallback served history forever.
775        assert!(
776            cache
777                .fallback_payload(Duration::from_millis(5))
778                .unwrap()
779                .is_none()
780        );
781
782        // Inside the window it is still served, so the guard is a limit and
783        // not a blanket refusal.
784        assert_eq!(
785            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
786            Some(&b"old"[..])
787        );
788    }
789
790    #[test]
791    fn last_error_round_trip() {
792        let (_td, cache) = fixture();
793        cache.write_last_error(503, "service unavailable");
794        let (code, msg) = cache.read_last_error().unwrap();
795        assert_eq!(code, 503);
796        assert_eq!(msg, "service unavailable");
797    }
798
799    #[test]
800    fn last_error_with_empty_message_round_trips() {
801        let (_td, cache) = fixture();
802        cache.write_last_error(429, "");
803        let (code, msg) = cache.read_last_error().unwrap();
804        assert_eq!(code, 429);
805        assert_eq!(msg, "");
806    }
807
808    #[test]
809    fn last_error_replaces_401_body_with_credential_neutral_message() {
810        let (_td, cache) = fixture();
811        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
812
813        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
814        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
815        assert!(!persisted.contains("PANCEA"));
816        assert!(!persisted.contains("<credential>"));
817    }
818
819    #[test]
820    fn last_error_replaces_403_body_with_credential_neutral_message() {
821        let (_td, cache) = fixture();
822        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
823
824        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
825        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
826        assert!(!persisted.contains("PANCEA"));
827        assert!(!persisted.contains("<credential>"));
828    }
829
830    /// The invariant that keeps the displayed message from drifting away from
831    /// the persisted one: what comes back is what a later run would read from
832    /// disk, so a caller that shows the return value cannot show anything the
833    /// cache refused to keep. Asserted for the redacting arm and the ordinary
834    /// one, since only the first rewrites the message.
835    #[test]
836    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
837        for (code, raw) in [
838            (401u16, "PANCEA user@example.test <credential>&token"),
839            (403, "PANCEA account@example.test <credential>&token"),
840            (429, "rate limited, retry in 60s"),
841            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
842        ] {
843            let (_td, cache) = fixture();
844            let returned = cache.write_last_error(code, raw);
845            assert_eq!(
846                returned,
847                cache.read_last_error().unwrap(),
848                "returned pair diverged from the persisted one for {code}"
849            );
850        }
851    }
852
853    /// The defect recurred once under a second name — six vendors wrote
854    /// `Some((status, body))` inline and six more built a `diag` local first —
855    /// so the sweep that fixed the first six missed the rest. This forbids the
856    /// shape rather than the spelling: a `last_error` pair must come from
857    /// [`Cache::write_last_error`], which is the only thing that redacts.
858    ///
859    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
860    /// redacts on its own and destructures as `(*status, body)`, which is not
861    /// the borrowed shape a leak takes.
862    #[test]
863    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
864        let mut sites = Vec::new();
865        for file in crate::guard::rs_files_in("src") {
866            if !file.ends_with("fetch.rs") {
867                continue;
868            }
869            let source = std::fs::read_to_string(&file).expect("readable module");
870            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
871                if line.contains("(status, body") {
872                    sites.push(format!("{}:{}", file.display(), n + 1));
873                }
874            }
875        }
876        assert!(
877            sites.is_empty(),
878            "a last_error pair must be the return of `write_last_error`, which \
879             redacts 401/403 — building one from the raw body puts the response \
880             body in the widget tooltip. Found: {sites:#?}"
881        );
882    }
883
884    /// The cold-cache decision — serve a stale figure, or surface the error
885    /// that caused the refresh to fail — is `outcome::fallback`'s alone. It
886    /// drifted into two disagreeing generations once, when each vendor owned a
887    /// copy: five replaced the original error with a generic "no usable cache"
888    /// while thirteen returned it. `fallback_payload` is the entry point to
889    /// that decision, so a second caller is a second copy in the making.
890    #[test]
891    fn only_the_shared_fallback_reads_the_stale_payload() {
892        let mut sites = Vec::new();
893        for file in crate::guard::rs_files_in("src") {
894            if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
895                continue;
896            }
897            let source = std::fs::read_to_string(&file).expect("readable module");
898            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
899                if line.contains("fallback_payload(") {
900                    sites.push(format!("{}:{}", file.display(), n + 1));
901                }
902            }
903        }
904        assert!(
905            sites.is_empty(),
906            "reach the stale payload through `outcome::fallback`, which decides \
907             what a cold cache means for every vendor at once. Found: {sites:#?}"
908        );
909    }
910
911    /// The bug this closes: the pair handed to the widget was built from the
912    /// raw body in parallel with the redacted one going to disk, so the run
913    /// that hit the `401` showed the body and only the *next* run showed the
914    /// neutral message. The returned pair carries the redaction.
915    #[test]
916    fn the_returned_pair_carries_the_auth_redaction() {
917        for code in [401u16, 403] {
918            let (_td, cache) = fixture();
919            let (returned_code, msg) =
920                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
921            assert_eq!(returned_code, code);
922            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
923            assert!(!msg.contains("PANCEA"), "{msg}");
924            assert!(!msg.contains("<credential>"), "{msg}");
925        }
926    }
927
928    /// The regression this guards: vendors write the raw HTTP body, which is
929    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
930    /// showed `{` and dropped the actual API explanation.
931    #[test]
932    fn last_error_round_trips_a_multi_line_message() {
933        let (_td, cache) = fixture();
934        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
935        cache.write_last_error(429, body);
936
937        let (code, msg) = cache.read_last_error().unwrap();
938        assert_eq!(code, 429);
939        assert_eq!(msg, body);
940        assert!(
941            msg.contains("quota exhausted"),
942            "message was truncated to its first line: {msg:?}"
943        );
944    }
945
946    #[test]
947    fn last_error_strips_terminal_controls_before_persisting() {
948        let (_td, cache) = fixture();
949        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
950
951        let (code, msg) = cache.read_last_error().unwrap();
952        assert_eq!(code, 500);
953        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
954        assert!(
955            msg.contains("Y2FuYXJ5"),
956            "non-auth diagnostic was not preserved"
957        );
958        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
959    }
960
961    /// A user upgrades with a `.last_error` already on disk; it must still
962    /// parse. Trailing-newline-free files (the whole marker being just a code)
963    /// count too — that is the one shape the old `lines()` reader tolerated.
964    #[test]
965    fn last_error_reads_files_written_by_the_previous_version() {
966        let (_td, cache) = fixture();
967
968        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
969        assert_eq!(
970            cache.read_last_error(),
971            Some((503, "service unavailable".into()))
972        );
973
974        fs::write(cache.last_error_path(), "429").unwrap();
975        assert_eq!(cache.read_last_error(), Some((429, String::new())));
976
977        // A non-numeric first line is still no error at all, never a fake 0.
978        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
979        assert!(cache.read_last_error().is_none());
980    }
981
982    #[test]
983    fn lock_serializes_concurrent_acquirers() {
984        // First lock succeeds; while held, a second non-blocking attempt
985        // should time out quickly.
986        let (_td, cache) = fixture();
987        let lock_path = cache.lock_path();
988        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
989
990        let res = acquire_lock(&lock_path, Duration::from_millis(100));
991        assert!(matches!(res, Err(AppError::Other(_))));
992    }
993
994    /// The regression this guards: `acquire_lock` parks the thread in a sleep
995    /// loop, so on the TUI's current-thread runtime a contended lock froze
996    /// keyboard input, the refresh timer and every other vendor's fetch until
997    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
998    /// so unrelated timers must keep firing while the lock is held elsewhere.
999    #[tokio::test(flavor = "current_thread")]
1000    async fn async_lock_does_not_stall_the_runtime() {
1001        let (_td, cache) = fixture();
1002        let lock_path = cache.lock_path();
1003        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1004
1005        // This will wait the full timeout — it can never win the lock.
1006        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1007
1008        // Meanwhile the runtime must still be able to make progress.
1009        let mut ticks = 0usize;
1010        let ticker = async {
1011            let mut iv = tokio::time::interval(Duration::from_millis(20));
1012            iv.tick().await;
1013            loop {
1014                iv.tick().await;
1015                ticks += 1;
1016            }
1017        };
1018
1019        tokio::select! {
1020            res = waiter => {
1021                // The lock attempt is expected to time out.
1022                assert!(matches!(res, Err(AppError::Other(_))));
1023            }
1024            _ = ticker => unreachable!("the ticker loops forever"),
1025        }
1026        assert!(
1027            ticks > 1,
1028            "runtime was starved while the lock was contended ({ticks} ticks)"
1029        );
1030    }
1031
1032    #[test]
1033    fn atomic_write_creates_parent_dirs() {
1034        let td = TempDir::new().unwrap();
1035        let nested = td.path().join("a/b/c/file.txt");
1036        atomic_write(&nested, b"abc").unwrap();
1037        assert_eq!(fs::read(&nested).unwrap(), b"abc");
1038    }
1039}