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/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
440/// it. [`atomic_write`] replaces its destination via rename, which on Windows
441/// fails while the destination is held open (as a live `NamedTempFile` handle
442/// would be) — so tests that exercise a write-back must target a closed file.
443/// Returns the dir (the caller keeps it alive) and the file's path; the file
444/// exists only when `contents` is given.
445#[cfg(test)]
446pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
447    let dir = tempfile::TempDir::new().unwrap();
448    let path = dir.path().join(name);
449    if let Some(c) = contents {
450        std::fs::write(&path, c).unwrap();
451    }
452    (dir, path)
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use tempfile::TempDir;
459
460    fn fixture() -> (TempDir, Cache) {
461        let td = TempDir::new().unwrap();
462        let cache = Cache::at(td.path().join("anthropic"));
463        cache.ensure_dir().unwrap();
464        (td, cache)
465    }
466
467    #[test]
468    fn ensure_dir_is_idempotent() {
469        let (_td, cache) = fixture();
470        cache.ensure_dir().unwrap();
471        cache.ensure_dir().unwrap();
472        assert!(cache.dir().is_dir());
473    }
474
475    #[test]
476    fn write_then_read_round_trip() {
477        let (_td, cache) = fixture();
478        cache.write_payload(b"hello world").unwrap();
479        let got = cache.maybe_payload().unwrap();
480        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
481    }
482
483    #[test]
484    fn forget_drops_the_payload_and_its_sidecars() {
485        let (_td, cache) = fixture();
486        cache.write_payload(b"previous login").unwrap();
487        cache.mark_stale();
488        cache.write_last_error(401, "expired");
489        cache.note_rate_limit_at(SystemTime::now());
490        cache.forget();
491        assert!(cache.maybe_payload().unwrap().is_none());
492        assert!(!cache.is_stale());
493        assert!(cache.read_last_error().is_none());
494        assert!(cache.backoff_remaining().is_none());
495        assert!(cache.dir().is_dir());
496    }
497
498    #[test]
499    fn maybe_payload_returns_none_when_missing() {
500        let (_td, cache) = fixture();
501        assert!(cache.maybe_payload().unwrap().is_none());
502    }
503
504    #[test]
505    fn fresh_payload_respects_ttl() {
506        let (_td, cache) = fixture();
507        cache.write_payload(b"x").unwrap();
508        // Fresh = within a generous TTL.
509        assert!(
510            cache
511                .fresh_payload(Duration::from_secs(10))
512                .unwrap()
513                .is_some()
514        );
515        // Force "stale" by passing a zero TTL — payload is older than 0s.
516        assert!(
517            cache
518                .fresh_payload(Duration::from_secs(0))
519                .unwrap()
520                .is_none()
521        );
522    }
523
524    #[test]
525    fn write_clears_stale_marker_and_last_error() {
526        let (_td, cache) = fixture();
527        cache.mark_stale();
528        cache.write_last_error(429, "rate limited");
529        assert!(cache.is_stale());
530        assert!(cache.read_last_error().is_some());
531
532        cache.write_payload(b"fresh").unwrap();
533        assert!(!cache.is_stale());
534        assert!(cache.read_last_error().is_none());
535    }
536
537    // ---- rate-limit backoff ------------------------------------------------
538
539    /// A fixed instant well past the epoch so the arithmetic never underflows.
540    fn t0() -> SystemTime {
541        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
542    }
543
544    fn arm_backoff_at(cache: &Cache, now: SystemTime) {
545        cache.note_rate_limit_at(now);
546        assert!(cache.retry_after_path().exists());
547    }
548
549    #[test]
550    fn a_429_arms_the_backoff_and_other_statuses_do_not() {
551        let (_td, cache) = fixture();
552        cache.write_last_error(500, "upstream down");
553        assert!(cache.backoff_remaining().is_none());
554        assert!(!cache.retry_after_path().exists());
555
556        cache.write_last_error(429, "slow down");
557        let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
558        // Written against the real clock a moment ago: within a few seconds of
559        // the full window, never above it.
560        assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
561        assert!(
562            remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
563            "{remaining:?}"
564        );
565        // The persisted `.last_error` is untouched by the backoff bookkeeping.
566        assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
567    }
568
569    #[test]
570    fn backoff_remaining_counts_down_from_the_injected_clock() {
571        let (_td, cache) = fixture();
572        arm_backoff_at(&cache, t0());
573
574        assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
575        assert_eq!(
576            cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
577            Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
578        );
579        // Exactly at expiry and beyond: no backoff.
580        assert!(
581            cache
582                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
583                .is_none()
584        );
585        assert!(
586            cache
587                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
588                .is_none()
589        );
590    }
591
592    #[test]
593    fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
594        let (_td, cache) = fixture();
595        arm_backoff_at(&cache, t0());
596
597        let err = cache
598            .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
599            .expect_err("no payload during backoff must be an error, not a fetch");
600        match err {
601            AppError::Http { status, body } => {
602                assert_eq!(status, 429);
603                assert!(body.contains("next attempt in"), "{body}");
604                // 5m − 19s = 4m41s, rounded up to the next minute.
605                assert!(body.ends_with("5m"), "{body}");
606            }
607            other => panic!("expected Http 429, got {other:?}"),
608        }
609    }
610
611    /// The whole point of the backoff: a vendor that still has a figure keeps
612    /// showing it instead of re-hitting the limit every poll. The payload is
613    /// written now with a zero TTL, so the ordinary fast path would reject it
614    /// as expired; only the backoff branch can be the one returning it.
615    #[test]
616    fn during_backoff_an_expired_but_not_stale_payload_is_served() {
617        let (_td, cache) = fixture();
618        cache.write_payload(b"last good").unwrap();
619        arm_backoff_at(&cache, t0());
620
621        // Control: without a backoff, TTL 0 means "not fresh".
622        assert!(
623            cache
624                .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
625                .unwrap()
626                .is_none()
627        );
628        // Under backoff the same payload is the answer.
629        assert_eq!(
630            cache
631                .fresh_payload_at(Duration::ZERO, t0())
632                .unwrap()
633                .as_deref(),
634            Some(&b"last good"[..])
635        );
636    }
637
638    #[test]
639    fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
640        let (_td, cache) = fixture();
641        cache.write_payload(b"x").unwrap();
642        arm_backoff_at(&cache, t0());
643        let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
644
645        assert!(
646            cache
647                .fresh_payload_at(Duration::from_secs(10), later)
648                .unwrap()
649                .is_some()
650        );
651        assert!(
652            cache
653                .fresh_payload_at(Duration::ZERO, later)
654                .unwrap()
655                .is_none()
656        );
657        // And with no payload at all, expiry means a plain "go fetch".
658        fs::remove_file(cache.payload_path()).unwrap();
659        assert!(
660            cache
661                .fresh_payload_at(DEFAULT_TTL, later)
662                .unwrap()
663                .is_none()
664        );
665    }
666
667    #[test]
668    fn a_successful_payload_write_clears_the_backoff() {
669        let (_td, cache) = fixture();
670        arm_backoff_at(&cache, t0());
671        assert!(cache.backoff_remaining_at(t0()).is_some());
672
673        cache.write_payload(b"fresh").unwrap();
674        assert!(cache.backoff_remaining_at(t0()).is_none());
675        assert!(!cache.retry_after_path().exists());
676    }
677
678    #[test]
679    fn clear_last_error_also_clears_the_backoff() {
680        let (_td, cache) = fixture();
681        cache.write_last_error(429, "slow down");
682        assert!(cache.backoff_remaining().is_some());
683
684        cache.clear_last_error();
685        assert!(cache.backoff_remaining().is_none());
686        assert!(!cache.retry_after_path().exists());
687    }
688
689    #[test]
690    fn a_corrupt_retry_after_marker_is_no_backoff() {
691        let (_td, cache) = fixture();
692        for raw in ["", "soon", "-5", "1e9", "12 34"] {
693            fs::write(cache.retry_after_path(), raw).unwrap();
694            assert!(
695                cache.backoff_remaining_at(t0()).is_none(),
696                "{raw:?} must not pin the vendor offline"
697            );
698            assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
699        }
700        // Surrounding whitespace is tolerated, though — a trailing newline is
701        // the sort of thing a hand edit leaves behind.
702        let until = t0() + Duration::from_secs(90);
703        let secs = until
704            .duration_since(SystemTime::UNIX_EPOCH)
705            .unwrap()
706            .as_secs();
707        fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
708        assert_eq!(
709            cache.backoff_remaining_at(t0()),
710            Some(Duration::from_secs(90))
711        );
712    }
713
714    #[test]
715    fn human_backoff_formats_seconds_minutes_and_hours() {
716        let s = Duration::from_secs;
717        assert_eq!(human_backoff(s(0)), "0s");
718        assert_eq!(human_backoff(s(45)), "45s");
719        assert_eq!(human_backoff(s(59)), "59s");
720        assert_eq!(human_backoff(s(60)), "1m");
721        assert_eq!(human_backoff(s(4 * 60)), "4m");
722        assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
723        assert_eq!(human_backoff(s(5 * 60)), "5m");
724        assert_eq!(human_backoff(s(60 * 60)), "1h");
725        assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
726        assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
727        assert_eq!(human_backoff(s(2 * 3600)), "2h");
728    }
729
730    #[test]
731    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
732        let (_td, cache) = fixture();
733        cache.write_payload(b"old").unwrap();
734
735        // Let the payload acquire real age rather than rewriting its mtime:
736        // Windows denies reopening the just-persisted file for an attribute
737        // write, and the boundary being tested is the same either way. The
738        // margin is ~12x the threshold so filesystem timestamp granularity
739        // cannot make this flaky.
740        std::thread::sleep(Duration::from_millis(60));
741
742        // Still readable when age is not considered — `maybe_payload` is the
743        // unbounded reader, which is exactly why failure paths must not use it.
744        assert!(cache.maybe_payload().unwrap().is_some());
745
746        // Past the limit, the failure path gets nothing and the caller has to
747        // surface the real error. `MAX_STALE` was dead code before this:
748        // every fallback served history forever.
749        assert!(
750            cache
751                .fallback_payload(Duration::from_millis(5))
752                .unwrap()
753                .is_none()
754        );
755
756        // Inside the window it is still served, so the guard is a limit and
757        // not a blanket refusal.
758        assert_eq!(
759            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
760            Some(&b"old"[..])
761        );
762    }
763
764    #[test]
765    fn last_error_round_trip() {
766        let (_td, cache) = fixture();
767        cache.write_last_error(503, "service unavailable");
768        let (code, msg) = cache.read_last_error().unwrap();
769        assert_eq!(code, 503);
770        assert_eq!(msg, "service unavailable");
771    }
772
773    #[test]
774    fn last_error_with_empty_message_round_trips() {
775        let (_td, cache) = fixture();
776        cache.write_last_error(429, "");
777        let (code, msg) = cache.read_last_error().unwrap();
778        assert_eq!(code, 429);
779        assert_eq!(msg, "");
780    }
781
782    #[test]
783    fn last_error_replaces_401_body_with_credential_neutral_message() {
784        let (_td, cache) = fixture();
785        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
786
787        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
788        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
789        assert!(!persisted.contains("PANCEA"));
790        assert!(!persisted.contains("<credential>"));
791    }
792
793    #[test]
794    fn last_error_replaces_403_body_with_credential_neutral_message() {
795        let (_td, cache) = fixture();
796        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
797
798        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
799        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
800        assert!(!persisted.contains("PANCEA"));
801        assert!(!persisted.contains("<credential>"));
802    }
803
804    /// The invariant that keeps the displayed message from drifting away from
805    /// the persisted one: what comes back is what a later run would read from
806    /// disk, so a caller that shows the return value cannot show anything the
807    /// cache refused to keep. Asserted for the redacting arm and the ordinary
808    /// one, since only the first rewrites the message.
809    #[test]
810    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
811        for (code, raw) in [
812            (401u16, "PANCEA user@example.test <credential>&token"),
813            (403, "PANCEA account@example.test <credential>&token"),
814            (429, "rate limited, retry in 60s"),
815            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
816        ] {
817            let (_td, cache) = fixture();
818            let returned = cache.write_last_error(code, raw);
819            assert_eq!(
820                returned,
821                cache.read_last_error().unwrap(),
822                "returned pair diverged from the persisted one for {code}"
823            );
824        }
825    }
826
827    /// The defect recurred once under a second name — six vendors wrote
828    /// `Some((status, body))` inline and six more built a `diag` local first —
829    /// so the sweep that fixed the first six missed the rest. This forbids the
830    /// shape rather than the spelling: a `last_error` pair must come from
831    /// [`Cache::write_last_error`], which is the only thing that redacts.
832    ///
833    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
834    /// redacts on its own and destructures as `(*status, body)`, which is not
835    /// the borrowed shape a leak takes.
836    #[test]
837    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
838        let mut sites = Vec::new();
839        for file in crate::guard::rs_files_in("src") {
840            if !file.ends_with("fetch.rs") {
841                continue;
842            }
843            let source = std::fs::read_to_string(&file).expect("readable module");
844            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
845                if line.contains("(status, body") {
846                    sites.push(format!("{}:{}", file.display(), n + 1));
847                }
848            }
849        }
850        assert!(
851            sites.is_empty(),
852            "a last_error pair must be the return of `write_last_error`, which \
853             redacts 401/403 — building one from the raw body puts the response \
854             body in the widget tooltip. Found: {sites:#?}"
855        );
856    }
857
858    /// The cold-cache decision — serve a stale figure, or surface the error
859    /// that caused the refresh to fail — is `outcome::fallback`'s alone. It
860    /// drifted into two disagreeing generations once, when each vendor owned a
861    /// copy: five replaced the original error with a generic "no usable cache"
862    /// while thirteen returned it. `fallback_payload` is the entry point to
863    /// that decision, so a second caller is a second copy in the making.
864    #[test]
865    fn only_the_shared_fallback_reads_the_stale_payload() {
866        let mut sites = Vec::new();
867        for file in crate::guard::rs_files_in("src") {
868            if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
869                continue;
870            }
871            let source = std::fs::read_to_string(&file).expect("readable module");
872            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
873                if line.contains("fallback_payload(") {
874                    sites.push(format!("{}:{}", file.display(), n + 1));
875                }
876            }
877        }
878        assert!(
879            sites.is_empty(),
880            "reach the stale payload through `outcome::fallback`, which decides \
881             what a cold cache means for every vendor at once. Found: {sites:#?}"
882        );
883    }
884
885    /// The bug this closes: the pair handed to the widget was built from the
886    /// raw body in parallel with the redacted one going to disk, so the run
887    /// that hit the `401` showed the body and only the *next* run showed the
888    /// neutral message. The returned pair carries the redaction.
889    #[test]
890    fn the_returned_pair_carries_the_auth_redaction() {
891        for code in [401u16, 403] {
892            let (_td, cache) = fixture();
893            let (returned_code, msg) =
894                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
895            assert_eq!(returned_code, code);
896            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
897            assert!(!msg.contains("PANCEA"), "{msg}");
898            assert!(!msg.contains("<credential>"), "{msg}");
899        }
900    }
901
902    /// The regression this guards: vendors write the raw HTTP body, which is
903    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
904    /// showed `{` and dropped the actual API explanation.
905    #[test]
906    fn last_error_round_trips_a_multi_line_message() {
907        let (_td, cache) = fixture();
908        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
909        cache.write_last_error(429, body);
910
911        let (code, msg) = cache.read_last_error().unwrap();
912        assert_eq!(code, 429);
913        assert_eq!(msg, body);
914        assert!(
915            msg.contains("quota exhausted"),
916            "message was truncated to its first line: {msg:?}"
917        );
918    }
919
920    #[test]
921    fn last_error_strips_terminal_controls_before_persisting() {
922        let (_td, cache) = fixture();
923        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
924
925        let (code, msg) = cache.read_last_error().unwrap();
926        assert_eq!(code, 500);
927        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
928        assert!(
929            msg.contains("Y2FuYXJ5"),
930            "non-auth diagnostic was not preserved"
931        );
932        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
933    }
934
935    /// A user upgrades with a `.last_error` already on disk; it must still
936    /// parse. Trailing-newline-free files (the whole marker being just a code)
937    /// count too — that is the one shape the old `lines()` reader tolerated.
938    #[test]
939    fn last_error_reads_files_written_by_the_previous_version() {
940        let (_td, cache) = fixture();
941
942        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
943        assert_eq!(
944            cache.read_last_error(),
945            Some((503, "service unavailable".into()))
946        );
947
948        fs::write(cache.last_error_path(), "429").unwrap();
949        assert_eq!(cache.read_last_error(), Some((429, String::new())));
950
951        // A non-numeric first line is still no error at all, never a fake 0.
952        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
953        assert!(cache.read_last_error().is_none());
954    }
955
956    #[test]
957    fn lock_serializes_concurrent_acquirers() {
958        // First lock succeeds; while held, a second non-blocking attempt
959        // should time out quickly.
960        let (_td, cache) = fixture();
961        let lock_path = cache.lock_path();
962        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
963
964        let res = acquire_lock(&lock_path, Duration::from_millis(100));
965        assert!(matches!(res, Err(AppError::Other(_))));
966    }
967
968    /// The regression this guards: `acquire_lock` parks the thread in a sleep
969    /// loop, so on the TUI's current-thread runtime a contended lock froze
970    /// keyboard input, the refresh timer and every other vendor's fetch until
971    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
972    /// so unrelated timers must keep firing while the lock is held elsewhere.
973    #[tokio::test(flavor = "current_thread")]
974    async fn async_lock_does_not_stall_the_runtime() {
975        let (_td, cache) = fixture();
976        let lock_path = cache.lock_path();
977        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
978
979        // This will wait the full timeout — it can never win the lock.
980        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
981
982        // Meanwhile the runtime must still be able to make progress.
983        let mut ticks = 0usize;
984        let ticker = async {
985            let mut iv = tokio::time::interval(Duration::from_millis(20));
986            iv.tick().await;
987            loop {
988                iv.tick().await;
989                ticks += 1;
990            }
991        };
992
993        tokio::select! {
994            res = waiter => {
995                // The lock attempt is expected to time out.
996                assert!(matches!(res, Err(AppError::Other(_))));
997            }
998            _ = ticker => unreachable!("the ticker loops forever"),
999        }
1000        assert!(
1001            ticks > 1,
1002            "runtime was starved while the lock was contended ({ticks} ticks)"
1003        );
1004    }
1005
1006    #[test]
1007    fn atomic_write_creates_parent_dirs() {
1008        let td = TempDir::new().unwrap();
1009        let nested = td.path().join("a/b/c/file.txt");
1010        atomic_write(&nested, b"abc").unwrap();
1011        assert_eq!(fs::read(&nested).unwrap(), b"abc");
1012    }
1013}