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 chrono::{DateTime, Utc};
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: DateTime<Utc>,
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: Utc::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: Utc::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 <= Utc::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
274// --- LockStore ----------------------------------------------------------
275
276/// Reader/writer for `usage.lock`.
277pub struct LockStore {
278    root: PathBuf,
279}
280
281impl LockStore {
282    #[must_use]
283    pub fn new(root: PathBuf) -> Self {
284        Self { root }
285    }
286
287    #[must_use]
288    pub fn path(&self) -> PathBuf {
289        self.root.join(LOCK_FILE)
290    }
291
292    /// Return the current lock, the legacy-mtime fallback, or
293    /// `Ok(None)` for absence. `blocked_until` is capped at
294    /// `now + MAX_LOCK_DURATION_SECS` so a pathological on-disk
295    /// value can't park the orchestrator indefinitely. Non-UTF-8 or
296    /// non-JSON contents route through the legacy mtime fallback
297    /// per `docs/specs/data-fetching.md` §Lock file shape.
298    /// Unexpected I/O errors surface as `Err`.
299    pub fn read(&self) -> Result<Option<Lock>, CacheError> {
300        let path = self.path();
301        let bytes = match fs::read(&path) {
302            Ok(bytes) => bytes,
303            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
304            Err(cause) => return Err(CacheError::Io { path, cause }),
305        };
306        // Only try JSON when the bytes are valid UTF-8; anything
307        // else falls through to the legacy-mtime path.
308        if let Ok(text) = std::str::from_utf8(&bytes) {
309            if let Ok(mut lock) = serde_json::from_str::<Lock>(text) {
310                cap_blocked_until(&mut lock.blocked_until);
311                return Ok(Some(lock));
312            }
313        }
314        // Legacy non-JSON (or non-UTF-8) lock: derive `blocked_until`
315        // from mtime per `docs/specs/data-fetching.md` §Lock file
316        // shape.
317        let meta = fs::metadata(&path).map_err(|cause| CacheError::Io {
318            path: path.clone(),
319            cause,
320        })?;
321        let mtime = meta.modified().map_err(|cause| CacheError::Io {
322            path: path.clone(),
323            cause,
324        })?;
325        let mtime_unix: i64 = match mtime.duration_since(std::time::UNIX_EPOCH) {
326            Ok(d) => d.as_secs() as i64,
327            Err(_) => {
328                // mtime before UNIX_EPOCH — extreme clock
329                // misconfiguration or restored-from-backup weirdness.
330                // Fall back to 0 so the legacy lock is effectively
331                // expired; in debug builds the assertion loud-fails.
332                debug_assert!(false, "lock file mtime before UNIX_EPOCH");
333                0
334            }
335        };
336        let mut blocked_until = mtime_unix + LEGACY_LOCK_TTL_SECS;
337        cap_blocked_until(&mut blocked_until);
338        Ok(Some(Lock {
339            blocked_until,
340            error: None,
341        }))
342    }
343
344    pub fn write(&self, lock: &Lock) -> Result<(), CacheError> {
345        atomic_write_json(&self.path(), lock)
346    }
347}
348
349fn cap_blocked_until(blocked_until: &mut i64) {
350    let max = Utc::now().timestamp() + MAX_LOCK_DURATION_SECS;
351    if *blocked_until > max {
352        *blocked_until = max;
353    }
354}
355
356// --- Atomic write helper ------------------------------------------------
357
358/// Write a JSON-serializable value to `path` atomically: serialize
359/// into a sibling tempfile, then `persist` (rename on Unix,
360/// `MoveFileEx` on Windows). The parent directory is created on
361/// demand; a concurrent writer will always see either the old file
362/// or the new one, never a torn write.
363pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), CacheError> {
364    let parent = path.parent().ok_or_else(|| CacheError::Io {
365        path: path.to_path_buf(),
366        cause: io::Error::new(io::ErrorKind::InvalidInput, "path has no parent"),
367    })?;
368    fs::create_dir_all(parent).map_err(|cause| CacheError::Io {
369        path: parent.to_path_buf(),
370        cause,
371    })?;
372    let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|cause| CacheError::Io {
373        path: parent.to_path_buf(),
374        cause,
375    })?;
376    serde_json::to_writer_pretty(&tmp, value).map_err(|e| CacheError::Io {
377        path: path.to_path_buf(),
378        cause: io::Error::other(e),
379    })?;
380    tmp.as_file().sync_all().map_err(|cause| CacheError::Io {
381        path: path.to_path_buf(),
382        cause,
383    })?;
384    tmp.persist(path).map_err(|e| CacheError::Persist {
385        path: path.to_path_buf(),
386        cause: e.error,
387    })?;
388    Ok(())
389}
390
391// --- Tests --------------------------------------------------------------
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use chrono::Duration;
397    use tempfile::TempDir;
398
399    fn sample_response() -> UsageApiResponse {
400        let json = r#"{
401            "five_hour": { "utilization": 22.0, "resets_at": "2026-04-19T05:00:00Z" },
402            "seven_day": { "utilization": 33.0, "resets_at": "2026-04-23T19:00:00Z" }
403        }"#;
404        serde_json::from_str(json).expect("parse")
405    }
406
407    // Path-resolution tests live with the XDG cascade in
408    // `data_context/xdg.rs`; `default_root` is a thin wrapper that
409    // reads process env into `XdgEnv` and delegates.
410
411    // --- CacheStore round-trip -----------------------------------------
412
413    #[test]
414    fn cache_round_trip_preserves_data_entry() {
415        let tmp = TempDir::new().unwrap();
416        let store = CacheStore::new(tmp.path().to_path_buf());
417        let entry = CachedUsage::with_data(sample_response());
418        store.write(&entry).expect("write");
419        let read_back = store.read().expect("read").expect("some");
420        assert_eq!(read_back, entry);
421    }
422
423    #[test]
424    fn cache_round_trip_preserves_error_entry() {
425        let tmp = TempDir::new().unwrap();
426        let store = CacheStore::new(tmp.path().to_path_buf());
427        let entry = CachedUsage::with_error("Timeout");
428        store.write(&entry).expect("write");
429        let read_back = store.read().expect("read").expect("some");
430        assert_eq!(read_back.error.unwrap().code, "Timeout");
431        assert!(read_back.data.is_none());
432    }
433
434    #[test]
435    fn cache_read_returns_none_when_missing() {
436        let tmp = TempDir::new().unwrap();
437        let store = CacheStore::new(tmp.path().to_path_buf());
438        assert!(store.read().expect("read").is_none());
439    }
440
441    #[test]
442    fn cache_read_returns_none_for_schema_mismatch() {
443        let tmp = TempDir::new().unwrap();
444        let path = tmp.path().join(USAGE_FILE);
445        fs::create_dir_all(tmp.path()).unwrap();
446        fs::write(
447            &path,
448            r#"{ "schema_version": 9999, "cached_at": "2026-04-20T12:00:00Z", "data": null, "error": null }"#,
449        )
450        .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_read_returns_none_for_clock_skew() {
457        // cached_at is 10 minutes in the future → treated as a miss.
458        let tmp = TempDir::new().unwrap();
459        let store = CacheStore::new(tmp.path().to_path_buf());
460        let mut entry = CachedUsage::with_data(sample_response());
461        entry.cached_at = Utc::now() + Duration::minutes(10);
462        store.write(&entry).expect("write");
463        assert!(store.read().expect("read").is_none());
464    }
465
466    #[test]
467    fn cache_read_returns_none_for_corrupt_json() {
468        let tmp = TempDir::new().unwrap();
469        fs::write(tmp.path().join(USAGE_FILE), "{ not valid json ").unwrap();
470        let store = CacheStore::new(tmp.path().to_path_buf());
471        assert!(store.read().expect("read").is_none());
472    }
473
474    #[test]
475    fn cache_read_returns_none_for_zero_byte_file() {
476        let tmp = TempDir::new().unwrap();
477        fs::write(tmp.path().join(USAGE_FILE), "").unwrap();
478        let store = CacheStore::new(tmp.path().to_path_buf());
479        assert!(store.read().expect("read").is_none());
480    }
481
482    #[test]
483    fn cache_read_returns_none_for_non_utf8_bytes() {
484        // `fs::read_to_string` would raise `InvalidData` on these
485        // bytes, turning a corrupt file into a hard error that
486        // blocks the fallback cascade. `read` must collapse this to
487        // a miss so the next successful fetch can overwrite.
488        let tmp = TempDir::new().unwrap();
489        fs::write(tmp.path().join(USAGE_FILE), [0xFF, 0xFE, 0xFD]).unwrap();
490        let store = CacheStore::new(tmp.path().to_path_buf());
491        assert!(store.read().expect("read").is_none());
492    }
493
494    #[test]
495    fn cache_write_creates_missing_parent_directory() {
496        // Root points at a not-yet-existing subdir; write should
497        // create it rather than erroring.
498        let tmp = TempDir::new().unwrap();
499        let nested = tmp.path().join("nested").join("linesmith");
500        let store = CacheStore::new(nested.clone());
501        store
502            .write(&CachedUsage::with_data(sample_response()))
503            .expect("write");
504        assert!(nested.join(USAGE_FILE).exists());
505    }
506
507    #[test]
508    fn cache_round_trip_preserves_unknown_buckets() {
509        // Forward-compat: codenamed buckets the endpoint emits (but
510        // we don't recognize by name) must round-trip through the
511        // cache.
512        let tmp = TempDir::new().unwrap();
513        let store = CacheStore::new(tmp.path().to_path_buf());
514        let json = r#"{
515            "five_hour": { "utilization": 10.0, "resets_at": "2026-04-19T05:00:00Z" },
516            "quokka_experimental": { "utilization": 99.0, "resets_at": null }
517        }"#;
518        let response: UsageApiResponse = serde_json::from_str(json).unwrap();
519        store
520            .write(&CachedUsage::with_data(response))
521            .expect("write");
522        let read_back = store.read().expect("read").expect("some");
523        let data = read_back.data.unwrap();
524        assert!(data.unknown_buckets.contains_key("quokka_experimental"));
525    }
526
527    // --- Concurrent write smoke ---------------------------------------
528
529    #[test]
530    fn concurrent_writes_produce_intact_file() {
531        use std::sync::Arc;
532        use std::thread;
533
534        let tmp = TempDir::new().unwrap();
535        let store = Arc::new(CacheStore::new(tmp.path().to_path_buf()));
536
537        let store_a = Arc::clone(&store);
538        let handle_a = thread::spawn(move || {
539            let mut succeeded = 0;
540            for _ in 0..10 {
541                if store_a.write(&CachedUsage::with_error("Timeout")).is_ok() {
542                    succeeded += 1;
543                }
544            }
545            succeeded
546        });
547        let store_b = Arc::clone(&store);
548        let handle_b = thread::spawn(move || {
549            let mut succeeded = 0;
550            for _ in 0..10 {
551                if store_b
552                    .write(&CachedUsage::with_data(sample_response()))
553                    .is_ok()
554                {
555                    succeeded += 1;
556                }
557            }
558            succeeded
559        });
560        let succeeded = handle_a.join().unwrap() + handle_b.join().unwrap();
561
562        // Documented contract is final-state integrity, not per-call
563        // success. POSIX rename(2) never fails on concurrent renames,
564        // so on Unix every write must succeed — a regression that
565        // introduced spurious failures should fail loud here. Windows
566        // MoveFileEx returns ERROR_ACCESS_DENIED to the racing loser
567        // (surfaces as PermissionDenied), so on Windows we only require
568        // at least one writer to win (otherwise the final-state
569        // assertion below is meaningless).
570        #[cfg(unix)]
571        assert_eq!(succeeded, 20, "POSIX rename(2) should never fail");
572        #[cfg(not(unix))]
573        assert!(succeeded > 0, "at least one concurrent write must win");
574
575        // Final state is one of the two writers — never an interleaved
576        // torn write. Parse must succeed.
577        let read_back = store.read().expect("read").expect("some");
578        assert_eq!(read_back.schema_version, CACHE_SCHEMA_VERSION);
579        assert!(read_back.data.is_some() ^ read_back.error.is_some());
580    }
581
582    // --- LockStore ----------------------------------------------------
583
584    #[test]
585    fn lock_round_trip() {
586        let tmp = TempDir::new().unwrap();
587        let store = LockStore::new(tmp.path().to_path_buf());
588        // A recent timestamp that falls well within the cap window.
589        let now = Utc::now().timestamp();
590        let lock = Lock {
591            blocked_until: now + 60,
592            error: Some("rate-limited".into()),
593        };
594        store.write(&lock).expect("write");
595        let read_back = store.read().expect("read").expect("some");
596        assert_eq!(read_back, lock);
597    }
598
599    #[test]
600    fn lock_read_returns_none_when_missing() {
601        let tmp = TempDir::new().unwrap();
602        let store = LockStore::new(tmp.path().to_path_buf());
603        assert!(store.read().expect("read").is_none());
604    }
605
606    #[test]
607    fn lock_read_non_utf8_routes_through_legacy_fallback() {
608        // Partially-written binary or otherwise-corrupt lock must
609        // fall through to the mtime+30s path per
610        // `docs/specs/data-fetching.md` §Lock file shape, not
611        // hard-error the cache layer.
612        let tmp = TempDir::new().unwrap();
613        let path = tmp.path().join(LOCK_FILE);
614        fs::write(&path, [0xFF, 0xFE, 0x00, 0xFD]).unwrap();
615        let mtime = fs::metadata(&path)
616            .unwrap()
617            .modified()
618            .unwrap()
619            .duration_since(std::time::UNIX_EPOCH)
620            .unwrap()
621            .as_secs() as i64;
622        let store = LockStore::new(tmp.path().to_path_buf());
623        let lock = store.read().expect("read").expect("some");
624        assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
625        assert!(lock.error.is_none());
626    }
627
628    #[test]
629    fn lock_read_legacy_non_json_uses_mtime_plus_30s() {
630        let tmp = TempDir::new().unwrap();
631        let path = tmp.path().join(LOCK_FILE);
632        fs::write(&path, "# legacy lock from older linesmith").unwrap();
633        let mtime = fs::metadata(&path)
634            .unwrap()
635            .modified()
636            .unwrap()
637            .duration_since(std::time::UNIX_EPOCH)
638            .unwrap()
639            .as_secs() as i64;
640        let store = LockStore::new(tmp.path().to_path_buf());
641        let lock = store.read().expect("read").expect("some");
642        assert_eq!(lock.blocked_until, mtime + LEGACY_LOCK_TTL_SECS);
643        assert!(lock.error.is_none());
644    }
645
646    #[test]
647    fn lock_read_caps_pathological_blocked_until() {
648        // A writer persisting a wildly-future `blocked_until` must
649        // not let the orchestrator park forever. Same risk class as
650        // `Retry-After: u64::MAX` fixed in fetcher.rs.
651        let tmp = TempDir::new().unwrap();
652        let store = LockStore::new(tmp.path().to_path_buf());
653        let malicious = Lock {
654            blocked_until: i64::MAX,
655            error: None,
656        };
657        store.write(&malicious).expect("write");
658        let read_back = store.read().expect("read").expect("some");
659        let ceiling = Utc::now().timestamp() + MAX_LOCK_DURATION_SECS;
660        // Cap may drift by a second during the test; allow a small
661        // window but reject the raw i64::MAX that was persisted.
662        assert!(
663            read_back.blocked_until <= ceiling + 1 && read_back.blocked_until >= ceiling - 1,
664            "blocked_until = {}, expected near {}",
665            read_back.blocked_until,
666            ceiling
667        );
668    }
669
670    #[test]
671    fn lock_error_omitted_from_serialized_form_when_none() {
672        // `Option<String>` with `skip_serializing_if` keeps the JSON
673        // clean on legacy-fallback writes that have no error text.
674        let tmp = TempDir::new().unwrap();
675        let store = LockStore::new(tmp.path().to_path_buf());
676        store
677            .write(&Lock {
678                blocked_until: Utc::now().timestamp() + 60,
679                error: None,
680            })
681            .expect("write");
682        let raw = fs::read_to_string(store.path()).unwrap();
683        assert!(!raw.contains("\"error\""), "unexpected error key: {raw}");
684    }
685
686    // --- atomic_write_json failure paths ------------------------------
687    //
688    // Serialization failure isn't covered by a dedicated test: our
689    // cache types (chrono, Option<String>, HashMap<String, Value>)
690    // are JSON-safe end to end, and `serde_json` turns pathological
691    // floats into `null` rather than erroring. The branch remains
692    // for defensive correctness if a future type introduces a failing
693    // Serialize impl.
694
695    #[test]
696    fn atomic_write_json_rejects_path_without_parent() {
697        // The root path `/` has no parent — `parent()` returns None.
698        let err = atomic_write_json(
699            Path::new("/"),
700            &Lock {
701                blocked_until: 0,
702                error: None,
703            },
704        )
705        .unwrap_err();
706        match err {
707            CacheError::Io { cause, .. } => {
708                assert_eq!(cause.kind(), io::ErrorKind::InvalidInput);
709            }
710            other => panic!("expected Io(InvalidInput), got {other:?}"),
711        }
712    }
713
714    // --- CacheStore I/O error branch ----------------------------------
715
716    #[cfg(unix)]
717    #[test]
718    fn cache_read_surfaces_permission_denied() {
719        use std::os::unix::fs::PermissionsExt;
720        let tmp = TempDir::new().unwrap();
721        let path = tmp.path().join(USAGE_FILE);
722        fs::write(&path, "{}").unwrap();
723        fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
724        let err = CacheStore::new(tmp.path().to_path_buf())
725            .read()
726            .unwrap_err();
727        assert!(matches!(err, CacheError::Io { .. }));
728        // Restore perms so TempDir cleanup doesn't fail.
729        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
730    }
731
732    // --- CachedUsage tolerant-reader invariant ------------------------
733
734    #[test]
735    fn cache_read_tolerates_entry_with_both_data_and_error() {
736        // The doc says constructors keep one null, but readers are
737        // tolerant — pin that contract so a future "helpful" fix
738        // doesn't regress silently.
739        let tmp = TempDir::new().unwrap();
740        let path = tmp.path().join(USAGE_FILE);
741        fs::write(
742            &path,
743            r#"{
744                "schema_version": 1,
745                "cached_at": "2026-04-20T12:00:00Z",
746                "data": {
747                    "five_hour": { "utilization": 0.0, "resets_at": null }
748                },
749                "error": { "code": "Timeout" }
750            }"#,
751        )
752        .unwrap();
753        let store = CacheStore::new(tmp.path().to_path_buf());
754        let entry = store.read().expect("read").expect("some");
755        assert!(entry.data.is_some());
756        assert!(entry.error.is_some());
757    }
758}