Skip to main content

ai_usagebar/
cache.rs

1//! Per-vendor on-disk cache with atomic writes, TTL checks, and inter-process
2//! locking.
3//!
4//! Mirrors claudebar's cache layout but per-vendor:
5//!   `~/.cache/ai-usagebar/<vendor>/usage.json`         payload
6//!   `~/.cache/ai-usagebar/<vendor>/.stale`             marker (cache is stale)
7//!   `~/.cache/ai-usagebar/<vendor>/.last_error`        HTTP code\nmessage
8//!   `~/.cache/ai-usagebar/<vendor>/.fetch.lock`        flock target
9//!
10//! Multi-monitor safety: callers should `acquire_lock()` before the refresh+
11//! fetch window, mirroring claudebar:402-407's `exec 9>"$_lockfile" / flock`.
12
13use std::fs::{self, File, OpenOptions};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::time::{Duration, SystemTime};
17
18use fs2::FileExt;
19
20use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
21
22/// Default TTL — claudebar's `CACHE_TTL=60`.
23pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
24
25/// Maximum staleness before we refuse to serve cached data even on failure.
26/// Mirrors claudebar's `WEEKLY_WINDOW` (7 days).
27pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
28
29/// Per-vendor cache directory and helper API.
30///
31/// Construct with [`Cache::for_vendor`]; the directory is created lazily.
32#[derive(Debug, Clone)]
33pub struct Cache {
34    dir: PathBuf,
35}
36
37impl Cache {
38    /// Build a cache rooted at `~/.cache/ai-usagebar/<vendor>` (or under
39    /// `$XDG_CACHE_HOME` when set).
40    pub fn for_vendor(vendor: &str) -> Result<Self> {
41        let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
42        Ok(Self { dir: base })
43    }
44
45    /// Cache for a specific named account of a vendor, rooted at
46    /// `~/.cache/ai-usagebar/<vendor>/<label>`. Only *extra* accounts use
47    /// this; the default account keeps [`Cache::for_vendor`] so its path never
48    /// moves (issue #14, back-compat rule 2).
49    pub fn for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
50        let base = xdg_cache_dir()?
51            .join("ai-usagebar")
52            .join(vendor)
53            .join(label);
54        Ok(Self { dir: base })
55    }
56
57    /// Cache rooted at an arbitrary directory — for tests.
58    pub fn at(path: PathBuf) -> Self {
59        Self { dir: path }
60    }
61
62    /// Ensure the directory exists. Safe to call repeatedly.
63    pub fn ensure_dir(&self) -> Result<()> {
64        fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
65    }
66
67    pub fn dir(&self) -> &Path {
68        &self.dir
69    }
70
71    pub fn payload_path(&self) -> PathBuf {
72        self.dir.join("usage.json")
73    }
74    pub fn stale_path(&self) -> PathBuf {
75        self.dir.join(".stale")
76    }
77    pub fn last_error_path(&self) -> PathBuf {
78        self.dir.join(".last_error")
79    }
80    pub fn lock_path(&self) -> PathBuf {
81        self.dir.join(".fetch.lock")
82    }
83
84    /// Age of the payload (`None` if it doesn't exist). Used by the widget to
85    /// decide whether the 60s cache window applies.
86    pub fn payload_age(&self) -> Option<Duration> {
87        let meta = fs::metadata(self.payload_path()).ok()?;
88        let mtime = meta.modified().ok()?;
89        SystemTime::now().duration_since(mtime).ok()
90    }
91
92    /// Returns the cached payload only if it is younger than `ttl`. Used as
93    /// the fast path in `_fetch_usage` (claudebar:343-349).
94    pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
95        let Some(age) = self.payload_age() else {
96            return Ok(None);
97        };
98        if age < ttl {
99            self.read_payload().map(Some)
100        } else {
101            Ok(None)
102        }
103    }
104
105    /// Read the payload regardless of age. `Err` if the file exists but is
106    /// unreadable; `Ok(None)` if it just doesn't exist.
107    ///
108    /// Prefer [`Cache::fallback_payload`] on failure paths — this one imposes
109    /// no age limit, so it will happily hand back a month-old figure.
110    pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
111        if !self.payload_path().exists() {
112            return Ok(None);
113        }
114        self.read_payload().map(Some)
115    }
116
117    /// Payload for the *failure* path: the last good value, but only while it
118    /// is still worth showing. Beyond `max_stale` this returns `Ok(None)` so
119    /// the caller surfaces the real error instead of presenting week-old
120    /// numbers as if they were current — a bar that silently freezes on
121    /// history is worse than one that says it cannot reach the API.
122    pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
123        let Some(age) = self.payload_age() else {
124            return Ok(None);
125        };
126        if age > max_stale {
127            return Ok(None);
128        }
129        self.read_payload().map(Some)
130    }
131
132    fn read_payload(&self) -> Result<Vec<u8>> {
133        let p = self.payload_path();
134        let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
135        let mut buf = Vec::new();
136        f.read_to_end(&mut buf)
137            .map_err(|e| AppError::io_at(&p, e))?;
138        Ok(buf)
139    }
140
141    /// Atomically write a new payload. Uses `tempfile + persist` (POSIX
142    /// rename), matching claudebar's `mktemp + mv` invariant.
143    pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
144        self.ensure_dir()?;
145        let mut tmp = tempfile::Builder::new()
146            .prefix(".usage.")
147            .tempfile_in(&self.dir)
148            .map_err(|e| AppError::io_at(&self.dir, e))?;
149        tmp.write_all(bytes)
150            .map_err(|e| AppError::io_at(tmp.path(), e))?;
151        tmp.as_file_mut()
152            .sync_all()
153            .map_err(|e| AppError::io_at(tmp.path(), e))?;
154        tmp.persist(self.payload_path())
155            .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
156        // A successful write clears any stale marker.
157        let _ = fs::remove_file(self.stale_path());
158        let _ = fs::remove_file(self.last_error_path());
159        Ok(())
160    }
161
162    /// Mark the cache as stale. Idempotent.
163    pub fn mark_stale(&self) {
164        let _ = self.ensure_dir();
165        let _ = File::create(self.stale_path());
166    }
167
168    pub fn is_stale(&self) -> bool {
169        self.stale_path().exists()
170    }
171
172    /// Write the `.last_error` marker — first line `code`, everything after it
173    /// `msg`. Best-effort, never errors (matches claudebar:478-486 which
174    /// silently continues if the cache dir isn't writable).
175    ///
176    /// **Returns exactly what was written**, so a caller that also puts the
177    /// failure in its [`crate::vendor::VendorOutcome`] can hand over this pair
178    /// instead of deriving a second one from the raw body. The two must not be
179    /// computed separately: persisting a redacted message while the in-memory
180    /// copy kept the original is how a `401` body reached the widget tooltip on
181    /// the one run that had a warm cache to fall back on. Callers that only
182    /// persist can keep ignoring the return.
183    pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
184        let _ = self.ensure_dir();
185        let path = self.last_error_path();
186        // Authentication failure bodies routinely include account identifiers or
187        // partial credential details. Do not persist them; other status bodies
188        // remain useful diagnostics after their usual control-char cleanup.
189        let msg = if matches!(code, 401 | 403) {
190            AUTH_FAILURE_MESSAGE
191        } else {
192            msg
193        };
194        let msg = crate::display::sanitize_untrusted_field(msg);
195        let body = format!("{code}\n{msg}");
196        let _ = atomic_write(&path, body.as_bytes());
197        (code, msg)
198    }
199
200    /// Best-effort removal of the `.last_error` marker.
201    pub fn clear_last_error(&self) {
202        let _ = fs::remove_file(self.last_error_path());
203    }
204
205    pub fn read_last_error(&self) -> Option<(u16, String)> {
206        let raw = fs::read_to_string(self.last_error_path()).ok()?;
207        // The message is *everything* past the first newline, not just the next
208        // line: vendors store the raw HTTP body here and those are routinely
209        // multi-line JSON, so taking one line truncated the user's diagnostic.
210        // Files from before this fix parse unchanged — the writer always framed
211        // them this way, only the reader threw the tail away.
212        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
213        Some((code.parse::<u16>().ok()?, msg.to_string()))
214    }
215}
216
217/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
218/// Returned guard releases the lock on drop.
219///
220/// The flock file is created if missing, but its content is unused — only
221/// the lock matters.
222/// Async wrapper around [`acquire_lock`].
223///
224/// The blocking version parks the calling thread in a sleep loop for up to
225/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
226/// stalls *everything*: keyboard input, the refresh timer, and every other
227/// vendor's in-flight request. Running the wait on the blocking pool keeps the
228/// reactor free while a contended lock is waited on.
229pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
230    let path = path.to_path_buf();
231    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
232        .await
233        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
234}
235
236pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
237    if let Some(parent) = path.parent() {
238        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
239    }
240    let f = OpenOptions::new()
241        .create(true)
242        .read(true)
243        .write(true)
244        .truncate(false)
245        .open(path)
246        .map_err(|e| AppError::io_at(path, e))?;
247
248    let deadline = std::time::Instant::now() + timeout;
249    loop {
250        match f.try_lock_exclusive() {
251            Ok(()) => return Ok(LockGuard { file: f }),
252            Err(_) => {
253                if std::time::Instant::now() >= deadline {
254                    return Err(AppError::Other(format!(
255                        "cache lock timeout after {:?}",
256                        timeout
257                    )));
258                }
259                std::thread::sleep(Duration::from_millis(50));
260            }
261        }
262    }
263}
264
265/// Releases the flock on drop. Holding this across an `.await` is fine as
266/// long as you don't move it across tasks (we always use it in `tokio::main`
267/// on a single thread).
268pub struct LockGuard {
269    file: File,
270}
271
272impl Drop for LockGuard {
273    fn drop(&mut self) {
274        let _ = FileExt::unlock(&self.file);
275    }
276}
277
278/// Atomic write helper used by `write_last_error`. Public for vendors that
279/// need to write small sidecar files (credentials, etc.).
280pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
281    let dir = path.parent().ok_or_else(|| {
282        AppError::Other(format!(
283            "atomic_write: path has no parent: {}",
284            path.display()
285        ))
286    })?;
287    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
288    let mut tmp = tempfile::Builder::new()
289        .prefix(".tmp.")
290        .tempfile_in(dir)
291        .map_err(|e| AppError::io_at(dir, e))?;
292    tmp.write_all(bytes)
293        .map_err(|e| AppError::io_at(tmp.path(), e))?;
294    tmp.as_file_mut()
295        .sync_all()
296        .map_err(|e| AppError::io_at(tmp.path(), e))?;
297    tmp.persist(path)
298        .map_err(|e| AppError::io_at(path, e.error))?;
299    Ok(())
300}
301
302fn xdg_cache_dir() -> Result<PathBuf> {
303    directories::BaseDirs::new()
304        .map(|b| b.cache_dir().to_path_buf())
305        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
306}
307
308/// The user's home directory, resolved cross-platform via `directories`
309/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
310///
311/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
312/// files from fixed dotfiles under `$HOME`; they share this resolver the same
313/// way they already share [`atomic_write`], so home resolution lives in one
314/// place rather than being reimplemented per vendor.
315pub fn home_dir() -> Result<PathBuf> {
316    directories::BaseDirs::new()
317        .map(|b| b.home_dir().to_path_buf())
318        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
319}
320
321/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
322/// it. [`atomic_write`] replaces its destination via rename, which on Windows
323/// fails while the destination is held open (as a live `NamedTempFile` handle
324/// would be) — so tests that exercise a write-back must target a closed file.
325/// Returns the dir (the caller keeps it alive) and the file's path; the file
326/// exists only when `contents` is given.
327#[cfg(test)]
328pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
329    let dir = tempfile::TempDir::new().unwrap();
330    let path = dir.path().join(name);
331    if let Some(c) = contents {
332        std::fs::write(&path, c).unwrap();
333    }
334    (dir, path)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use tempfile::TempDir;
341
342    fn fixture() -> (TempDir, Cache) {
343        let td = TempDir::new().unwrap();
344        let cache = Cache::at(td.path().join("anthropic"));
345        cache.ensure_dir().unwrap();
346        (td, cache)
347    }
348
349    #[test]
350    fn ensure_dir_is_idempotent() {
351        let (_td, cache) = fixture();
352        cache.ensure_dir().unwrap();
353        cache.ensure_dir().unwrap();
354        assert!(cache.dir().is_dir());
355    }
356
357    #[test]
358    fn write_then_read_round_trip() {
359        let (_td, cache) = fixture();
360        cache.write_payload(b"hello world").unwrap();
361        let got = cache.maybe_payload().unwrap();
362        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
363    }
364
365    #[test]
366    fn maybe_payload_returns_none_when_missing() {
367        let (_td, cache) = fixture();
368        assert!(cache.maybe_payload().unwrap().is_none());
369    }
370
371    #[test]
372    fn fresh_payload_respects_ttl() {
373        let (_td, cache) = fixture();
374        cache.write_payload(b"x").unwrap();
375        // Fresh = within a generous TTL.
376        assert!(
377            cache
378                .fresh_payload(Duration::from_secs(10))
379                .unwrap()
380                .is_some()
381        );
382        // Force "stale" by passing a zero TTL — payload is older than 0s.
383        assert!(
384            cache
385                .fresh_payload(Duration::from_secs(0))
386                .unwrap()
387                .is_none()
388        );
389    }
390
391    #[test]
392    fn write_clears_stale_marker_and_last_error() {
393        let (_td, cache) = fixture();
394        cache.mark_stale();
395        cache.write_last_error(429, "rate limited");
396        assert!(cache.is_stale());
397        assert!(cache.read_last_error().is_some());
398
399        cache.write_payload(b"fresh").unwrap();
400        assert!(!cache.is_stale());
401        assert!(cache.read_last_error().is_none());
402    }
403
404    #[test]
405    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
406        let (_td, cache) = fixture();
407        cache.write_payload(b"old").unwrap();
408
409        // Let the payload acquire real age rather than rewriting its mtime:
410        // Windows denies reopening the just-persisted file for an attribute
411        // write, and the boundary being tested is the same either way. The
412        // margin is ~12x the threshold so filesystem timestamp granularity
413        // cannot make this flaky.
414        std::thread::sleep(Duration::from_millis(60));
415
416        // Still readable when age is not considered — `maybe_payload` is the
417        // unbounded reader, which is exactly why failure paths must not use it.
418        assert!(cache.maybe_payload().unwrap().is_some());
419
420        // Past the limit, the failure path gets nothing and the caller has to
421        // surface the real error. `MAX_STALE` was dead code before this:
422        // every fallback served history forever.
423        assert!(
424            cache
425                .fallback_payload(Duration::from_millis(5))
426                .unwrap()
427                .is_none()
428        );
429
430        // Inside the window it is still served, so the guard is a limit and
431        // not a blanket refusal.
432        assert_eq!(
433            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
434            Some(&b"old"[..])
435        );
436    }
437
438    #[test]
439    fn last_error_round_trip() {
440        let (_td, cache) = fixture();
441        cache.write_last_error(503, "service unavailable");
442        let (code, msg) = cache.read_last_error().unwrap();
443        assert_eq!(code, 503);
444        assert_eq!(msg, "service unavailable");
445    }
446
447    #[test]
448    fn last_error_with_empty_message_round_trips() {
449        let (_td, cache) = fixture();
450        cache.write_last_error(429, "");
451        let (code, msg) = cache.read_last_error().unwrap();
452        assert_eq!(code, 429);
453        assert_eq!(msg, "");
454    }
455
456    #[test]
457    fn last_error_replaces_401_body_with_credential_neutral_message() {
458        let (_td, cache) = fixture();
459        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
460
461        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
462        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
463        assert!(!persisted.contains("PANCEA"));
464        assert!(!persisted.contains("<credential>"));
465    }
466
467    #[test]
468    fn last_error_replaces_403_body_with_credential_neutral_message() {
469        let (_td, cache) = fixture();
470        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
471
472        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
473        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
474        assert!(!persisted.contains("PANCEA"));
475        assert!(!persisted.contains("<credential>"));
476    }
477
478    /// The invariant that keeps the displayed message from drifting away from
479    /// the persisted one: what comes back is what a later run would read from
480    /// disk, so a caller that shows the return value cannot show anything the
481    /// cache refused to keep. Asserted for the redacting arm and the ordinary
482    /// one, since only the first rewrites the message.
483    #[test]
484    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
485        for (code, raw) in [
486            (401u16, "PANCEA user@example.test <credential>&token"),
487            (403, "PANCEA account@example.test <credential>&token"),
488            (429, "rate limited, retry in 60s"),
489            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
490        ] {
491            let (_td, cache) = fixture();
492            let returned = cache.write_last_error(code, raw);
493            assert_eq!(
494                returned,
495                cache.read_last_error().unwrap(),
496                "returned pair diverged from the persisted one for {code}"
497            );
498        }
499    }
500
501    /// The defect recurred once under a second name — six vendors wrote
502    /// `Some((status, body))` inline and six more built a `diag` local first —
503    /// so the sweep that fixed the first six missed the rest. This forbids the
504    /// shape rather than the spelling: a `last_error` pair must come from
505    /// [`Cache::write_last_error`], which is the only thing that redacts.
506    ///
507    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
508    /// redacts on its own and destructures as `(*status, body)`, which is not
509    /// the borrowed shape a leak takes.
510    #[test]
511    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
512        let mut sites = Vec::new();
513        for file in crate::guard::rs_files_in("src") {
514            if !file.ends_with("fetch.rs") {
515                continue;
516            }
517            let source = std::fs::read_to_string(&file).expect("readable module");
518            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
519                if line.contains("(status, body") {
520                    sites.push(format!("{}:{}", file.display(), n + 1));
521                }
522            }
523        }
524        assert!(
525            sites.is_empty(),
526            "a last_error pair must be the return of `write_last_error`, which \
527             redacts 401/403 — building one from the raw body puts the response \
528             body in the widget tooltip. Found: {sites:#?}"
529        );
530    }
531
532    /// A vendor whose cache is cold has no figure to show, so the error is the
533    /// entire output. Substituting a generic "no usable cache" there throws
534    /// away the only diagnostic the user gets: on a first run, an expired key,
535    /// a 500, and a genuinely empty cache all render identically. Thirteen
536    /// vendors returned the original error and five synthesized one; this
537    /// keeps them from diverging again.
538    #[test]
539    fn no_vendor_replaces_the_original_error_with_a_no_cache_message() {
540        let mut sites = Vec::new();
541        for file in crate::guard::rs_files_in("src") {
542            if !file.ends_with("fetch.rs") {
543                continue;
544            }
545            let source = std::fs::read_to_string(&file).expect("readable module");
546            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
547                if line.contains("no usable cache") || line.contains("no cache and network") {
548                    sites.push(format!("{}:{}", file.display(), n + 1));
549                }
550            }
551        }
552        assert!(
553            sites.is_empty(),
554            "a cold cache must return the error that caused the fetch to fail,              not a generic message about the cache. Thread the original              `AppError` into the fallback instead. Found: {sites:#?}"
555        );
556    }
557
558    /// The bug this closes: the pair handed to the widget was built from the
559    /// raw body in parallel with the redacted one going to disk, so the run
560    /// that hit the `401` showed the body and only the *next* run showed the
561    /// neutral message. The returned pair carries the redaction.
562    #[test]
563    fn the_returned_pair_carries_the_auth_redaction() {
564        for code in [401u16, 403] {
565            let (_td, cache) = fixture();
566            let (returned_code, msg) =
567                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
568            assert_eq!(returned_code, code);
569            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
570            assert!(!msg.contains("PANCEA"), "{msg}");
571            assert!(!msg.contains("<credential>"), "{msg}");
572        }
573    }
574
575    /// The regression this guards: vendors write the raw HTTP body, which is
576    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
577    /// showed `{` and dropped the actual API explanation.
578    #[test]
579    fn last_error_round_trips_a_multi_line_message() {
580        let (_td, cache) = fixture();
581        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
582        cache.write_last_error(429, body);
583
584        let (code, msg) = cache.read_last_error().unwrap();
585        assert_eq!(code, 429);
586        assert_eq!(msg, body);
587        assert!(
588            msg.contains("quota exhausted"),
589            "message was truncated to its first line: {msg:?}"
590        );
591    }
592
593    #[test]
594    fn last_error_strips_terminal_controls_before_persisting() {
595        let (_td, cache) = fixture();
596        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
597
598        let (code, msg) = cache.read_last_error().unwrap();
599        assert_eq!(code, 500);
600        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
601        assert!(
602            msg.contains("Y2FuYXJ5"),
603            "non-auth diagnostic was not preserved"
604        );
605        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
606    }
607
608    /// A user upgrades with a `.last_error` already on disk; it must still
609    /// parse. Trailing-newline-free files (the whole marker being just a code)
610    /// count too — that is the one shape the old `lines()` reader tolerated.
611    #[test]
612    fn last_error_reads_files_written_by_the_previous_version() {
613        let (_td, cache) = fixture();
614
615        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
616        assert_eq!(
617            cache.read_last_error(),
618            Some((503, "service unavailable".into()))
619        );
620
621        fs::write(cache.last_error_path(), "429").unwrap();
622        assert_eq!(cache.read_last_error(), Some((429, String::new())));
623
624        // A non-numeric first line is still no error at all, never a fake 0.
625        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
626        assert!(cache.read_last_error().is_none());
627    }
628
629    #[test]
630    fn lock_serializes_concurrent_acquirers() {
631        // First lock succeeds; while held, a second non-blocking attempt
632        // should time out quickly.
633        let (_td, cache) = fixture();
634        let lock_path = cache.lock_path();
635        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
636
637        let res = acquire_lock(&lock_path, Duration::from_millis(100));
638        assert!(matches!(res, Err(AppError::Other(_))));
639    }
640
641    /// The regression this guards: `acquire_lock` parks the thread in a sleep
642    /// loop, so on the TUI's current-thread runtime a contended lock froze
643    /// keyboard input, the refresh timer and every other vendor's fetch until
644    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
645    /// so unrelated timers must keep firing while the lock is held elsewhere.
646    #[tokio::test(flavor = "current_thread")]
647    async fn async_lock_does_not_stall_the_runtime() {
648        let (_td, cache) = fixture();
649        let lock_path = cache.lock_path();
650        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
651
652        // This will wait the full timeout — it can never win the lock.
653        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
654
655        // Meanwhile the runtime must still be able to make progress.
656        let mut ticks = 0usize;
657        let ticker = async {
658            let mut iv = tokio::time::interval(Duration::from_millis(20));
659            iv.tick().await;
660            loop {
661                iv.tick().await;
662                ticks += 1;
663            }
664        };
665
666        tokio::select! {
667            res = waiter => {
668                // The lock attempt is expected to time out.
669                assert!(matches!(res, Err(AppError::Other(_))));
670            }
671            _ = ticker => unreachable!("the ticker loops forever"),
672        }
673        assert!(
674            ticks > 1,
675            "runtime was starved while the lock was contended ({ticks} ticks)"
676        );
677    }
678
679    #[test]
680    fn atomic_write_creates_parent_dirs() {
681        let td = TempDir::new().unwrap();
682        let nested = td.path().join("a/b/c/file.txt");
683        atomic_write(&nested, b"abc").unwrap();
684        assert_eq!(fs::read(&nested).unwrap(), b"abc");
685    }
686}