Skip to main content

linesmith_core/data_context/
cache.rs

1//! Disk-backed OAuth usage cache + lock file.
2//!
3//! Two storage layers per `docs/specs/data-fetching.md` §OAuth usage
4//! cache stack:
5//!
6//! - [`CacheStore`] — `usage.json` holds either the last endpoint
7//!   response ([`CachedData`]) or a tag-only error record
8//!   ([`CachedError`]), stamped with `schema_version` + `cached_at`.
9//!   The orchestrator compares `cached_at` against
10//!   `usage.cache_duration` to decide freshness.
11//! - [`LockStore`] — `usage.lock` holds a `blocked_until` Unix
12//!   timestamp that prevents concurrent linesmith processes from
13//!   stampeding the endpoint under 429 backoff.
14//!
15//! Both writes go through [`atomic_write_json`]: write to a sibling
16//! tempfile, then rename over the target. `tempfile::NamedTempFile::
17//! persist` is rename-on-Unix and `MoveFileEx` with
18//! `MOVEFILE_REPLACE_EXISTING` on Windows — atomic at the filesystem
19//! level on both platforms.
20
21use std::collections::HashMap;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26use jiff::Timestamp;
27use serde::{Deserialize, Serialize};
28
29use super::usage::{ExtraUsage, UsageApiResponse, UsageBucket};
30
31/// Cache schema version. Bump when the on-disk shape changes in a
32/// way that can't be read by an older linesmith — readers with a
33/// mismatched version treat the file as a miss per
34/// `docs/specs/data-fetching.md` §Schema versioning.
35pub const CACHE_SCHEMA_VERSION: u32 = 1;
36
37const USAGE_FILE: &str = "usage.json";
38const LOCK_FILE: &str = "usage.lock";
39
40/// Implicit lock TTL used when a legacy non-JSON `.lock` file is
41/// encountered (`mtime + 30s`), per `docs/specs/data-fetching.md`
42/// §Lock file shape.
43const LEGACY_LOCK_TTL_SECS: i64 = 30;
44
45/// Ceiling on `Lock.blocked_until - now`. A buggy or adversarial
46/// writer persisting a wildly-future timestamp would otherwise park
47/// the orchestrator in "rate-limited" state until the distant date.
48/// Matches the `MAX_RETRY_AFTER` cap in `fetcher.rs` — 1h above any
49/// realistic 429 backoff.
50const MAX_LOCK_DURATION_SECS: i64 = 24 * 60 * 60;
51
52// --- Errors -------------------------------------------------------------
53
54/// Cache-layer I/O / parse failures. Distinct from [`UsageError`]
55/// because cache-miss variants collapse to `Ok(None)` in [`CacheStore::read`];
56/// `CacheError` carries only the cases that indicate a real problem
57/// (filesystem error on write, inability to create the parent dir).
58#[derive(Debug)]
59#[non_exhaustive]
60pub enum CacheError {
61    /// Directory creation or file read/write failed.
62    Io { path: PathBuf, cause: io::Error },
63    /// Tempfile rename during atomic write failed.
64    Persist { path: PathBuf, cause: io::Error },
65}
66
67impl std::fmt::Display for CacheError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Io { path, cause } => {
71                write!(f, "cache I/O error on {}: {}", path.display(), cause.kind())
72            }
73            Self::Persist { path, cause } => write!(
74                f,
75                "atomic persist failed for {}: {}",
76                path.display(),
77                cause.kind()
78            ),
79        }
80    }
81}
82
83impl std::error::Error for CacheError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            Self::Io { cause, .. } | Self::Persist { cause, .. } => Some(cause),
87        }
88    }
89}
90
91// --- On-disk types ------------------------------------------------------
92
93/// Single cache-file entry. Writers produced via [`Self::with_data`]
94/// or [`Self::with_error`] keep exactly one of `data`/`error` null
95/// per `docs/specs/data-fetching.md` §OAuth usage cache stack.
96/// Readers tolerate any combination (both null, both populated) and
97/// treat anomalies as misses at the orchestrator layer rather than
98/// erroring at parse time.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct CachedUsage {
101    pub schema_version: u32,
102    pub cached_at: Timestamp,
103    #[serde(default)]
104    pub data: Option<CachedData>,
105    #[serde(default)]
106    pub error: Option<CachedError>,
107}
108
109impl CachedUsage {
110    #[must_use]
111    pub fn with_data(data: UsageApiResponse) -> Self {
112        Self {
113            schema_version: CACHE_SCHEMA_VERSION,
114            cached_at: Timestamp::now(),
115            data: Some(CachedData::from(data)),
116            error: None,
117        }
118    }
119
120    #[must_use]
121    pub fn with_error(code: &str) -> Self {
122        Self {
123            schema_version: CACHE_SCHEMA_VERSION,
124            cached_at: Timestamp::now(),
125            data: None,
126            error: Some(CachedError {
127                code: code.to_string(),
128            }),
129        }
130    }
131}
132
133/// Disk-serializable mirror of [`UsageApiResponse`]. The wire shape
134/// uses `#[serde(flatten)]` for `unknown_buckets` so codenamed keys
135/// appear at the top level of the endpoint response. The cache nests
136/// them under a named `unknown_buckets` key so the outer
137/// [`CachedUsage`] wrapper's fields (`schema_version`, `cached_at`,
138/// etc.) don't collide with endpoint-emitted keys like `five_hour`.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140#[non_exhaustive]
141pub struct CachedData {
142    #[serde(default)]
143    pub five_hour: Option<UsageBucket>,
144    #[serde(default)]
145    pub seven_day: Option<UsageBucket>,
146    #[serde(default)]
147    pub seven_day_opus: Option<UsageBucket>,
148    #[serde(default)]
149    pub seven_day_sonnet: Option<UsageBucket>,
150    #[serde(default)]
151    pub seven_day_oauth_apps: Option<UsageBucket>,
152    #[serde(default)]
153    pub extra_usage: Option<ExtraUsage>,
154    #[serde(default)]
155    pub unknown_buckets: HashMap<String, serde_json::Value>,
156}
157
158impl From<UsageApiResponse> for CachedData {
159    fn from(r: UsageApiResponse) -> Self {
160        Self {
161            five_hour: r.five_hour,
162            seven_day: r.seven_day,
163            seven_day_opus: r.seven_day_opus,
164            seven_day_sonnet: r.seven_day_sonnet,
165            seven_day_oauth_apps: r.seven_day_oauth_apps,
166            extra_usage: r.extra_usage,
167            unknown_buckets: r.unknown_buckets,
168        }
169    }
170}
171
172impl From<CachedData> for UsageApiResponse {
173    fn from(c: CachedData) -> Self {
174        UsageApiResponse {
175            five_hour: c.five_hour,
176            seven_day: c.seven_day,
177            seven_day_opus: c.seven_day_opus,
178            seven_day_sonnet: c.seven_day_sonnet,
179            seven_day_oauth_apps: c.seven_day_oauth_apps,
180            extra_usage: c.extra_usage,
181            unknown_buckets: c.unknown_buckets,
182        }
183    }
184}
185
186/// On-disk error record. Intentionally lossy — carries only the
187/// [`UsageError::code`](super::UsageError::code) tag, not the full
188/// Rust enum. Live errors from the current process take precedence
189/// over cached ones; the cache is just for cross-invocation hints.
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct CachedError {
192    pub code: String,
193}
194
195/// On-disk lock file shape. `blocked_until` is a signed Unix
196/// timestamp in seconds — `i64` wide enough for any plausible Unix
197/// time, signed so the `mtime + LEGACY_LOCK_TTL_SECS` arithmetic
198/// used by the legacy path can't underflow.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct Lock {
201    pub blocked_until: i64,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub error: Option<String>,
204}
205
206// --- Path resolution ----------------------------------------------------
207
208/// Locate the linesmith cache root. Returns `None` in environments
209/// that provide neither `$XDG_CACHE_HOME` nor `$HOME`. Delegates to
210/// [`xdg::resolve_subdir`](super::xdg::resolve_subdir); the
211/// `from_process_env` factory uses `var_os` so non-UTF-8 paths
212/// (Unix byte-string paths) survive through to the cache reader.
213#[must_use]
214pub fn default_root() -> Option<PathBuf> {
215    use super::xdg::{resolve_subdir, XdgEnv, XdgScope};
216    resolve_subdir(&XdgEnv::from_process_env(), XdgScope::Cache, "")
217}
218
219// --- CacheStore ---------------------------------------------------------
220
221/// Reader/writer for `usage.json`.
222pub struct CacheStore {
223    root: PathBuf,
224}
225
226impl CacheStore {
227    #[must_use]
228    pub fn new(root: PathBuf) -> Self {
229        Self { root }
230    }
231
232    #[must_use]
233    pub fn path(&self) -> PathBuf {
234        self.root.join(USAGE_FILE)
235    }
236
237    /// Return the cached entry or `Ok(None)` for any condition that
238    /// should degrade to a cache miss: file not present, non-UTF-8
239    /// bytes, malformed JSON, `schema_version` mismatch, or
240    /// `cached_at` in the future (clock skew). Only unexpected I/O
241    /// errors (permission denied, etc.) surface as `Err`.
242    pub fn read(&self) -> Result<Option<CachedUsage>, CacheError> {
243        let path = self.path();
244        let bytes = match fs::read(&path) {
245            Ok(bytes) => bytes,
246            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
247            Err(cause) => return Err(CacheError::Io { path, cause }),
248        };
249        // Non-UTF-8 is one flavor of corruption; collapse to miss so
250        // the next write can overwrite. serde_json wouldn't accept
251        // the bytes anyway.
252        let Ok(text) = std::str::from_utf8(&bytes) else {
253            return Ok(None);
254        };
255        match serde_json::from_str::<CachedUsage>(text) {
256            Ok(entry)
257                if entry.schema_version == CACHE_SCHEMA_VERSION
258                    && entry.cached_at <= Timestamp::now() =>
259            {
260                Ok(Some(entry))
261            }
262            // The next write will overwrite.
263            _ => Ok(None),
264        }
265    }
266
267    /// Persist the entry via the atomic-rename helper. Creates the
268    /// cache root on demand — no init step needed.
269    pub fn write(&self, entry: &CachedUsage) -> Result<(), CacheError> {
270        atomic_write_json(&self.path(), entry)
271    }
272
273    /// Remove the cache file. Idempotent — `Ok(())` on `NotFound` so
274    /// callers don't have to gate on existence first. Intended for
275    /// invalidating cached data that's tied to a no-longer-valid
276    /// token: a still-fresh cache otherwise short-circuits the
277    /// lock-active 401 guard for up to `cache_duration`.
278    pub fn clear(&self) -> Result<(), CacheError> {
279        let path = self.path();
280        match fs::remove_file(&path) {
281            Ok(()) => Ok(()),
282            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
283            Err(cause) => Err(CacheError::Io { path, cause }),
284        }
285    }
286}
287
288// --- LockStore ----------------------------------------------------------
289
290/// Reader/writer for `usage.lock`.
291pub struct LockStore {
292    root: PathBuf,
293}
294
295impl LockStore {
296    #[must_use]
297    pub fn new(root: PathBuf) -> Self {
298        Self { root }
299    }
300
301    #[must_use]
302    pub fn path(&self) -> PathBuf {
303        self.root.join(LOCK_FILE)
304    }
305
306    /// Return the current lock, the legacy-mtime fallback, or
307    /// `Ok(None)` for absence. `blocked_until` is capped at
308    /// `now + MAX_LOCK_DURATION_SECS` so a pathological on-disk
309    /// value can't park the orchestrator indefinitely. Non-UTF-8 or
310    /// non-JSON contents route through the legacy mtime fallback
311    /// per `docs/specs/data-fetching.md` §Lock file shape.
312    /// Unexpected I/O errors surface as `Err`.
313    pub fn read(&self) -> Result<Option<Lock>, CacheError> {
314        let path = self.path();
315        let bytes = match fs::read(&path) {
316            Ok(bytes) => bytes,
317            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
318            Err(cause) => return Err(CacheError::Io { path, cause }),
319        };
320        // Only try JSON when the bytes are valid UTF-8; anything
321        // else falls through to the legacy-mtime path.
322        if let Ok(text) = std::str::from_utf8(&bytes) {
323            if let Ok(mut lock) = serde_json::from_str::<Lock>(text) {
324                cap_blocked_until(&mut lock.blocked_until);
325                return Ok(Some(lock));
326            }
327        }
328        // Legacy non-JSON (or non-UTF-8) lock: derive `blocked_until`
329        // from mtime per `docs/specs/data-fetching.md` §Lock file
330        // shape.
331        let meta = fs::metadata(&path).map_err(|cause| CacheError::Io {
332            path: path.clone(),
333            cause,
334        })?;
335        let mtime = meta.modified().map_err(|cause| CacheError::Io {
336            path: path.clone(),
337            cause,
338        })?;
339        let mtime_unix: i64 = match mtime.duration_since(std::time::UNIX_EPOCH) {
340            Ok(d) => d.as_secs() as i64,
341            Err(_) => {
342                // mtime before UNIX_EPOCH — extreme clock
343                // misconfiguration or restored-from-backup weirdness.
344                // Fall back to 0 so the legacy lock is effectively
345                // expired; in debug builds the assertion loud-fails.
346                debug_assert!(false, "lock file mtime before UNIX_EPOCH");
347                0
348            }
349        };
350        let mut blocked_until = mtime_unix + LEGACY_LOCK_TTL_SECS;
351        cap_blocked_until(&mut blocked_until);
352        Ok(Some(Lock {
353            blocked_until,
354            error: None,
355        }))
356    }
357
358    pub fn write(&self, lock: &Lock) -> Result<(), CacheError> {
359        atomic_write_json(&self.path(), lock)
360    }
361}
362
363fn cap_blocked_until(blocked_until: &mut i64) {
364    let max = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
365    if *blocked_until > max {
366        *blocked_until = max;
367    }
368}
369
370// --- Atomic write helper ------------------------------------------------
371
372/// Write a JSON-serializable value to `path` atomically: serialize
373/// into a sibling tempfile, then `persist` (rename on Unix,
374/// `MoveFileEx` on Windows). The parent directory is created on
375/// demand; a concurrent writer will always see either the old file
376/// or the new one, never a torn write.
377pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CacheError> {
378    let parent = path.parent().ok_or_else(|| CacheError::Io {
379        path: path.to_path_buf(),
380        cause: io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"),
381    })?;
382    fs::create_dir_all(parent).map_err(|cause| CacheError::Io {
383        path: parent.to_path_buf(),
384        cause,
385    })?;
386    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|cause| CacheError::Io {
387        path: parent.to_path_buf(),
388        cause,
389    })?;
390    serde_json::to_writer_pretty(&tmp, value).map_err(|e| CacheError::Io {
391        path: path.to_path_buf(),
392        cause: io::Error::other(e),
393    })?;
394    tmp.as_file().sync_all().map_err(|cause| CacheError::Io {
395        path: path.to_path_buf(),
396        cause,
397    })?;
398    tmp.persist(path).map_err(|e| CacheError::Persist {
399        path: path.to_path_buf(),
400        cause: e.error,
401    })?;
402    Ok(())
403}
404
405// --- Tests --------------------------------------------------------------
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use jiff::SignedDuration;
411    use tempfile::TempDir;
412
413    fn sample_response() -> UsageApiResponse {
414        let json = r#"{
415            "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
416            "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
417        }"#;
418        serde_json::from_str(json).expect("parse")
419    }
420
421    // Path-resolution tests live with the XDG cascade in
422    // `data_context/xdg.rs`; `default_root` is a thin wrapper that
423    // reads process env into `XdgEnv` and delegates.
424
425    // --- CacheStore round-trip -----------------------------------------
426
427    #[test]
428    fn cache_round_trip_preserves_data_entry() {
429        let tmp = TempDir::new().unwrap();
430        let store = CacheStore::new(tmp.path().to_path_buf());
431        let entry = CachedUsage::with_data(sample_response());
432        store.write(&entry).expect("write");
433        let read_back = store.read().expect("read").expect("some");
434        assert_eq!(read_back, entry);
435    }
436
437    #[test]
438    fn cache_round_trip_preserves_error_entry() {
439        let tmp = TempDir::new().unwrap();
440        let store = CacheStore::new(tmp.path().to_path_buf());
441        let entry = CachedUsage::with_error("Timeout");
442        store.write(&entry).expect("write");
443        let read_back = store.read().expect("read").expect("some");
444        assert_eq!(read_back.error.unwrap().code, "Timeout");
445        assert!(read_back.data.is_none());
446    }
447
448    #[test]
449    fn cache_read_returns_none_when_missing() {
450        let tmp = TempDir::new().unwrap();
451        let store = CacheStore::new(tmp.path().to_path_buf());
452        assert!(store.read().expect("read").is_none());
453    }
454
455    #[test]
456    fn cache_clear_is_idempotent_on_missing_file() {
457        // The cascade calls `clear()` unconditionally on the 401 arm
458        // — including the first 401 ever seen, when the cache file
459        // was never written. The `NotFound` arm must return `Ok(())`
460        // so the caller doesn't log a spurious error every time.
461        let tmp = TempDir::new().unwrap();
462        let store = CacheStore::new(tmp.path().to_path_buf());
463        store.clear().expect("clear on missing file is Ok");
464        // Repeat: still idempotent.
465        store.clear().expect("clear after clear is Ok");
466    }
467
468    #[test]
469    fn cache_clear_removes_existing_file() {
470        let tmp = TempDir::new().unwrap();
471        let store = CacheStore::new(tmp.path().to_path_buf());
472        store
473            .write(&CachedUsage::with_data(sample_response()))
474            .expect("write");
475        assert!(store.read().expect("read").is_some(), "fixture wrote");
476        store.clear().expect("clear");
477        assert!(
478            store.read().expect("read").is_none(),
479            "clear must remove the file",
480        );
481    }
482
483    #[test]
484    fn cache_reads_rfc3339_z_suffix_serde_format() {
485        // Pin the on-disk RFC 3339 (`Z` suffix) timestamp shape so a
486        // future datetime-library bump that changes the default serde
487        // format fails loudly here, and existing cache files don't
488        // start silently failing to parse. Prefer pinning the wire
489        // format via a custom `Deserialize` adapter that accepts both
490        // shapes when the change is purely cosmetic; reserve a
491        // `CACHE_SCHEMA_VERSION` bump for genuinely semantic changes
492        // since the bump invalidates every existing cache file on disk.
493        let tmp = TempDir::new().unwrap();
494        let path = tmp.path().join(USAGE_FILE);
495        let payload = r#"{
496            "schema_version": 1,
497            "cached_at": "2026-04-19T12:00:00.000Z",
498            "data": {
499                "five_hour": { "utilization": 42.0, "resets_at": "2026-04-19T17:00:00.000Z" },
500                "seven_day": null,
501                "seven_day_opus": null,
502                "seven_day_sonnet": null,
503                "seven_day_oauth_apps": null,
504                "extra_usage": null,
505                "unknown_buckets": {}
506            },
507            "error": null
508        }"#;
509        fs::create_dir_all(path.parent().unwrap()).unwrap();
510        fs::write(&path, payload).unwrap();
511        let store = CacheStore::new(tmp.path().to_path_buf());
512        let read_back = store.read().expect("read").expect("some");
513        assert_eq!(read_back.cached_at.to_string(), "2026-04-19T12:00:00Z");
514        let bucket = read_back.data.as_ref().unwrap().five_hour.as_ref().unwrap();
515        assert_eq!(bucket.utilization.value(), 42.0);
516        assert_eq!(
517            bucket.resets_at.unwrap().to_string(),
518            "2026-04-19T17:00:00Z",
519        );
520    }
521
522    #[test]
523    fn cache_read_returns_none_for_schema_mismatch() {
524        let tmp = TempDir::new().unwrap();
525        let path = tmp.path().join(USAGE_FILE);
526        fs::create_dir_all(tmp.path()).unwrap();
527        fs::write(
528            &path,
529            r#"{ "schema_version": 9999, "cached_at": "2026-04-20T12:00:00Z", "data": null, "error": null }"#,
530        )
531        .unwrap();
532        let store = CacheStore::new(tmp.path().to_path_buf());
533        assert!(store.read().expect("read").is_none());
534    }
535
536    #[test]
537    fn cache_read_returns_none_for_clock_skew() {
538        // cached_at is 10 minutes in the future → treated as a miss.
539        let tmp = TempDir::new().unwrap();
540        let store = CacheStore::new(tmp.path().to_path_buf());
541        let mut entry = CachedUsage::with_data(sample_response());
542        entry.cached_at = Timestamp::now() + SignedDuration::from_mins(10);
543        store.write(&entry).expect("write");
544        assert!(store.read().expect("read").is_none());
545    }
546
547    #[test]
548    fn cache_read_returns_none_for_corrupt_json() {
549        let tmp = TempDir::new().unwrap();
550        fs::write(tmp.path().join(USAGE_FILE), "{ not valid json ").unwrap();
551        let store = CacheStore::new(tmp.path().to_path_buf());
552        assert!(store.read().expect("read").is_none());
553    }
554
555    #[test]
556    fn cache_read_returns_none_for_zero_byte_file() {
557        let tmp = TempDir::new().unwrap();
558        fs::write(tmp.path().join(USAGE_FILE), "").unwrap();
559        let store = CacheStore::new(tmp.path().to_path_buf());
560        assert!(store.read().expect("read").is_none());
561    }
562
563    #[test]
564    fn cache_read_returns_none_for_non_utf8_bytes() {
565        // `fs::read_to_string` would raise `InvalidData` on these
566        // bytes, turning a corrupt file into a hard error that
567        // blocks the fallback cascade. `read` must collapse this to
568        // a miss so the next successful fetch can overwrite.
569        let tmp = TempDir::new().unwrap();
570        fs::write(tmp.path().join(USAGE_FILE), [0xFF, 0xFE, 0xFD]).unwrap();
571        let store = CacheStore::new(tmp.path().to_path_buf());
572        assert!(store.read().expect("read").is_none());
573    }
574
575    #[test]
576    fn cache_write_creates_missing_parent_directory() {
577        // Root points at a not-yet-existing subdir; write should
578        // create it rather than erroring.
579        let tmp = TempDir::new().unwrap();
580        let nested = tmp.path().join("nested").join("linesmith");
581        let store = CacheStore::new(nested.clone());
582        store
583            .write(&CachedUsage::with_data(sample_response()))
584            .expect("write");
585        assert!(nested.join(USAGE_FILE).exists());
586    }
587
588    #[test]
589    fn cache_round_trip_preserves_unknown_buckets() {
590        // Forward-compat: codenamed buckets the endpoint emits (but
591        // we don't recognize by name) must round-trip through the
592        // cache.
593        let tmp = TempDir::new().unwrap();
594        let store = CacheStore::new(tmp.path().to_path_buf());
595        let json = r#"{
596            "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
597            "quokka_experimental": { "utilization": 99.0, "resets_at": null }
598        }"#;
599        let response: UsageApiResponse = serde_json::from_str(json).unwrap();
600        store
601            .write(&CachedUsage::with_data(response))
602            .expect("write");
603        let read_back = store.read().expect("read").expect("some");
604        let data = read_back.data.unwrap();
605        assert!(data.unknown_buckets.contains_key("quokka_experimental"));
606    }
607
608    // --- Concurrent write smoke ---------------------------------------
609
610    #[test]
611    fn concurrent_writes_produce_intact_file() {
612        use std::sync::Arc;
613        use std::thread;
614
615        let tmp = TempDir::new().unwrap();
616        let store = Arc::new(CacheStore::new(tmp.path().to_path_buf()));
617
618        let store_a = Arc::clone(&store);
619        let handle_a = thread::spawn(move || {
620            let mut succeeded = 0;
621            for _ in 0..10 {
622                if store_a.write(&CachedUsage::with_error("Timeout")).is_ok() {
623                    succeeded += 1;
624                }
625            }
626            succeeded
627        });
628        let store_b = Arc::clone(&store);
629        let handle_b = thread::spawn(move || {
630            let mut succeeded = 0;
631            for _ in 0..10 {
632                if store_b
633                    .write(&CachedUsage::with_data(sample_response()))
634                    .is_ok()
635                {
636                    succeeded += 1;
637                }
638            }
639            succeeded
640        });
641        let succeeded = handle_a.join().unwrap() + handle_b.join().unwrap();
642
643        // Documented contract is final-state integrity, not per-call
644        // success. POSIX rename(2) never fails on concurrent renames,
645        // so on Unix every write must succeed — a regression that
646        // introduced spurious failures should fail loud here. Windows
647        // MoveFileEx returns ERROR_ACCESS_DENIED to the racing loser
648        // (surfaces as PermissionDenied), so on Windows we only require
649        // at least one writer to win (otherwise the final-state
650        // assertion below is meaningless).
651        #[cfg(unix)]
652        assert_eq!(succeeded, 20, "POSIX rename(2) should never fail");
653        #[cfg(not(unix))]
654        assert!(succeeded > 0, "at least one concurrent write must win");
655
656        // Final state is one of the two writers — never an interleaved
657        // torn write. Parse must succeed.
658        let read_back = store.read().expect("read").expect("some");
659        assert_eq!(read_back.schema_version, CACHE_SCHEMA_VERSION);
660        assert!(read_back.data.is_some() ^ read_back.error.is_some());
661    }
662
663    // --- LockStore ----------------------------------------------------
664
665    #[test]
666    fn lock_round_trip() {
667        let tmp = TempDir::new().unwrap();
668        let store = LockStore::new(tmp.path().to_path_buf());
669        // A recent timestamp that falls well within the cap window.
670        let now = Timestamp::now().as_second();
671        let lock = Lock {
672            blocked_until: now + 60,
673            error: Some("rate-limited".into()),
674        };
675        store.write(&lock).expect("write");
676        let read_back = store.read().expect("read").expect("some");
677        assert_eq!(read_back, lock);
678    }
679
680    #[test]
681    fn lock_read_returns_none_when_missing() {
682        let tmp = TempDir::new().unwrap();
683        let store = LockStore::new(tmp.path().to_path_buf());
684        assert!(store.read().expect("read").is_none());
685    }
686
687    #[test]
688    fn lock_read_non_utf8_routes_through_legacy_fallback() {
689        // Partially-written binary or otherwise-corrupt lock must
690        // fall through to the mtime+30s path per
691        // `docs/specs/data-fetching.md` §Lock file shape, not
692        // hard-error the cache layer.
693        let tmp = TempDir::new().unwrap();
694        let path = tmp.path().join(LOCK_FILE);
695        fs::write(&path, [0xFF, 0xFE, 0x00, 0xFD]).unwrap();
696        let mtime = fs::metadata(&path)
697            .unwrap()
698            .modified()
699            .unwrap()
700            .duration_since(std::time::UNIX_EPOCH)
701            .unwrap()
702            .as_secs() as i64;
703        let store = LockStore::new(tmp.path().to_path_buf());
704        let lock = store.read().expect("read").expect("some");
705        assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
706        assert!(lock.error.is_none());
707    }
708
709    #[test]
710    fn lock_read_legacy_non_json_uses_mtime_plus_30s() {
711        let tmp = TempDir::new().unwrap();
712        let path = tmp.path().join(LOCK_FILE);
713        fs::write(&path, "# legacy lock from older linesmith").unwrap();
714        let mtime = fs::metadata(&path)
715            .unwrap()
716            .modified()
717            .unwrap()
718            .duration_since(std::time::UNIX_EPOCH)
719            .unwrap()
720            .as_secs() as i64;
721        let store = LockStore::new(tmp.path().to_path_buf());
722        let lock = store.read().expect("read").expect("some");
723        assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
724        assert!(lock.error.is_none());
725    }
726
727    #[test]
728    fn lock_read_caps_pathological_blocked_until() {
729        // A writer persisting a wildly-future `blocked_until` must
730        // not let the orchestrator park forever. Same risk class as
731        // `Retry-After: u64::MAX` fixed in fetcher.rs.
732        let tmp = TempDir::new().unwrap();
733        let store = LockStore::new(tmp.path().to_path_buf());
734        let malicious = Lock {
735            blocked_until: i64::MAX,
736            error: None,
737        };
738        store.write(&malicious).expect("write");
739        let read_back = store.read().expect("read").expect("some");
740        let ceiling = Timestamp::now().as_second() + MAX_LOCK_DURATION_SECS;
741        // Cap may drift by a second during the test; allow a small
742        // window but reject the raw i64::MAX that was persisted.
743        assert!(
744            read_back.blocked_until <= ceiling + 1 && read_back.blocked_until >= ceiling - 1,
745            "blocked_until = {}, expected near {}",
746            read_back.blocked_until,
747            ceiling
748        );
749    }
750
751    #[test]
752    fn lock_error_omitted_from_serialized_form_when_none() {
753        // `Option<String>` with `skip_serializing_if` keeps the JSON
754        // clean on legacy-fallback writes that have no error text.
755        let tmp = TempDir::new().unwrap();
756        let store = LockStore::new(tmp.path().to_path_buf());
757        store
758            .write(&Lock {
759                blocked_until: Timestamp::now().as_second() + 60,
760                error: None,
761            })
762            .expect("write");
763        let raw = fs::read_to_string(store.path()).unwrap();
764        assert!(!raw.contains("\"error\""), "unexpected error key: {raw}");
765    }
766
767    // --- atomic_write_json failure paths ------------------------------
768    //
769    // Serialization failure isn't covered by a dedicated test: our
770    // cache types (jiff::Timestamp, Option<String>, HashMap<String, Value>)
771    // are JSON-safe end to end, and `serde_json` turns pathological
772    // floats into `null` rather than erroring. The branch remains
773    // for defensive correctness if a future type introduces a failing
774    // Serialize impl.
775
776    #[test]
777    fn atomic_write_json_rejects_path_without_parent() {
778        // The root path `/` has no parent — `parent()` returns None.
779        let err = atomic_write_json(
780            Path::new("/"),
781            &Lock {
782                blocked_until: 0,
783                error: None,
784            },
785        )
786        .unwrap_err();
787        match err {
788            CacheError::Io { cause, .. } => {
789                assert_eq!(cause.kind(), io::ErrorKind::InvalidInput);
790            }
791            other => panic!("expected Io(InvalidInput), got {other:?}"),
792        }
793    }
794
795    // --- CacheStore I/O error branch ----------------------------------
796
797    #[cfg(unix)]
798    #[test]
799    fn cache_read_surfaces_permission_denied() {
800        use std::os::unix::fs::PermissionsExt;
801        let tmp = TempDir::new().unwrap();
802        let path = tmp.path().join(USAGE_FILE);
803        fs::write(&path, "{}").unwrap();
804        fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
805        let err = CacheStore::new(tmp.path().to_path_buf())
806            .read()
807            .unwrap_err();
808        assert!(matches!(err, CacheError::Io { .. }));
809        // Restore perms so TempDir cleanup doesn't fail.
810        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
811    }
812
813    // --- CachedUsage tolerant-reader invariant ------------------------
814
815    #[test]
816    fn cache_read_tolerates_entry_with_both_data_and_error() {
817        // The doc says constructors keep one null, but readers are
818        // tolerant — pin that contract so a future "helpful" fix
819        // doesn't regress silently.
820        let tmp = TempDir::new().unwrap();
821        let path = tmp.path().join(USAGE_FILE);
822        fs::write(
823            &path,
824            r#"{
825                "schema_version": 1,
826                "cached_at": "2026-04-20T12:00:00Z",
827                "data": {
828                    "five_hour": { "utilization": 0.0, "resets_at": null }
829                },
830                "error": { "code": "Timeout" }
831            }"#,
832        )
833        .unwrap();
834        let store = CacheStore::new(tmp.path().to_path_buf());
835        let entry = store.read().expect("read").expect("some");
836        assert!(entry.data.is_some());
837        assert!(entry.error.is_some());
838    }
839}