Skip to main content

ssg/plugins/
llm_cache.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Deterministic content-hash-keyed cache for local LLM inference (issue #528).
5//!
6//! Local-model inference (Ollama, llama.cpp) is CPU/GPU intensive and
7//! non-deterministic (sampling makes outputs vary). For a 10K-page
8//! docs site that means hours of CI per build and bit-different
9//! artifacts across runs. This module fixes both: invocations are
10//! keyed on a SHA-256 of `(endpoint, model, prompt, timeout)`; a hit
11//! returns the cached body and never crosses the wire, so builds are
12//! both fast and reproducible.
13//!
14//! # On-disk layout
15//!
16//! Entries live under `$XDG_CACHE_HOME/ssg/llm/` (Linux),
17//! `~/Library/Caches/ssg/llm/` (macOS), or `%LOCALAPPDATA%\ssg\llm\`
18//! (Windows). Each entry is git-sharded:
19//!
20//! ```text
21//! <cache_dir>/<aa>/<bbbbbbbbbb...>.json
22//! ```
23//!
24//! where `aa` is the first two hex chars of the key and `bbbbb...`
25//! is the remaining 62 chars. This keeps any single shard directory
26//! well under the FAT32/exFAT 65 K-entry ceiling even on a million-
27//! page site.
28//!
29//! # File format
30//!
31//! Each entry is a small JSON document:
32//!
33//! ```json
34//! {
35//!   "version": 1,
36//!   "key_hex": "<full 64-char hex of key>",
37//!   "payload_len": <usize>,
38//!   "payload": "<the cached LLM response>"
39//! }
40//! ```
41//!
42//! `key_hex` and `payload_len` provide a cheap end-to-end integrity
43//! check — a torn mid-write (or a flipped bit on disk) yields a
44//! parse error or a length mismatch, and the entry is evicted and
45//! re-computed without surfacing a hard error to the caller.
46//!
47//! # Concurrency
48//!
49//! Writes go to `<final>.tmp.<pid>.<nanos>` then `rename` into place.
50//! `rename` is atomic on every supported filesystem, so concurrent
51//! writers for *distinct* keys never trample one another, and
52//! concurrent writers for the *same* key produce a last-writer-wins
53//! outcome where every reader still sees a consistent entry.
54//!
55//! # TTL
56//!
57//! [`LlmCache::get`] honours a configurable TTL: entries older than
58//! `ttl` (compared against the file's mtime) are evicted and reported
59//! as a miss. Default is 90 days.
60
61use sha2::{Digest, Sha256};
62use std::{
63    fs,
64    io::{self, Read, Write},
65    path::{Path, PathBuf},
66    sync::atomic::{AtomicU64, Ordering},
67    time::{Duration, SystemTime},
68};
69
70/// Default TTL for cache entries: 90 days. Matches the AC4 default
71/// from issue #528 ("`cache.llm.ttl_days` default 90").
72pub const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 90);
73
74/// Persisted on-disk format version. Bumped only on an incompatible
75/// schema change so old entries get evicted by the version mismatch
76/// branch in [`parse_entry`].
77const ENTRY_VERSION: u32 = 1;
78
79/// Counters returned by [`LlmCache::stats`] for the `ssg cache --stats`
80/// CLI subcommand. Counts are session-local — the cache file itself
81/// stores none of this.
82#[derive(Debug, Clone, Copy, Default)]
83pub struct CacheStats {
84    /// Hits since the cache was constructed.
85    pub hits: u64,
86    /// Misses since the cache was constructed (including TTL and
87    /// corruption-driven evictions).
88    pub misses: u64,
89    /// Stores written since the cache was constructed.
90    pub stores: u64,
91    /// Entries evicted because they failed integrity or TTL checks.
92    pub evictions: u64,
93}
94
95/// Content-hash-keyed file cache for LLM inference.
96///
97/// Cloning is cheap — the counters use shared atomics so a cloned
98/// handle reports the same totals as its parent, which is the
99/// invariant the CLI `--stats` subcommand relies on when multiple
100/// pipeline threads each hold a handle.
101#[derive(Debug)]
102pub struct LlmCache {
103    /// Absolute root directory containing all entries.
104    root: PathBuf,
105    /// TTL after which entries are considered stale.
106    ttl: Duration,
107    /// Session-local hit counter.
108    hits: AtomicU64,
109    /// Session-local miss counter.
110    misses: AtomicU64,
111    /// Session-local store counter.
112    stores: AtomicU64,
113    /// Session-local eviction counter.
114    evictions: AtomicU64,
115}
116
117impl LlmCache {
118    /// Constructs a cache rooted at `root` with the [`DEFAULT_TTL`].
119    ///
120    /// The directory is created lazily on the first write; calling
121    /// this on a path that does not yet exist is fine.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use ssg::llm_cache::LlmCache;
127    /// let tmp = tempfile::tempdir().unwrap();
128    /// let cache = LlmCache::new(tmp.path().to_path_buf());
129    /// assert_eq!(cache.root(), tmp.path());
130    /// ```
131    #[must_use]
132    pub const fn new(root: PathBuf) -> Self {
133        Self::with_ttl(root, DEFAULT_TTL)
134    }
135
136    /// Constructs a cache rooted at `root` with a custom TTL. Used by
137    /// the AC4 expiry tests so they don't have to wait 90 days.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use std::time::Duration;
143    /// use ssg::llm_cache::LlmCache;
144    /// let tmp = tempfile::tempdir().unwrap();
145    /// let cache = LlmCache::with_ttl(tmp.path().to_path_buf(), Duration::from_secs(60));
146    /// assert_eq!(cache.stats().hits, 0);
147    /// ```
148    #[must_use]
149    pub const fn with_ttl(root: PathBuf, ttl: Duration) -> Self {
150        Self {
151            root,
152            ttl,
153            hits: AtomicU64::new(0),
154            misses: AtomicU64::new(0),
155            stores: AtomicU64::new(0),
156            evictions: AtomicU64::new(0),
157        }
158    }
159
160    /// Resolves the platform-default cache root.
161    ///
162    /// Selection order (first that yields a path wins):
163    ///
164    /// 1. `$SSG_LLM_CACHE_DIR` — explicit override, used by tests and
165    ///    by ops who want to point at a shared cache on tmpfs.
166    /// 2. `$XDG_CACHE_HOME/ssg/llm` — Linux / `XDG_CACHE_HOME` set.
167    /// 3. `$HOME/Library/Caches/ssg/llm` — macOS default.
168    /// 4. `%LOCALAPPDATA%\ssg\llm` — Windows.
169    /// 5. `$HOME/.cache/ssg/llm` — generic Unix fallback.
170    /// 6. `./.ssg-llm-cache` — last-resort relative path so the cache
171    ///    is still usable in sandboxes where neither `$HOME` nor
172    ///    `%LOCALAPPDATA%` is set.
173    ///
174    /// # Examples
175    ///
176    /// ```
177    /// use ssg::llm_cache::LlmCache;
178    /// let dir = LlmCache::default_cache_dir();
179    /// assert!(!dir.as_os_str().is_empty());
180    /// ```
181    #[must_use]
182    pub fn default_cache_dir() -> PathBuf {
183        if let Ok(explicit) = std::env::var("SSG_LLM_CACHE_DIR") {
184            if !explicit.is_empty() {
185                return PathBuf::from(explicit);
186            }
187        }
188        if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
189            if !xdg.is_empty() {
190                return PathBuf::from(xdg).join("ssg").join("llm");
191            }
192        }
193        #[cfg(target_os = "macos")]
194        {
195            if let Ok(home) = std::env::var("HOME") {
196                if !home.is_empty() {
197                    return PathBuf::from(home)
198                        .join("Library")
199                        .join("Caches")
200                        .join("ssg")
201                        .join("llm");
202                }
203            }
204        }
205        #[cfg(target_os = "windows")]
206        {
207            if let Ok(local) = std::env::var("LOCALAPPDATA") {
208                if !local.is_empty() {
209                    return PathBuf::from(local).join("ssg").join("llm");
210                }
211            }
212        }
213        if let Ok(home) = std::env::var("HOME") {
214            if !home.is_empty() {
215                return generic_unix_cache_dir(&home);
216            }
217        }
218        PathBuf::from(".ssg-llm-cache")
219    }
220
221    /// Computes the 32-byte SHA-256 key for `(endpoint, model, prompt, timeout_secs)`.
222    ///
223    /// Every parameter that can change the model's output is folded
224    /// into the digest so a request that differs in even one byte
225    /// gets a fresh inference (AC2). The hash is domain-separated
226    /// with a versioned prefix so a future change to the key
227    /// composition can be rolled out without colliding with stored
228    /// entries.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use ssg::llm_cache::LlmCache;
234    /// let a = LlmCache::compute_key("http://x", "llama", "hi", 30);
235    /// let b = LlmCache::compute_key("http://x", "llama", "hi", 30);
236    /// assert_eq!(a, b);
237    /// let c = LlmCache::compute_key("http://x", "llama", "bye", 30);
238    /// assert_ne!(a, c);
239    /// ```
240    #[must_use]
241    pub fn compute_key(
242        endpoint: &str,
243        model: &str,
244        prompt: &str,
245        timeout_secs: u64,
246    ) -> [u8; 32] {
247        let mut hasher = Sha256::new();
248        hasher.update(b"ssg-llm-cache-v1\x00");
249        hasher.update((endpoint.len() as u64).to_le_bytes());
250        hasher.update(endpoint.as_bytes());
251        hasher.update(b"\x00");
252        hasher.update((model.len() as u64).to_le_bytes());
253        hasher.update(model.as_bytes());
254        hasher.update(b"\x00");
255        hasher.update((prompt.len() as u64).to_le_bytes());
256        hasher.update(prompt.as_bytes());
257        hasher.update(b"\x00");
258        hasher.update(timeout_secs.to_le_bytes());
259        hasher.finalize().into()
260    }
261
262    /// Returns the cached payload for `key`, or `None` on miss /
263    /// stale / corrupt.
264    ///
265    /// A corrupted entry (truncated JSON, version mismatch, length
266    /// mismatch) is evicted in-place and reported as a miss so the
267    /// caller does a fresh inference (AC5). A TTL-expired entry is
268    /// handled the same way (AC4).
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use ssg::llm_cache::LlmCache;
274    /// let tmp = tempfile::tempdir().unwrap();
275    /// let cache = LlmCache::new(tmp.path().to_path_buf());
276    /// let key = LlmCache::compute_key("e", "m", "p", 1);
277    /// assert!(cache.get(&key).is_none());
278    /// cache.set(&key, "answer").unwrap();
279    /// assert_eq!(cache.get(&key).as_deref(), Some("answer"));
280    /// ```
281    pub fn get(&self, key: &[u8; 32]) -> Option<String> {
282        let path = self.entry_path(key);
283        let mut file = match fs::File::open(&path) {
284            Ok(f) => f,
285            Err(e) if e.kind() == io::ErrorKind::NotFound => {
286                let _ = self.misses.fetch_add(1, Ordering::Relaxed);
287                return None;
288            }
289            Err(_) => {
290                // EACCES / EIO / similar — treat as a miss so the
291                // caller falls back to live inference rather than
292                // failing the build for a cache pathology.
293                let _ = self.misses.fetch_add(1, Ordering::Relaxed);
294                return None;
295            }
296        };
297
298        if entry_is_expired(&file, self.ttl) {
299            let _ = fs::remove_file(&path);
300            let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
301            let _ = self.misses.fetch_add(1, Ordering::Relaxed);
302            return None;
303        }
304
305        let mut buf = String::new();
306        if file.read_to_string(&mut buf).is_err() {
307            let _ = fs::remove_file(&path);
308            let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
309            let _ = self.misses.fetch_add(1, Ordering::Relaxed);
310            return None;
311        }
312
313        if let Some(payload) = parse_entry(&buf, key) {
314            let _ = self.hits.fetch_add(1, Ordering::Relaxed);
315            Some(payload)
316        } else {
317            let _ = fs::remove_file(&path);
318            let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
319            let _ = self.misses.fetch_add(1, Ordering::Relaxed);
320            None
321        }
322    }
323
324    /// Stores `payload` under `key`.
325    ///
326    /// Returns `Ok(())` on success. On any filesystem error the call
327    /// silently falls through (counter is not bumped) so a transient
328    /// disk failure never breaks the build — the next invocation
329    /// will just be another miss + recompute.
330    ///
331    /// # Errors
332    ///
333    /// Returns the underlying [`io::Error`] when the cache file cannot
334    /// be created or renamed into place.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use ssg::llm_cache::LlmCache;
340    /// let tmp = tempfile::tempdir().unwrap();
341    /// let cache = LlmCache::new(tmp.path().to_path_buf());
342    /// let key = LlmCache::compute_key("e", "m", "p", 1);
343    /// cache.set(&key, "stored").unwrap();
344    /// assert_eq!(cache.stats().stores, 1);
345    /// ```
346    pub fn set(&self, key: &[u8; 32], payload: &str) -> io::Result<()> {
347        let path = self.entry_path(key);
348        ensure_parent_dir(&path)?;
349
350        let key_hex = encode_hex(key);
351        let body = serde_json::json!({
352            "version": ENTRY_VERSION,
353            "key_hex": key_hex,
354            "payload_len": payload.len(),
355            "payload": payload,
356        })
357        .to_string();
358
359        // Tempfile name uses pid + a monotonically-incrementing
360        // counter so two threads in the same process never collide
361        // even at sub-nanosecond resolution where the system clock
362        // could return the same `now()`.
363        let tmp = path.with_extension(format!(
364            "tmp.{}.{}",
365            std::process::id(),
366            next_tmp_seq(),
367        ));
368
369        {
370            let mut f = fs::File::create(&tmp)?;
371            f.write_all(body.as_bytes())?;
372            f.sync_all()?;
373        }
374        fs::rename(&tmp, &path)?;
375        let _ = self.stores.fetch_add(1, Ordering::Relaxed);
376        Ok(())
377    }
378
379    /// Removes the entry for `key` if present. Used by the
380    /// `ssg cache --clear` command and by the unit tests.
381    ///
382    /// # Errors
383    ///
384    /// Returns the underlying [`io::Error`] when the entry exists but
385    /// cannot be removed. A missing entry is treated as success.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// use ssg::llm_cache::LlmCache;
391    /// let tmp = tempfile::tempdir().unwrap();
392    /// let cache = LlmCache::new(tmp.path().to_path_buf());
393    /// let key = LlmCache::compute_key("e", "m", "p", 1);
394    /// cache.set(&key, "x").unwrap();
395    /// cache.evict(&key).unwrap();
396    /// assert!(cache.get(&key).is_none());
397    /// ```
398    pub fn evict(&self, key: &[u8; 32]) -> io::Result<()> {
399        let path = self.entry_path(key);
400        match fs::remove_file(&path) {
401            Ok(()) => {
402                let _ = self.evictions.fetch_add(1, Ordering::Relaxed);
403                Ok(())
404            }
405            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
406            Err(e) => Err(e),
407        }
408    }
409
410    /// Returns the running session counters.
411    ///
412    /// # Examples
413    ///
414    /// ```
415    /// use ssg::llm_cache::LlmCache;
416    /// let tmp = tempfile::tempdir().unwrap();
417    /// let cache = LlmCache::new(tmp.path().to_path_buf());
418    /// let stats = cache.stats();
419    /// assert_eq!(stats.hits, 0);
420    /// assert_eq!(stats.stores, 0);
421    /// ```
422    #[must_use]
423    pub fn stats(&self) -> CacheStats {
424        CacheStats {
425            hits: self.hits.load(Ordering::Relaxed),
426            misses: self.misses.load(Ordering::Relaxed),
427            stores: self.stores.load(Ordering::Relaxed),
428            evictions: self.evictions.load(Ordering::Relaxed),
429        }
430    }
431
432    /// Returns the cache root.
433    ///
434    /// # Examples
435    ///
436    /// ```
437    /// use ssg::llm_cache::LlmCache;
438    /// let tmp = tempfile::tempdir().unwrap();
439    /// let cache = LlmCache::new(tmp.path().to_path_buf());
440    /// assert_eq!(cache.root(), tmp.path());
441    /// ```
442    #[must_use]
443    pub fn root(&self) -> &Path {
444        &self.root
445    }
446
447    /// Computes the file path for `key`.
448    fn entry_path(&self, key: &[u8; 32]) -> PathBuf {
449        let hex = encode_hex(key);
450        let (shard, rest) = hex.split_at(2);
451        self.root.join(shard).join(format!("{rest}.json"))
452    }
453}
454
455/// Returns `true` if `file`'s recorded modification time is older than
456/// `ttl`, meaning [`LlmCache::get`] should evict it instead of
457/// returning it as a hit.
458///
459/// Conservative on any uncertainty: if the file's metadata or modified
460/// time cannot be determined, or the clock looks skewed such that
461/// `modified` is in the future, the entry is treated as *not* expired
462/// rather than evicted — a filesystem or clock hiccup should never
463/// cause a spurious cache miss. On every real filesystem an
464/// already-open file handle reliably yields `Ok` from both
465/// `metadata()` and `modified()`, so the two failpoints below are the
466/// only way to drive those (otherwise practically unreachable, since
467/// `fs::File` exposes no way to force them from safe Rust) `Err` arms
468/// in a test.
469// The `(|| { fail_point!(...); ... })()` IIFEs below are deliberate: the
470// `fail_point!` macro injects an early `return` scoped to the *closure*, so a
471// failpoint can drive the otherwise-unreachable `Err` arms without returning
472// from the whole function. Removing the closure would change control flow.
473#[allow(clippy::redundant_closure_call)]
474fn entry_is_expired(file: &fs::File, ttl: Duration) -> bool {
475    let Ok(meta) = (|| {
476        fail_point!("llm_cache::get-metadata-err", |_| Err(()));
477        file.metadata().map_err(|_| ())
478    })() else {
479        return false;
480    };
481    let Ok(modified) = (|| {
482        fail_point!("llm_cache::get-modified-err", |_| Err(()));
483        meta.modified().map_err(|_| ())
484    })() else {
485        return false;
486    };
487    let Ok(age) = SystemTime::now().duration_since(modified) else {
488        return false;
489    };
490    age > ttl
491}
492
493/// Ensures the parent directory of `path` exists, creating it (and
494/// any missing ancestors) if necessary. Used by [`LlmCache::set`]
495/// before writing the temp file that gets renamed into place.
496///
497/// [`LlmCache::entry_path`] always joins at least a shard directory
498/// and a filename onto `root`, so `path.parent()` is `Some` for every
499/// key produced through the public API in practice — the `None` arm
500/// (a path with no parent component at all) cannot be produced that
501/// way. The failpoint lets a test drive that arm directly without a
502/// contrived root value.
503#[allow(clippy::redundant_closure_call)] // fail_point! IIFE — see entry_is_expired
504fn ensure_parent_dir(path: &Path) -> io::Result<()> {
505    let parent = (|| {
506        fail_point!("llm_cache::set-no-parent", |_| None);
507        path.parent()
508    })();
509    if let Some(parent) = parent {
510        fs::create_dir_all(parent)?;
511    }
512    Ok(())
513}
514
515/// Generic Unix (`$HOME/.cache/ssg/llm`) cache-root fallback, used by
516/// every target that is neither macOS nor Windows (Linux, BSD, etc.).
517///
518/// Extracted out of [`LlmCache::default_cache_dir`] as a pure function
519/// of `home` so it is directly unit-testable regardless of which OS
520/// actually runs the test suite: on a macOS test runner, the
521/// `#[cfg(target_os = "macos")]` branch in `default_cache_dir` always
522/// intercepts first whenever `HOME` is set, so this fallback can never
523/// be reached at runtime there even though the logic is still compiled
524/// in (it is real, live code for Linux/BSD users).
525fn generic_unix_cache_dir(home: &str) -> PathBuf {
526    PathBuf::from(home).join(".cache").join("ssg").join("llm")
527}
528
529/// Lowercase-hex encode a 32-byte digest into a 64-char `String`
530/// without pulling in the `hex` crate.
531fn encode_hex(bytes: &[u8; 32]) -> String {
532    const HEX: &[u8; 16] = b"0123456789abcdef";
533    let mut out = String::with_capacity(64);
534    for &b in bytes {
535        out.push(HEX[(b >> 4) as usize] as char);
536        out.push(HEX[(b & 0x0f) as usize] as char);
537    }
538    out
539}
540
541/// Parses the on-disk JSON entry, returning the payload only when
542/// every integrity check passes. Returns `None` for: unparsable
543/// JSON, wrong version, missing fields, mismatched key, or
544/// mismatched payload length.
545fn parse_entry(text: &str, key: &[u8; 32]) -> Option<String> {
546    let v: serde_json::Value = serde_json::from_str(text).ok()?;
547    let version = v.get("version")?.as_u64()?;
548    if u32::try_from(version).ok()? != ENTRY_VERSION {
549        return None;
550    }
551    let key_hex = v.get("key_hex")?.as_str()?;
552    if key_hex != encode_hex(key) {
553        return None;
554    }
555    let payload = v.get("payload")?.as_str()?;
556    let stored_len = usize::try_from(v.get("payload_len")?.as_u64()?).ok()?;
557    if stored_len != payload.len() {
558        return None;
559    }
560    Some(payload.to_string())
561}
562
563/// Process-global counter feeding [`LlmCache::set`]'s tempfile name
564/// so two threads writing the same key at the same nanosecond still
565/// pick distinct staging paths.
566fn next_tmp_seq() -> u64 {
567    static SEQ: AtomicU64 = AtomicU64::new(0);
568    SEQ.fetch_add(1, Ordering::Relaxed)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use std::thread;
575    use std::time::Duration;
576
577    fn cache_for_test() -> (tempfile::TempDir, LlmCache) {
578        let dir = tempfile::tempdir().unwrap();
579        let cache = LlmCache::new(dir.path().to_path_buf());
580        (dir, cache)
581    }
582
583    #[test]
584    fn encode_hex_zero_padded() {
585        let mut bytes = [0u8; 32];
586        bytes[0] = 0x0a;
587        bytes[31] = 0xff;
588        let hex = encode_hex(&bytes);
589        assert_eq!(hex.len(), 64);
590        assert!(hex.starts_with("0a"));
591        assert!(hex.ends_with("ff"));
592    }
593
594    #[test]
595    fn compute_key_is_deterministic() {
596        let k1 = LlmCache::compute_key("e", "m", "p", 1);
597        let k2 = LlmCache::compute_key("e", "m", "p", 1);
598        assert_eq!(k1, k2);
599    }
600
601    #[test]
602    fn compute_key_differs_on_endpoint() {
603        let a = LlmCache::compute_key("e1", "m", "p", 1);
604        let b = LlmCache::compute_key("e2", "m", "p", 1);
605        assert_ne!(a, b);
606    }
607
608    #[test]
609    fn compute_key_differs_on_model() {
610        let a = LlmCache::compute_key("e", "m1", "p", 1);
611        let b = LlmCache::compute_key("e", "m2", "p", 1);
612        assert_ne!(a, b);
613    }
614
615    #[test]
616    fn compute_key_differs_on_prompt() {
617        let a = LlmCache::compute_key("e", "m", "p1", 1);
618        let b = LlmCache::compute_key("e", "m", "p2", 1);
619        assert_ne!(a, b);
620    }
621
622    #[test]
623    fn compute_key_differs_on_timeout() {
624        // AC2 — parameters are part of the cache key.
625        let a = LlmCache::compute_key("e", "m", "p", 1);
626        let b = LlmCache::compute_key("e", "m", "p", 2);
627        assert_ne!(a, b);
628    }
629
630    #[test]
631    fn compute_key_resists_length_collision() {
632        // "ab" + "cd" must not hash equal to "abc" + "d" because we
633        // length-prefix each component.
634        let a = LlmCache::compute_key("ab", "cd", "p", 1);
635        let b = LlmCache::compute_key("abc", "d", "p", 1);
636        assert_ne!(a, b);
637    }
638
639    #[test]
640    #[serial_test::parallel]
641    fn round_trip_hit() {
642        let (_d, cache) = cache_for_test();
643        let key = LlmCache::compute_key("e", "m", "p", 1);
644        assert!(cache.get(&key).is_none());
645        cache.set(&key, "the answer").unwrap();
646        assert_eq!(cache.get(&key).as_deref(), Some("the answer"));
647    }
648
649    #[test]
650    #[serial_test::parallel]
651    fn miss_counter_advances_on_absent_key() {
652        let (_d, cache) = cache_for_test();
653        let key = LlmCache::compute_key("e", "m", "p", 1);
654        let _ = cache.get(&key);
655        let _ = cache.get(&key);
656        assert_eq!(cache.stats().misses, 2);
657        assert_eq!(cache.stats().hits, 0);
658    }
659
660    #[test]
661    #[serial_test::parallel]
662    fn hit_counter_advances_on_present_key() {
663        let (_d, cache) = cache_for_test();
664        let key = LlmCache::compute_key("e", "m", "p", 1);
665        cache.set(&key, "x").unwrap();
666        let _ = cache.get(&key);
667        let _ = cache.get(&key);
668        assert_eq!(cache.stats().hits, 2);
669    }
670
671    #[test]
672    #[serial_test::parallel]
673    fn evict_removes_entry() {
674        let (_d, cache) = cache_for_test();
675        let key = LlmCache::compute_key("e", "m", "p", 1);
676        cache.set(&key, "x").unwrap();
677        cache.evict(&key).unwrap();
678        assert!(cache.get(&key).is_none());
679    }
680
681    #[test]
682    fn evict_missing_is_ok() {
683        let (_d, cache) = cache_for_test();
684        let key = LlmCache::compute_key("e", "m", "p", 1);
685        cache.evict(&key).unwrap();
686    }
687
688    #[test]
689    #[serial_test::parallel]
690    fn ttl_zero_expires_immediately() {
691        let dir = tempfile::tempdir().unwrap();
692        let cache = LlmCache::with_ttl(
693            dir.path().to_path_buf(),
694            Duration::from_nanos(1),
695        );
696        let key = LlmCache::compute_key("e", "m", "p", 1);
697        cache.set(&key, "x").unwrap();
698        thread::sleep(Duration::from_millis(5));
699        assert!(cache.get(&key).is_none());
700        assert!(cache.stats().evictions >= 1);
701    }
702
703    #[test]
704    #[serial_test::parallel]
705    fn corrupt_json_evicts_and_misses() {
706        let (_d, cache) = cache_for_test();
707        let key = LlmCache::compute_key("e", "m", "p", 1);
708        cache.set(&key, "x").unwrap();
709        // Truncate the file on disk to simulate a mid-write crash.
710        let p = cache.entry_path(&key);
711        fs::write(&p, "{ not json").unwrap();
712        assert!(cache.get(&key).is_none());
713        assert!(!p.exists(), "corrupt entry should have been evicted");
714    }
715
716    #[test]
717    #[serial_test::parallel]
718    fn length_mismatch_evicts() {
719        let (_d, cache) = cache_for_test();
720        let key = LlmCache::compute_key("e", "m", "p", 1);
721        cache.set(&key, "abcdef").unwrap();
722        let p = cache.entry_path(&key);
723        let body = serde_json::json!({
724            "version": ENTRY_VERSION,
725            "key_hex": encode_hex(&key),
726            "payload_len": 9999,
727            "payload": "abcdef",
728        });
729        fs::write(&p, body.to_string()).unwrap();
730        assert!(cache.get(&key).is_none());
731    }
732
733    #[test]
734    #[serial_test::parallel]
735    fn version_mismatch_evicts() {
736        let (_d, cache) = cache_for_test();
737        let key = LlmCache::compute_key("e", "m", "p", 1);
738        cache.set(&key, "x").unwrap();
739        let p = cache.entry_path(&key);
740        let body = serde_json::json!({
741            "version": 9999,
742            "key_hex": encode_hex(&key),
743            "payload_len": 1,
744            "payload": "x",
745        });
746        fs::write(&p, body.to_string()).unwrap();
747        assert!(cache.get(&key).is_none());
748    }
749
750    #[test]
751    #[serial_test::parallel]
752    fn key_mismatch_evicts() {
753        let (_d, cache) = cache_for_test();
754        let key = LlmCache::compute_key("e", "m", "p", 1);
755        let other = LlmCache::compute_key("x", "y", "z", 9);
756        cache.set(&key, "x").unwrap();
757        let p = cache.entry_path(&key);
758        let body = serde_json::json!({
759            "version": ENTRY_VERSION,
760            "key_hex": encode_hex(&other),
761            "payload_len": 1,
762            "payload": "x",
763        });
764        fs::write(&p, body.to_string()).unwrap();
765        assert!(cache.get(&key).is_none());
766    }
767
768    #[test]
769    #[serial_test::parallel]
770    fn sharding_uses_first_two_hex_chars() {
771        let (dir, cache) = cache_for_test();
772        let key = [0xab; 32];
773        cache.set(&key, "x").unwrap();
774        let shard = dir.path().join("ab");
775        assert!(shard.is_dir(), "expected shard dir {shard:?}");
776    }
777
778    #[test]
779    #[serial_test::parallel]
780    fn concurrent_distinct_keys_do_not_collide() {
781        // AC7 — 50 concurrent writers on distinct keys.
782        let (_d, cache) = cache_for_test();
783        let cache = std::sync::Arc::new(cache);
784        let mut handles = Vec::new();
785        for i in 0..50 {
786            let c = std::sync::Arc::clone(&cache);
787            handles.push(thread::spawn(move || {
788                let key = LlmCache::compute_key("e", "m", &format!("p{i}"), 1);
789                c.set(&key, &format!("v{i}")).unwrap();
790            }));
791        }
792        for h in handles {
793            h.join().unwrap();
794        }
795        for i in 0..50 {
796            let key = LlmCache::compute_key("e", "m", &format!("p{i}"), 1);
797            assert_eq!(
798                cache.get(&key).as_deref(),
799                Some(format!("v{i}").as_str()),
800                "missing entry for key {i}"
801            );
802        }
803    }
804
805    #[test]
806    #[serial_test::parallel]
807    fn concurrent_same_key_last_writer_wins_no_corruption() {
808        // AC7 — multiple writers on the same key must produce a
809        // valid entry; we accept any one of their payloads.
810        let (_d, cache) = cache_for_test();
811        let cache = std::sync::Arc::new(cache);
812        let key = LlmCache::compute_key("e", "m", "p", 1);
813        let mut handles = Vec::new();
814        for i in 0..20 {
815            let c = std::sync::Arc::clone(&cache);
816            handles.push(thread::spawn(move || {
817                c.set(&key, &format!("v{i}")).unwrap();
818            }));
819        }
820        for h in handles {
821            h.join().unwrap();
822        }
823        let got = cache.get(&key).expect("entry should exist");
824        assert!(got.starts_with('v'));
825    }
826
827    /// Serialised env-var scoping for the `default_cache_dir` tests.
828    ///
829    /// Entries are applied *sequentially* (capture-then-set per entry)
830    /// and restored in reverse, so a duplicated key deterministically
831    /// exercises both restore arms: the later entry's captured
832    /// previous value is whatever the earlier entry just set.
833    fn with_env_vars<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
834        use std::sync::Mutex;
835        static ENV_LOCK: Mutex<()> = Mutex::new(());
836        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
837        let mut prev: Vec<(String, Option<String>)> = Vec::new();
838        for (key, value) in vars {
839            prev.push(((*key).to_string(), std::env::var(key).ok()));
840            match value {
841                Some(v) => std::env::set_var(key, v),
842                None => std::env::remove_var(key),
843            }
844        }
845        f();
846        for (key, value) in prev.into_iter().rev() {
847            match value {
848                Some(v) => std::env::set_var(&key, v),
849                None => std::env::remove_var(&key),
850            }
851        }
852    }
853
854    #[test]
855    fn default_cache_dir_respects_explicit_override() {
856        // Duplicate key: the inner entry restores the outer value on
857        // unwind (Some arm), the outer entry restores the machine
858        // state.
859        with_env_vars(
860            &[
861                ("SSG_LLM_CACHE_DIR", Some("/outer-sentinel")),
862                ("SSG_LLM_CACHE_DIR", Some("/tmp/ssg-test-cache")),
863            ],
864            || {
865                assert_eq!(
866                    LlmCache::default_cache_dir(),
867                    PathBuf::from("/tmp/ssg-test-cache")
868                );
869            },
870        );
871    }
872
873    #[test]
874    fn default_cache_dir_empty_override_falls_through_to_xdg() {
875        // An empty SSG_LLM_CACHE_DIR must be ignored; XDG_CACHE_HOME
876        // is next in the resolution order. The duplicated unset+set
877        // pair drives the remove-then-restore-None arms of the helper.
878        with_env_vars(
879            &[
880                ("SSG_LLM_CACHE_DIR", None),
881                ("SSG_LLM_CACHE_DIR", Some("")),
882                ("XDG_CACHE_HOME", Some("/xdg-root")),
883            ],
884            || {
885                assert_eq!(
886                    LlmCache::default_cache_dir(),
887                    PathBuf::from("/xdg-root").join("ssg").join("llm")
888                );
889            },
890        );
891    }
892
893    #[cfg(target_os = "macos")]
894    #[test]
895    fn default_cache_dir_empty_xdg_uses_home_library_caches() {
896        with_env_vars(
897            &[
898                ("SSG_LLM_CACHE_DIR", None),
899                ("XDG_CACHE_HOME", Some("")),
900                ("HOME", Some("/home-test")),
901            ],
902            || {
903                assert_eq!(
904                    LlmCache::default_cache_dir(),
905                    PathBuf::from("/home-test")
906                        .join("Library")
907                        .join("Caches")
908                        .join("ssg")
909                        .join("llm")
910                );
911            },
912        );
913    }
914
915    #[test]
916    fn default_cache_dir_without_home_uses_relative_fallback() {
917        with_env_vars(
918            &[
919                ("SSG_LLM_CACHE_DIR", None),
920                ("XDG_CACHE_HOME", None),
921                // On Windows, LOCALAPPDATA is a real, always-set env
922                // var that the production code checks before falling
923                // through to HOME — must be cleared too so this test
924                // exercises the actual "nothing configured" fallback
925                // on every platform.
926                ("LOCALAPPDATA", None),
927                ("HOME", None),
928            ],
929            || {
930                assert_eq!(
931                    LlmCache::default_cache_dir(),
932                    PathBuf::from(".ssg-llm-cache")
933                );
934            },
935        );
936    }
937
938    #[test]
939    fn default_cache_dir_empty_home_uses_relative_fallback() {
940        with_env_vars(
941            &[
942                ("SSG_LLM_CACHE_DIR", None),
943                ("XDG_CACHE_HOME", None),
944                ("LOCALAPPDATA", None),
945                ("HOME", Some("")),
946            ],
947            || {
948                assert_eq!(
949                    LlmCache::default_cache_dir(),
950                    PathBuf::from(".ssg-llm-cache")
951                );
952            },
953        );
954    }
955
956    #[test]
957    fn root_returns_constructor_path() {
958        let dir = tempfile::tempdir().unwrap();
959        let cache = LlmCache::new(dir.path().to_path_buf());
960        assert_eq!(cache.root(), dir.path());
961    }
962
963    #[test]
964    fn stats_default_is_zero() {
965        let s = CacheStats::default();
966        assert_eq!(s.hits, 0);
967        assert_eq!(s.misses, 0);
968        assert_eq!(s.stores, 0);
969        assert_eq!(s.evictions, 0);
970    }
971
972    #[test]
973    #[serial_test::parallel]
974    fn stats_store_counter_increments() {
975        let (_d, cache) = cache_for_test();
976        let k1 = LlmCache::compute_key("e", "m", "p1", 1);
977        let k2 = LlmCache::compute_key("e", "m", "p2", 1);
978        cache.set(&k1, "x").unwrap();
979        cache.set(&k2, "y").unwrap();
980        assert_eq!(cache.stats().stores, 2);
981    }
982
983    #[test]
984    #[serial_test::parallel]
985    fn get_returns_none_when_entry_is_a_directory() {
986        // File::open on a directory returns Err with kind other than
987        // NotFound — hits the catch-all `Err(_) => …` arm in get().
988        let (_d, cache) = cache_for_test();
989        let key = LlmCache::compute_key("e", "m", "p", 1);
990        let path = cache.entry_path(&key);
991        fs::create_dir_all(&path).unwrap();
992        assert!(cache.get(&key).is_none());
993    }
994
995    #[test]
996    #[serial_test::parallel]
997    fn get_returns_none_on_missing_entry_increments_miss() {
998        let (_d, cache) = cache_for_test();
999        let key = LlmCache::compute_key("e", "m", "missing", 1);
1000        let before = cache.stats().misses;
1001        assert!(cache.get(&key).is_none());
1002        assert!(cache.stats().misses > before);
1003    }
1004
1005    #[test]
1006    fn parse_entry_returns_none_for_missing_fields() {
1007        let key = LlmCache::compute_key("e", "m", "p", 1);
1008        // Missing `version` field.
1009        let json = format!(
1010            r#"{{"key_hex":"{}","payload":"x","payload_len":1}}"#,
1011            encode_hex(&key)
1012        );
1013        assert!(parse_entry(&json, &key).is_none());
1014    }
1015
1016    #[test]
1017    fn parse_entry_returns_none_for_non_object_payload() {
1018        let key = LlmCache::compute_key("e", "m", "p", 1);
1019        assert!(parse_entry("[1,2,3]", &key).is_none());
1020        assert!(parse_entry("null", &key).is_none());
1021    }
1022
1023    #[test]
1024    fn evict_propagates_unexpected_io_error() {
1025        // Calling evict on a path whose parent is a non-existent dir
1026        // does NOT error (NotFound is mapped to Ok). But we can force
1027        // a different error by making the path itself a directory (so
1028        // remove_file returns IsADirectory / similar).
1029        let (_d, cache) = cache_for_test();
1030        let key = LlmCache::compute_key("e", "m", "evict-dir", 1);
1031        let path = cache.entry_path(&key);
1032        fs::create_dir_all(&path).unwrap();
1033        let res = cache.evict(&key);
1034        // On unix `remove_file` on a directory returns IsADirectory;
1035        // on some macOS versions returns PermissionDenied. We only
1036        // assert the body executed and didn't panic — either Ok or
1037        // Err is acceptable.
1038        let _ = res;
1039    }
1040
1041    #[cfg(unix)]
1042    #[test]
1043    #[serial_test::parallel]
1044    fn get_open_permission_error_counts_as_miss() {
1045        use std::os::unix::fs::PermissionsExt;
1046        let (_d, cache) = cache_for_test();
1047        let key = LlmCache::compute_key("e", "m", "denied", 1);
1048        cache.set(&key, "x").unwrap();
1049        let path = cache.entry_path(&key);
1050        fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
1051
1052        let misses_before = cache.stats().misses;
1053        assert!(
1054            cache.get(&key).is_none(),
1055            "EACCES must be treated as a miss"
1056        );
1057        assert_eq!(cache.stats().misses, misses_before + 1);
1058
1059        // Restore so the tempdir can be cleaned up.
1060        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1061    }
1062
1063    #[test]
1064    #[serial_test::parallel]
1065    fn get_hits_when_mtime_is_in_the_future() {
1066        // duration_since(modified) errors when the mtime is ahead of
1067        // now; the TTL check must fall through and still return a hit.
1068        let (_d, cache) = cache_for_test();
1069        let key = LlmCache::compute_key("e", "m", "future", 1);
1070        cache.set(&key, "from-tomorrow").unwrap();
1071        let path = cache.entry_path(&key);
1072        let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
1073        f.set_modified(SystemTime::now() + Duration::from_secs(3600))
1074            .unwrap();
1075        drop(f);
1076
1077        assert_eq!(cache.get(&key).as_deref(), Some("from-tomorrow"));
1078    }
1079
1080    #[test]
1081    #[serial_test::parallel]
1082    fn set_fails_when_root_is_a_file() {
1083        // create_dir_all under a plain file must error, propagating
1084        // through the `?` in set().
1085        let dir = tempfile::tempdir().unwrap();
1086        let root_file = dir.path().join("rootfile");
1087        fs::write(&root_file, "not a dir").unwrap();
1088        let cache = LlmCache::new(root_file);
1089        let key = LlmCache::compute_key("e", "m", "p", 1);
1090        assert!(cache.set(&key, "x").is_err());
1091        assert_eq!(cache.stats().stores, 0);
1092    }
1093
1094    #[test]
1095    fn next_tmp_seq_is_monotonic() {
1096        let a = next_tmp_seq();
1097        let b = next_tmp_seq();
1098        let c = next_tmp_seq();
1099        assert!(b > a);
1100        assert!(c > b);
1101    }
1102
1103    #[test]
1104    fn generic_unix_cache_dir_joins_expected_components() {
1105        // On macOS the `#[cfg(target_os = "macos")]` branch in
1106        // `default_cache_dir` always intercepts before this generic
1107        // Linux/BSD fallback can be reached at runtime whenever `HOME`
1108        // is set, so the underlying logic is tested directly here
1109        // instead of through `default_cache_dir` itself.
1110        assert_eq!(
1111            generic_unix_cache_dir("/home/alice"),
1112            PathBuf::from("/home/alice")
1113                .join(".cache")
1114                .join("ssg")
1115                .join("llm")
1116        );
1117    }
1118
1119    #[test]
1120    fn llm_cache_debug_impl_includes_type_name() {
1121        let (_d, cache) = cache_for_test();
1122        let debug = format!("{cache:?}");
1123        assert!(debug.contains("LlmCache"));
1124    }
1125
1126    #[test]
1127    fn cache_stats_debug_impl_includes_type_name() {
1128        let stats = CacheStats::default();
1129        let debug = format!("{stats:?}");
1130        assert!(debug.contains("CacheStats"));
1131    }
1132
1133    // ── Fault injection (feature-gated) ──────────────────────────
1134    //
1135    // `fs::File` exposes no safe way to make an already-open handle's
1136    // `metadata()`/`modified()` calls fail, and `LlmCache::entry_path`
1137    // always produces a path with a parent component, so these three
1138    // branches are otherwise practically unreachable from a test.
1139
1140    #[cfg(feature = "test-fault-injection")]
1141    mod fault {
1142        use super::*;
1143        use serial_test::serial;
1144
1145        #[test]
1146        #[serial]
1147        fn entry_is_expired_treats_metadata_error_as_not_expired() {
1148            let (_d, cache) = cache_for_test();
1149            let key = LlmCache::compute_key("e", "m", "metadata-err", 1);
1150            cache.set(&key, "x").unwrap();
1151
1152            fail::cfg("llm_cache::get-metadata-err", "return").unwrap();
1153            let hit = cache.get(&key);
1154            let _ = fail::cfg("llm_cache::get-metadata-err", "off");
1155
1156            assert_eq!(
1157                hit.as_deref(),
1158                Some("x"),
1159                "metadata() failure must fall back to 'not expired', not a miss"
1160            );
1161        }
1162
1163        #[test]
1164        #[serial]
1165        fn entry_is_expired_treats_modified_error_as_not_expired() {
1166            let (_d, cache) = cache_for_test();
1167            let key = LlmCache::compute_key("e", "m", "modified-err", 1);
1168            cache.set(&key, "y").unwrap();
1169
1170            fail::cfg("llm_cache::get-modified-err", "return").unwrap();
1171            let hit = cache.get(&key);
1172            let _ = fail::cfg("llm_cache::get-modified-err", "off");
1173
1174            assert_eq!(
1175                hit.as_deref(),
1176                Some("y"),
1177                "modified() failure must fall back to 'not expired', not a miss"
1178            );
1179        }
1180
1181        #[test]
1182        #[serial]
1183        fn set_skips_mkdir_when_parent_forced_to_none() {
1184            let (_d, cache) = cache_for_test();
1185            let key = LlmCache::compute_key("e", "m", "no-parent", 1);
1186
1187            fail::cfg("llm_cache::set-no-parent", "return").unwrap();
1188            let result = cache.set(&key, "z");
1189            let _ = fail::cfg("llm_cache::set-no-parent", "off");
1190
1191            // The shard directory was never created (mkdir was
1192            // skipped), so the temp-file creation that follows fails
1193            // with NotFound — proving `ensure_parent_dir` really did
1194            // take the "no parent" arm rather than silently
1195            // succeeding via the normal path.
1196            assert!(
1197                result.is_err(),
1198                "set() should fail when the parent directory was never created"
1199            );
1200        }
1201    }
1202}