1use 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
70pub const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 90);
73
74const ENTRY_VERSION: u32 = 1;
78
79#[derive(Debug, Clone, Copy, Default)]
83pub struct CacheStats {
84 pub hits: u64,
86 pub misses: u64,
89 pub stores: u64,
91 pub evictions: u64,
93}
94
95#[derive(Debug)]
102pub struct LlmCache {
103 root: PathBuf,
105 ttl: Duration,
107 hits: AtomicU64,
109 misses: AtomicU64,
111 stores: AtomicU64,
113 evictions: AtomicU64,
115}
116
117impl LlmCache {
118 #[must_use]
132 pub const fn new(root: PathBuf) -> Self {
133 Self::with_ttl(root, DEFAULT_TTL)
134 }
135
136 #[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 #[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 #[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 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 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 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 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 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 #[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 #[must_use]
443 pub fn root(&self) -> &Path {
444 &self.root
445 }
446
447 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#[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#[allow(clippy::redundant_closure_call)] fn 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
515fn generic_unix_cache_dir(home: &str) -> PathBuf {
526 PathBuf::from(home).join(".cache").join("ssg").join("llm")
527}
528
529fn 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
541fn 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
563fn 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 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 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 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 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 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 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 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 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 ("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 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 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 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 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 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 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 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 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 #[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 assert!(
1197 result.is_err(),
1198 "set() should fail when the parent directory was never created"
1199 );
1200 }
1201 }
1202}