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    /// Write the `.last_error` marker — first line `code`, everything after it
258    /// `msg`. Best-effort, never errors (matches claudebar:478-486 which
259    /// silently continues if the cache dir isn't writable).
260    ///
261    /// **Returns exactly what was written**, so a caller that also puts the
262    /// failure in its [`crate::vendor::VendorOutcome`] can hand over this pair
263    /// instead of deriving a second one from the raw body. The two must not be
264    /// computed separately: persisting a redacted message while the in-memory
265    /// copy kept the original is how a `401` body reached the widget tooltip on
266    /// the one run that had a warm cache to fall back on. Callers that only
267    /// persist can keep ignoring the return.
268    pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
269        let _ = self.ensure_dir();
270        let path = self.last_error_path();
271        // Authentication failure bodies routinely include account identifiers or
272        // partial credential details. Do not persist them; other status bodies
273        // remain useful diagnostics after their usual control-char cleanup.
274        let msg = if matches!(code, 401 | 403) {
275            AUTH_FAILURE_MESSAGE
276        } else {
277            msg
278        };
279        let msg = crate::display::sanitize_untrusted_field(msg);
280        let body = format!("{code}\n{msg}");
281        let _ = atomic_write(&path, body.as_bytes());
282        if code == 429 {
283            self.note_rate_limit_at(SystemTime::now());
284        }
285        (code, msg)
286    }
287
288    /// Best-effort removal of the `.last_error` marker, and of the backoff
289    /// that a 429 among those errors may have armed.
290    pub fn clear_last_error(&self) {
291        let _ = fs::remove_file(self.last_error_path());
292        self.clear_backoff();
293    }
294
295    pub fn read_last_error(&self) -> Option<(u16, String)> {
296        let raw = fs::read_to_string(self.last_error_path()).ok()?;
297        // The message is *everything* past the first newline, not just the next
298        // line: vendors store the raw HTTP body here and those are routinely
299        // multi-line JSON, so taking one line truncated the user's diagnostic.
300        // Files from before this fix parse unchanged — the writer always framed
301        // them this way, only the reader threw the tail away.
302        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
303        Some((code.parse::<u16>().ok()?, msg.to_string()))
304    }
305}
306
307/// Render a backoff remainder for a tooltip: seconds below a minute, whole
308/// minutes rounded *up* above it (`4m01s` reads as `5m` — promising less
309/// wait than the real one would make the next poll look broken), and hours
310/// once the minutes pass sixty (`1h 2m`, `1h`).
311fn human_backoff(remaining: Duration) -> String {
312    let secs = remaining.as_secs();
313    if secs < 60 {
314        return format!("{secs}s");
315    }
316    let minutes = secs.div_ceil(60);
317    let (hours, minutes) = (minutes / 60, minutes % 60);
318    match (hours, minutes) {
319        (0, m) => format!("{m}m"),
320        (h, 0) => format!("{h}h"),
321        (h, m) => format!("{h}h {m}m"),
322    }
323}
324
325/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
326/// Returned guard releases the lock on drop.
327///
328/// The flock file is created if missing, but its content is unused — only
329/// the lock matters.
330/// Async wrapper around [`acquire_lock`].
331///
332/// The blocking version parks the calling thread in a sleep loop for up to
333/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
334/// stalls *everything*: keyboard input, the refresh timer, and every other
335/// vendor's in-flight request. Running the wait on the blocking pool keeps the
336/// reactor free while a contended lock is waited on.
337pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
338    let path = path.to_path_buf();
339    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
340        .await
341        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
342}
343
344pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
345    if let Some(parent) = path.parent() {
346        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
347    }
348    let f = OpenOptions::new()
349        .create(true)
350        .read(true)
351        .write(true)
352        .truncate(false)
353        .open(path)
354        .map_err(|e| AppError::io_at(path, e))?;
355
356    let deadline = std::time::Instant::now() + timeout;
357    loop {
358        match f.try_lock_exclusive() {
359            Ok(()) => return Ok(LockGuard { file: f }),
360            Err(_) => {
361                if std::time::Instant::now() >= deadline {
362                    return Err(AppError::Other(format!(
363                        "cache lock timeout after {:?}",
364                        timeout
365                    )));
366                }
367                std::thread::sleep(Duration::from_millis(50));
368            }
369        }
370    }
371}
372
373/// Releases the flock on drop. Holding this across an `.await` is fine as
374/// long as you don't move it across tasks (we always use it in `tokio::main`
375/// on a single thread).
376pub struct LockGuard {
377    file: File,
378}
379
380impl Drop for LockGuard {
381    fn drop(&mut self) {
382        let _ = FileExt::unlock(&self.file);
383    }
384}
385
386/// Atomic write helper used by `write_last_error`. Public for vendors that
387/// need to write small sidecar files (credentials, etc.).
388pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
389    let dir = path.parent().ok_or_else(|| {
390        AppError::Other(format!(
391            "atomic_write: path has no parent: {}",
392            path.display()
393        ))
394    })?;
395    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
396    let mut tmp = tempfile::Builder::new()
397        .prefix(".tmp.")
398        .tempfile_in(dir)
399        .map_err(|e| AppError::io_at(dir, e))?;
400    tmp.write_all(bytes)
401        .map_err(|e| AppError::io_at(tmp.path(), e))?;
402    tmp.as_file_mut()
403        .sync_all()
404        .map_err(|e| AppError::io_at(tmp.path(), e))?;
405    tmp.persist(path)
406        .map_err(|e| AppError::io_at(path, e.error))?;
407    Ok(())
408}
409
410pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
411    directories::BaseDirs::new()
412        .map(|b| b.cache_dir().to_path_buf())
413        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
414}
415
416/// The user's home directory, resolved cross-platform via `directories`
417/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
418///
419/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
420/// files from fixed dotfiles under `$HOME`; they share this resolver the same
421/// way they already share [`atomic_write`], so home resolution lives in one
422/// place rather than being reimplemented per vendor.
423pub fn home_dir() -> Result<PathBuf> {
424    directories::BaseDirs::new()
425        .map(|b| b.home_dir().to_path_buf())
426        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
427}
428
429/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
430/// it. [`atomic_write`] replaces its destination via rename, which on Windows
431/// fails while the destination is held open (as a live `NamedTempFile` handle
432/// would be) — so tests that exercise a write-back must target a closed file.
433/// Returns the dir (the caller keeps it alive) and the file's path; the file
434/// exists only when `contents` is given.
435#[cfg(test)]
436pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
437    let dir = tempfile::TempDir::new().unwrap();
438    let path = dir.path().join(name);
439    if let Some(c) = contents {
440        std::fs::write(&path, c).unwrap();
441    }
442    (dir, path)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use tempfile::TempDir;
449
450    fn fixture() -> (TempDir, Cache) {
451        let td = TempDir::new().unwrap();
452        let cache = Cache::at(td.path().join("anthropic"));
453        cache.ensure_dir().unwrap();
454        (td, cache)
455    }
456
457    #[test]
458    fn ensure_dir_is_idempotent() {
459        let (_td, cache) = fixture();
460        cache.ensure_dir().unwrap();
461        cache.ensure_dir().unwrap();
462        assert!(cache.dir().is_dir());
463    }
464
465    #[test]
466    fn write_then_read_round_trip() {
467        let (_td, cache) = fixture();
468        cache.write_payload(b"hello world").unwrap();
469        let got = cache.maybe_payload().unwrap();
470        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
471    }
472
473    #[test]
474    fn maybe_payload_returns_none_when_missing() {
475        let (_td, cache) = fixture();
476        assert!(cache.maybe_payload().unwrap().is_none());
477    }
478
479    #[test]
480    fn fresh_payload_respects_ttl() {
481        let (_td, cache) = fixture();
482        cache.write_payload(b"x").unwrap();
483        // Fresh = within a generous TTL.
484        assert!(
485            cache
486                .fresh_payload(Duration::from_secs(10))
487                .unwrap()
488                .is_some()
489        );
490        // Force "stale" by passing a zero TTL — payload is older than 0s.
491        assert!(
492            cache
493                .fresh_payload(Duration::from_secs(0))
494                .unwrap()
495                .is_none()
496        );
497    }
498
499    #[test]
500    fn write_clears_stale_marker_and_last_error() {
501        let (_td, cache) = fixture();
502        cache.mark_stale();
503        cache.write_last_error(429, "rate limited");
504        assert!(cache.is_stale());
505        assert!(cache.read_last_error().is_some());
506
507        cache.write_payload(b"fresh").unwrap();
508        assert!(!cache.is_stale());
509        assert!(cache.read_last_error().is_none());
510    }
511
512    // ---- rate-limit backoff ------------------------------------------------
513
514    /// A fixed instant well past the epoch so the arithmetic never underflows.
515    fn t0() -> SystemTime {
516        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
517    }
518
519    fn arm_backoff_at(cache: &Cache, now: SystemTime) {
520        cache.note_rate_limit_at(now);
521        assert!(cache.retry_after_path().exists());
522    }
523
524    #[test]
525    fn a_429_arms_the_backoff_and_other_statuses_do_not() {
526        let (_td, cache) = fixture();
527        cache.write_last_error(500, "upstream down");
528        assert!(cache.backoff_remaining().is_none());
529        assert!(!cache.retry_after_path().exists());
530
531        cache.write_last_error(429, "slow down");
532        let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
533        // Written against the real clock a moment ago: within a few seconds of
534        // the full window, never above it.
535        assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
536        assert!(
537            remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
538            "{remaining:?}"
539        );
540        // The persisted `.last_error` is untouched by the backoff bookkeeping.
541        assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
542    }
543
544    #[test]
545    fn backoff_remaining_counts_down_from_the_injected_clock() {
546        let (_td, cache) = fixture();
547        arm_backoff_at(&cache, t0());
548
549        assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
550        assert_eq!(
551            cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
552            Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
553        );
554        // Exactly at expiry and beyond: no backoff.
555        assert!(
556            cache
557                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
558                .is_none()
559        );
560        assert!(
561            cache
562                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
563                .is_none()
564        );
565    }
566
567    #[test]
568    fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
569        let (_td, cache) = fixture();
570        arm_backoff_at(&cache, t0());
571
572        let err = cache
573            .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
574            .expect_err("no payload during backoff must be an error, not a fetch");
575        match err {
576            AppError::Http { status, body } => {
577                assert_eq!(status, 429);
578                assert!(body.contains("next attempt in"), "{body}");
579                // 5m − 19s = 4m41s, rounded up to the next minute.
580                assert!(body.ends_with("5m"), "{body}");
581            }
582            other => panic!("expected Http 429, got {other:?}"),
583        }
584    }
585
586    /// The whole point of the backoff: a vendor that still has a figure keeps
587    /// showing it instead of re-hitting the limit every poll. The payload is
588    /// written now with a zero TTL, so the ordinary fast path would reject it
589    /// as expired; only the backoff branch can be the one returning it.
590    #[test]
591    fn during_backoff_an_expired_but_not_stale_payload_is_served() {
592        let (_td, cache) = fixture();
593        cache.write_payload(b"last good").unwrap();
594        arm_backoff_at(&cache, t0());
595
596        // Control: without a backoff, TTL 0 means "not fresh".
597        assert!(
598            cache
599                .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
600                .unwrap()
601                .is_none()
602        );
603        // Under backoff the same payload is the answer.
604        assert_eq!(
605            cache
606                .fresh_payload_at(Duration::ZERO, t0())
607                .unwrap()
608                .as_deref(),
609            Some(&b"last good"[..])
610        );
611    }
612
613    #[test]
614    fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
615        let (_td, cache) = fixture();
616        cache.write_payload(b"x").unwrap();
617        arm_backoff_at(&cache, t0());
618        let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
619
620        assert!(
621            cache
622                .fresh_payload_at(Duration::from_secs(10), later)
623                .unwrap()
624                .is_some()
625        );
626        assert!(
627            cache
628                .fresh_payload_at(Duration::ZERO, later)
629                .unwrap()
630                .is_none()
631        );
632        // And with no payload at all, expiry means a plain "go fetch".
633        fs::remove_file(cache.payload_path()).unwrap();
634        assert!(
635            cache
636                .fresh_payload_at(DEFAULT_TTL, later)
637                .unwrap()
638                .is_none()
639        );
640    }
641
642    #[test]
643    fn a_successful_payload_write_clears_the_backoff() {
644        let (_td, cache) = fixture();
645        arm_backoff_at(&cache, t0());
646        assert!(cache.backoff_remaining_at(t0()).is_some());
647
648        cache.write_payload(b"fresh").unwrap();
649        assert!(cache.backoff_remaining_at(t0()).is_none());
650        assert!(!cache.retry_after_path().exists());
651    }
652
653    #[test]
654    fn clear_last_error_also_clears_the_backoff() {
655        let (_td, cache) = fixture();
656        cache.write_last_error(429, "slow down");
657        assert!(cache.backoff_remaining().is_some());
658
659        cache.clear_last_error();
660        assert!(cache.backoff_remaining().is_none());
661        assert!(!cache.retry_after_path().exists());
662    }
663
664    #[test]
665    fn a_corrupt_retry_after_marker_is_no_backoff() {
666        let (_td, cache) = fixture();
667        for raw in ["", "soon", "-5", "1e9", "12 34"] {
668            fs::write(cache.retry_after_path(), raw).unwrap();
669            assert!(
670                cache.backoff_remaining_at(t0()).is_none(),
671                "{raw:?} must not pin the vendor offline"
672            );
673            assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
674        }
675        // Surrounding whitespace is tolerated, though — a trailing newline is
676        // the sort of thing a hand edit leaves behind.
677        let until = t0() + Duration::from_secs(90);
678        let secs = until
679            .duration_since(SystemTime::UNIX_EPOCH)
680            .unwrap()
681            .as_secs();
682        fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
683        assert_eq!(
684            cache.backoff_remaining_at(t0()),
685            Some(Duration::from_secs(90))
686        );
687    }
688
689    #[test]
690    fn human_backoff_formats_seconds_minutes_and_hours() {
691        let s = Duration::from_secs;
692        assert_eq!(human_backoff(s(0)), "0s");
693        assert_eq!(human_backoff(s(45)), "45s");
694        assert_eq!(human_backoff(s(59)), "59s");
695        assert_eq!(human_backoff(s(60)), "1m");
696        assert_eq!(human_backoff(s(4 * 60)), "4m");
697        assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
698        assert_eq!(human_backoff(s(5 * 60)), "5m");
699        assert_eq!(human_backoff(s(60 * 60)), "1h");
700        assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
701        assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
702        assert_eq!(human_backoff(s(2 * 3600)), "2h");
703    }
704
705    #[test]
706    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
707        let (_td, cache) = fixture();
708        cache.write_payload(b"old").unwrap();
709
710        // Let the payload acquire real age rather than rewriting its mtime:
711        // Windows denies reopening the just-persisted file for an attribute
712        // write, and the boundary being tested is the same either way. The
713        // margin is ~12x the threshold so filesystem timestamp granularity
714        // cannot make this flaky.
715        std::thread::sleep(Duration::from_millis(60));
716
717        // Still readable when age is not considered — `maybe_payload` is the
718        // unbounded reader, which is exactly why failure paths must not use it.
719        assert!(cache.maybe_payload().unwrap().is_some());
720
721        // Past the limit, the failure path gets nothing and the caller has to
722        // surface the real error. `MAX_STALE` was dead code before this:
723        // every fallback served history forever.
724        assert!(
725            cache
726                .fallback_payload(Duration::from_millis(5))
727                .unwrap()
728                .is_none()
729        );
730
731        // Inside the window it is still served, so the guard is a limit and
732        // not a blanket refusal.
733        assert_eq!(
734            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
735            Some(&b"old"[..])
736        );
737    }
738
739    #[test]
740    fn last_error_round_trip() {
741        let (_td, cache) = fixture();
742        cache.write_last_error(503, "service unavailable");
743        let (code, msg) = cache.read_last_error().unwrap();
744        assert_eq!(code, 503);
745        assert_eq!(msg, "service unavailable");
746    }
747
748    #[test]
749    fn last_error_with_empty_message_round_trips() {
750        let (_td, cache) = fixture();
751        cache.write_last_error(429, "");
752        let (code, msg) = cache.read_last_error().unwrap();
753        assert_eq!(code, 429);
754        assert_eq!(msg, "");
755    }
756
757    #[test]
758    fn last_error_replaces_401_body_with_credential_neutral_message() {
759        let (_td, cache) = fixture();
760        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
761
762        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
763        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
764        assert!(!persisted.contains("PANCEA"));
765        assert!(!persisted.contains("<credential>"));
766    }
767
768    #[test]
769    fn last_error_replaces_403_body_with_credential_neutral_message() {
770        let (_td, cache) = fixture();
771        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
772
773        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
774        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
775        assert!(!persisted.contains("PANCEA"));
776        assert!(!persisted.contains("<credential>"));
777    }
778
779    /// The invariant that keeps the displayed message from drifting away from
780    /// the persisted one: what comes back is what a later run would read from
781    /// disk, so a caller that shows the return value cannot show anything the
782    /// cache refused to keep. Asserted for the redacting arm and the ordinary
783    /// one, since only the first rewrites the message.
784    #[test]
785    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
786        for (code, raw) in [
787            (401u16, "PANCEA user@example.test <credential>&token"),
788            (403, "PANCEA account@example.test <credential>&token"),
789            (429, "rate limited, retry in 60s"),
790            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
791        ] {
792            let (_td, cache) = fixture();
793            let returned = cache.write_last_error(code, raw);
794            assert_eq!(
795                returned,
796                cache.read_last_error().unwrap(),
797                "returned pair diverged from the persisted one for {code}"
798            );
799        }
800    }
801
802    /// The defect recurred once under a second name — six vendors wrote
803    /// `Some((status, body))` inline and six more built a `diag` local first —
804    /// so the sweep that fixed the first six missed the rest. This forbids the
805    /// shape rather than the spelling: a `last_error` pair must come from
806    /// [`Cache::write_last_error`], which is the only thing that redacts.
807    ///
808    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
809    /// redacts on its own and destructures as `(*status, body)`, which is not
810    /// the borrowed shape a leak takes.
811    #[test]
812    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
813        let mut sites = Vec::new();
814        for file in crate::guard::rs_files_in("src") {
815            if !file.ends_with("fetch.rs") {
816                continue;
817            }
818            let source = std::fs::read_to_string(&file).expect("readable module");
819            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
820                if line.contains("(status, body") {
821                    sites.push(format!("{}:{}", file.display(), n + 1));
822                }
823            }
824        }
825        assert!(
826            sites.is_empty(),
827            "a last_error pair must be the return of `write_last_error`, which \
828             redacts 401/403 — building one from the raw body puts the response \
829             body in the widget tooltip. Found: {sites:#?}"
830        );
831    }
832
833    /// The cold-cache decision — serve a stale figure, or surface the error
834    /// that caused the refresh to fail — is `outcome::fallback`'s alone. It
835    /// drifted into two disagreeing generations once, when each vendor owned a
836    /// copy: five replaced the original error with a generic "no usable cache"
837    /// while thirteen returned it. `fallback_payload` is the entry point to
838    /// that decision, so a second caller is a second copy in the making.
839    #[test]
840    fn only_the_shared_fallback_reads_the_stale_payload() {
841        let mut sites = Vec::new();
842        for file in crate::guard::rs_files_in("src") {
843            if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
844                continue;
845            }
846            let source = std::fs::read_to_string(&file).expect("readable module");
847            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
848                if line.contains("fallback_payload(") {
849                    sites.push(format!("{}:{}", file.display(), n + 1));
850                }
851            }
852        }
853        assert!(
854            sites.is_empty(),
855            "reach the stale payload through `outcome::fallback`, which decides \
856             what a cold cache means for every vendor at once. Found: {sites:#?}"
857        );
858    }
859
860    /// The bug this closes: the pair handed to the widget was built from the
861    /// raw body in parallel with the redacted one going to disk, so the run
862    /// that hit the `401` showed the body and only the *next* run showed the
863    /// neutral message. The returned pair carries the redaction.
864    #[test]
865    fn the_returned_pair_carries_the_auth_redaction() {
866        for code in [401u16, 403] {
867            let (_td, cache) = fixture();
868            let (returned_code, msg) =
869                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
870            assert_eq!(returned_code, code);
871            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
872            assert!(!msg.contains("PANCEA"), "{msg}");
873            assert!(!msg.contains("<credential>"), "{msg}");
874        }
875    }
876
877    /// The regression this guards: vendors write the raw HTTP body, which is
878    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
879    /// showed `{` and dropped the actual API explanation.
880    #[test]
881    fn last_error_round_trips_a_multi_line_message() {
882        let (_td, cache) = fixture();
883        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
884        cache.write_last_error(429, body);
885
886        let (code, msg) = cache.read_last_error().unwrap();
887        assert_eq!(code, 429);
888        assert_eq!(msg, body);
889        assert!(
890            msg.contains("quota exhausted"),
891            "message was truncated to its first line: {msg:?}"
892        );
893    }
894
895    #[test]
896    fn last_error_strips_terminal_controls_before_persisting() {
897        let (_td, cache) = fixture();
898        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
899
900        let (code, msg) = cache.read_last_error().unwrap();
901        assert_eq!(code, 500);
902        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
903        assert!(
904            msg.contains("Y2FuYXJ5"),
905            "non-auth diagnostic was not preserved"
906        );
907        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
908    }
909
910    /// A user upgrades with a `.last_error` already on disk; it must still
911    /// parse. Trailing-newline-free files (the whole marker being just a code)
912    /// count too — that is the one shape the old `lines()` reader tolerated.
913    #[test]
914    fn last_error_reads_files_written_by_the_previous_version() {
915        let (_td, cache) = fixture();
916
917        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
918        assert_eq!(
919            cache.read_last_error(),
920            Some((503, "service unavailable".into()))
921        );
922
923        fs::write(cache.last_error_path(), "429").unwrap();
924        assert_eq!(cache.read_last_error(), Some((429, String::new())));
925
926        // A non-numeric first line is still no error at all, never a fake 0.
927        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
928        assert!(cache.read_last_error().is_none());
929    }
930
931    #[test]
932    fn lock_serializes_concurrent_acquirers() {
933        // First lock succeeds; while held, a second non-blocking attempt
934        // should time out quickly.
935        let (_td, cache) = fixture();
936        let lock_path = cache.lock_path();
937        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
938
939        let res = acquire_lock(&lock_path, Duration::from_millis(100));
940        assert!(matches!(res, Err(AppError::Other(_))));
941    }
942
943    /// The regression this guards: `acquire_lock` parks the thread in a sleep
944    /// loop, so on the TUI's current-thread runtime a contended lock froze
945    /// keyboard input, the refresh timer and every other vendor's fetch until
946    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
947    /// so unrelated timers must keep firing while the lock is held elsewhere.
948    #[tokio::test(flavor = "current_thread")]
949    async fn async_lock_does_not_stall_the_runtime() {
950        let (_td, cache) = fixture();
951        let lock_path = cache.lock_path();
952        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
953
954        // This will wait the full timeout — it can never win the lock.
955        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
956
957        // Meanwhile the runtime must still be able to make progress.
958        let mut ticks = 0usize;
959        let ticker = async {
960            let mut iv = tokio::time::interval(Duration::from_millis(20));
961            iv.tick().await;
962            loop {
963                iv.tick().await;
964                ticks += 1;
965            }
966        };
967
968        tokio::select! {
969            res = waiter => {
970                // The lock attempt is expected to time out.
971                assert!(matches!(res, Err(AppError::Other(_))));
972            }
973            _ = ticker => unreachable!("the ticker loops forever"),
974        }
975        assert!(
976            ticks > 1,
977            "runtime was starved while the lock was contended ({ticks} ticks)"
978        );
979    }
980
981    #[test]
982    fn atomic_write_creates_parent_dirs() {
983        let td = TempDir::new().unwrap();
984        let nested = td.path().join("a/b/c/file.txt");
985        atomic_write(&nested, b"abc").unwrap();
986        assert_eq!(fs::read(&nested).unwrap(), b"abc");
987    }
988}