1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use sha2::{Digest, Sha256};
6
7use crate::delta_encoder::DeltaEncoder;
8use crate::error::Result;
9use crate::pipeline::{CompressionPipeline, SessionContext};
10use crate::preset::Preset;
11use crate::session_store::SessionStore;
12use crate::types::CompressedContent;
13
14pub enum CacheResult {
21 Dedup {
23 inline_ref: String,
25 token_cost: u32,
27 },
28 Delta {
30 delta_text: String,
32 token_cost: u32,
34 similarity: f64,
36 },
37 Fresh { output: CompressedContent },
39}
40
41#[derive(Debug, Clone)]
50pub enum ExpandResult {
51 Original {
54 hash: String,
57 bytes: Vec<u8>,
59 },
60 CompressedOnly {
64 hash: String,
65 compressed: String,
67 },
68}
69
70#[derive(Debug, Clone)]
78#[allow(dead_code)]
79struct RefEntry {
80 last_sent_turn: u64,
82}
83
84pub struct CacheManager {
102 store: SessionStore,
103 max_size_bytes: u64,
104 delta_encoder: DeltaEncoder,
105 #[allow(dead_code)]
108 turn_counter: std::cell::Cell<u64>,
109 #[allow(dead_code)]
111 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
112 max_ref_age: Duration,
117 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
121}
122
123impl CacheManager {
124 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
130 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
131 }
132
133 #[doc(hidden)]
139 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
140 Self::with_ref_age_duration(
141 store,
142 max_size_bytes,
143 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
144 )
145 }
146
147 pub fn with_ref_age_duration(
149 store: SessionStore,
150 max_size_bytes: u64,
151 max_ref_age: Duration,
152 ) -> Self {
153 Self {
154 store,
155 max_size_bytes,
156 delta_encoder: DeltaEncoder::new(),
157 turn_counter: std::cell::Cell::new(0),
158 ref_tracker: std::cell::RefCell::new(HashMap::new()),
159 max_ref_age,
160 compaction_marker: std::cell::Cell::new(None),
161 }
162 }
163
164 pub fn sha256_hex(bytes: &[u8]) -> String {
166 let mut hasher = Sha256::new();
167 hasher.update(bytes);
168 format!("{:x}", hasher.finalize())
169 }
170
171 pub fn advance_turn(&self) {
175 self.turn_counter.set(self.turn_counter.get() + 1);
176 }
177
178 pub fn current_turn(&self) -> u64 {
180 self.turn_counter.get()
181 }
182
183 pub fn notify_compaction(&self) {
197 let now = chrono::Utc::now();
198 self.compaction_marker.set(Some(now));
199 self.ref_tracker.borrow_mut().clear();
200 let _ = self
206 .store
207 .set_metadata("last_compaction_at", &now.to_rfc3339());
208 }
209
210 fn is_ref_fresh(&self, hash: &str) -> bool {
225 if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
229 return true;
230 }
231 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
232 Ok(Some(ts)) => ts,
233 _ => return false,
234 };
235 if let Some(marker) = self.compaction_marker.get() {
237 if accessed < marker {
238 return false;
239 }
240 }
241 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
245 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
246 if accessed < marker {
247 return false;
248 }
249 }
250 }
251 let age = (chrono::Utc::now() - accessed)
252 .to_std()
253 .unwrap_or(Duration::from_secs(0));
254 age < self.max_ref_age
255 }
256
257 fn record_ref_sent(&self, hash: &str) {
263 let _ = self.store.touch_cache_entry(hash);
264 }
265
266 pub fn get_or_compress(
274 &self,
275 path: &Path,
276 content: &[u8],
277 pipeline: &CompressionPipeline,
278 ) -> Result<CacheResult> {
279 self.get_or_compress_inner(path, content, pipeline, false)
280 }
281
282 pub fn get_or_compress_lossless(
288 &self,
289 path: &Path,
290 content: &[u8],
291 pipeline: &CompressionPipeline,
292 ) -> Result<CacheResult> {
293 self.get_or_compress_inner(path, content, pipeline, true)
294 }
295
296 fn get_or_compress_inner(
297 &self,
298 _path: &Path,
299 content: &[u8],
300 pipeline: &CompressionPipeline,
301 lossless: bool,
302 ) -> Result<CacheResult> {
303 let hash = Self::sha256_hex(content);
304
305 let exists = self.store.cache_entry_exists(&hash)?;
310 if exists {
311 if self.is_ref_fresh(&hash) {
312 let hash_prefix = &hash[..16];
314 let inline_ref = format!("§ref:{hash_prefix}§");
315 self.record_ref_sent(&hash);
317 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
318 return Ok(CacheResult::Dedup {
319 inline_ref,
320 token_cost: 13,
321 });
322 } else {
323 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
326 let text = String::from_utf8_lossy(content).into_owned();
327 let ctx = SessionContext {
328 session_id: "cache".to_string(),
329 };
330 let preset = Preset::default();
331 let compressed = if lossless {
332 pipeline.compress_lossless(&text, &ctx, &preset)?
333 } else {
334 pipeline.compress(&text, &ctx, &preset)?
335 };
336 self.record_ref_sent(&hash);
338 return Ok(CacheResult::Fresh { output: compressed });
339 }
340 }
341
342 let text = String::from_utf8_lossy(content).into_owned();
344 let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
345 if let Some(delta_result) = self.try_delta_encode(&text)? {
346 let ctx = SessionContext {
348 session_id: "cache".to_string(),
349 };
350 let preset = Preset::default();
351 let compressed = if lossless {
352 pipeline.compress_lossless(&text, &ctx, &preset)?
353 } else {
354 pipeline.compress(&text, &ctx, &preset)?
355 };
356 self.store
358 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
359 self.record_ref_sent(&hash);
360
361 let token_cost = (delta_result.delta_text.len() / 4) as u32;
362 return Ok(CacheResult::Delta {
363 delta_text: delta_result.delta_text,
364 token_cost: token_cost.max(5),
365 similarity: delta_result.similarity,
366 });
367 }
368
369 let ctx = SessionContext {
370 session_id: "cache".to_string(),
371 };
372 let preset = Preset::default();
373 let compressed = if lossless {
374 pipeline.compress_lossless(&text, &ctx, &preset)?
375 } else {
376 pipeline.compress(&text, &ctx, &preset)?
377 };
378 self.store
379 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
380 self.record_ref_sent(&hash);
382
383 Ok(CacheResult::Fresh { output: compressed })
384 }
385
386 fn try_delta_encode(
389 &self,
390 new_content: &str,
391 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
392 let entries = self.store.list_cache_entries_lru()?;
393
394 let check_count = entries.len().min(10);
396 for (hash, _) in entries.iter().rev().take(check_count) {
397 if let Some(cached) = self.store.get_cache_entry(hash)? {
398 let hash_prefix = &hash[..hash.len().min(16)];
399 if let Ok(Some(delta)) =
400 self.delta_encoder
401 .encode(&cached.data, new_content, hash_prefix)
402 {
403 if delta.delta_text.len() < new_content.len() {
405 return Ok(Some(delta));
406 }
407 }
408 }
409 }
410
411 Ok(None)
412 }
413
414 pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
423 let hash = Self::sha256_hex(content);
424 let fresh = self.is_ref_fresh(&hash);
426 if fresh {
427 let hash_prefix = &hash[..16];
428 self.record_ref_sent(&hash);
429 Ok(Some(format!("§ref:{hash_prefix}§")))
430 } else {
431 Ok(None)
434 }
435 }
436
437 pub fn store_compressed(
445 &self,
446 original_content: &[u8],
447 compressed: &CompressedContent,
448 ) -> Result<()> {
449 let hash = Self::sha256_hex(original_content);
450 self.store
451 .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
452 self.record_ref_sent(&hash);
453 Ok(())
454 }
455
456 pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
482 let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
483 return Ok(None);
484 };
485
486 if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
489 return Ok(Some(ExpandResult::Original { hash, bytes }));
490 }
491 Ok(Some(ExpandResult::CompressedOnly {
492 hash,
493 compressed: compressed_entry.data,
494 }))
495 }
496
497 pub fn invalidate(&self, path: &Path) -> Result<()> {
502 if !path.exists() {
503 return Ok(());
504 }
505 let bytes = std::fs::read(path)?;
506 let hash = Self::sha256_hex(&bytes);
507 self.store.delete_cache_entry(&hash)?;
508 Ok(())
509 }
510
511 pub fn evict_lru(&self) -> Result<u64> {
516 let entries = self.store.list_cache_entries_lru()?;
517
518 let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
520 if total <= self.max_size_bytes {
521 return Ok(0);
522 }
523
524 let mut freed: u64 = 0;
525 let mut remaining = total;
526
527 for (hash, size) in &entries {
528 if remaining <= self.max_size_bytes {
529 break;
530 }
531 self.store.delete_cache_entry(hash)?;
532 freed += size;
533 remaining -= size;
534 }
535
536 Ok(freed)
537 }
538}
539
540#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::preset::{
546 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
547 CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
548 ToolSelectionConfig, TruncateStringsConfig,
549 };
550 use crate::session_store::SessionStore;
551
552 fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
553 let dir = tempfile::tempdir().unwrap();
554 let path = dir.path().join("test.db");
555 let store = SessionStore::open_or_create(&path).unwrap();
556 (store, dir)
557 }
558
559 fn test_preset() -> Preset {
560 Preset {
561 preset: PresetMeta {
562 name: "test".into(),
563 version: "1.0".into(),
564 description: String::new(),
565 },
566 compression: CompressionConfig {
567 stages: vec![],
568 keep_fields: None,
569 strip_fields: None,
570 condense: Some(CondenseConfig {
571 enabled: true,
572 max_repeated_lines: 3,
573 }),
574 git_diff_fold: None,
575 strip_nulls: Some(StripNullsConfig { enabled: true }),
576 flatten: None,
577 truncate_strings: Some(TruncateStringsConfig {
578 enabled: true,
579 max_length: 500,
580 }),
581 collapse_arrays: Some(CollapseArraysConfig {
582 enabled: true,
583 max_items: 5,
584 summary_template: "... and {remaining} more items".into(),
585 }),
586 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
587 },
588 tool_selection: ToolSelectionConfig {
589 max_tools: 5,
590 similarity_threshold: 0.7,
591 default_tools: vec![],
592 },
593 budget: BudgetConfig {
594 warning_threshold: 0.70,
595 ceiling_threshold: 0.85,
596 default_window_size: 200_000,
597 agents: Default::default(),
598 },
599 terse_mode: TerseModeConfig {
600 enabled: false,
601 level: crate::preset::TerseLevel::Moderate,
602 },
603 model: ModelConfig {
604 family: "anthropic".into(),
605 primary: "claude-sonnet-4-20250514".into(),
606 local: String::new(),
607 complexity_threshold: 0.4,
608 pricing: None,
609 },
610 }
611 }
612
613 fn make_pipeline() -> CompressionPipeline {
614 CompressionPipeline::new(&test_preset())
615 }
616
617 #[test]
618 fn first_read_is_miss() {
619 let (store, _dir) = in_memory_store();
620 let cm = CacheManager::new(store, u64::MAX);
621 let pipeline = make_pipeline();
622 let content = b"hello world";
623 let result = cm
624 .get_or_compress(Path::new("file.txt"), content, &pipeline)
625 .unwrap();
626 assert!(matches!(result, CacheResult::Fresh { .. }));
627 }
628
629 #[test]
634 fn get_or_compress_lossless_does_not_truncate_source() {
635 let pipeline = make_pipeline();
636
637 let mut segs = Vec::new();
640 for i in 0..10 {
641 if i % 2 == 0 {
642 segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
643 } else {
644 segs.push(format!(
645 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
646 ));
647 }
648 }
649 let content = segs.join("\n\n");
650 assert!(content.len() > 500);
651
652 let (s1, _d1) = in_memory_store();
654 let lossy = CacheManager::new(s1, u64::MAX);
655 match lossy
656 .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
657 .unwrap()
658 {
659 CacheResult::Fresh { output } => {
660 assert!(output.data.contains("omitted"), "lossy path should truncate");
661 }
662 _ => panic!("expected Fresh on a cold read"),
663 }
664
665 let (s2, _d2) = in_memory_store();
667 let cm = CacheManager::new(s2, u64::MAX);
668 match cm
669 .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
670 .unwrap()
671 {
672 CacheResult::Fresh { output } => {
673 assert!(
674 !output.data.contains("omitted"),
675 "file-read path truncated content: {}",
676 output.data
677 );
678 for i in 0..10 {
679 assert!(
680 output.data.contains(&format!("SEG{i}")),
681 "file-read path dropped SEG{i}"
682 );
683 }
684 }
685 _ => panic!("expected CacheResult::Fresh on a cold read"),
686 }
687 }
688
689 #[test]
690 fn second_read_is_hit() {
691 let (store, _dir) = in_memory_store();
692 let cm = CacheManager::new(store, u64::MAX);
693 let pipeline = make_pipeline();
694 let content = b"hello world";
695 let path = Path::new("file.txt");
696
697 cm.get_or_compress(path, content, &pipeline).unwrap();
699
700 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
702 match result {
703 CacheResult::Dedup {
704 inline_ref,
705 token_cost,
706 } => {
707 assert!(inline_ref.starts_with("§ref:"));
708 assert!(inline_ref.ends_with('§'));
709 assert_eq!(token_cost, 13);
710 }
711 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
712 }
713 }
714
715 #[test]
716 fn different_content_is_miss() {
717 let (store, _dir) = in_memory_store();
718 let cm = CacheManager::new(store, u64::MAX);
719 let pipeline = make_pipeline();
720 let path = Path::new("file.txt");
721
722 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
723 let result = cm
724 .get_or_compress(path, b"content v2", &pipeline)
725 .unwrap();
726 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
727 }
728
729 #[test]
730 fn evict_lru_frees_bytes_when_over_limit() {
731 let (store, _dir) = in_memory_store();
732 let cm = CacheManager::new(store, 1);
734 let pipeline = make_pipeline();
735 let path = Path::new("f.txt");
736
737 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
739 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
740 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
741
742 let freed = cm.evict_lru().unwrap();
743 assert!(freed > 0, "expected bytes to be freed");
744 }
745
746 #[test]
747 fn evict_lru_no_op_when_under_limit() {
748 let (store, _dir) = in_memory_store();
749 let cm = CacheManager::new(store, u64::MAX);
750 let pipeline = make_pipeline();
751
752 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
753 .unwrap();
754
755 let freed = cm.evict_lru().unwrap();
756 assert_eq!(freed, 0);
757 }
758
759 #[test]
760 fn invalidate_removes_entry() {
761 let dir = tempfile::tempdir().unwrap();
762 let file_path = dir.path().join("test.txt");
763 std::fs::write(&file_path, b"some content").unwrap();
764
765 let store_path = dir.path().join("store.db");
766 let store = SessionStore::open_or_create(&store_path).unwrap();
767 let cm = CacheManager::new(store, u64::MAX);
768 let pipeline = make_pipeline();
769
770 let content = std::fs::read(&file_path).unwrap();
772 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
773
774 let hit = cm
776 .get_or_compress(&file_path, &content, &pipeline)
777 .unwrap();
778 assert!(matches!(hit, CacheResult::Dedup { .. }));
779
780 cm.invalidate(&file_path).unwrap();
781
782 let miss = cm
783 .get_or_compress(&file_path, &content, &pipeline)
784 .unwrap();
785 assert!(matches!(miss, CacheResult::Fresh { .. }));
786 }
787
788 #[test]
789 fn invalidate_nonexistent_path_is_noop() {
790 let (store, _dir) = in_memory_store();
791 let cm = CacheManager::new(store, u64::MAX);
792 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
794 .unwrap();
795 }
796
797 #[test]
805 fn stale_ref_returns_fresh_instead_of_dedup() {
806 let (store, _dir) = in_memory_store();
807 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
809 let pipeline = make_pipeline();
810 let content = b"hello world";
811 let path = Path::new("file.txt");
812
813 cm.get_or_compress(path, content, &pipeline).unwrap();
815
816 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
818 assert!(
819 matches!(result, CacheResult::Fresh { .. }),
820 "stale ref (TTL=0) should return Fresh, not Dedup"
821 );
822 }
823
824 #[test]
825 fn fresh_ref_returns_dedup() {
826 let (store, _dir) = in_memory_store();
827 let cm = CacheManager::with_ref_age_duration(
829 store,
830 u64::MAX,
831 Duration::from_secs(86_400),
832 );
833 let pipeline = make_pipeline();
834 let content = b"hello world";
835 let path = Path::new("file.txt");
836
837 cm.get_or_compress(path, content, &pipeline).unwrap();
838 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
839 assert!(
840 matches!(result, CacheResult::Dedup { .. }),
841 "fresh ref should dedup"
842 );
843 }
844
845 #[test]
846 fn notify_compaction_invalidates_all_refs() {
847 let (store, _dir) = in_memory_store();
848 let cm = CacheManager::with_ref_age_duration(
849 store,
850 u64::MAX,
851 Duration::from_secs(86_400),
852 );
853 let pipeline = make_pipeline();
854 let path = Path::new("file.txt");
855
856 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
858 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
859 assert!(matches!(
860 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
861 CacheResult::Dedup { .. }
862 ));
863 assert!(matches!(
864 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
865 CacheResult::Dedup { .. }
866 ));
867
868 std::thread::sleep(std::time::Duration::from_millis(10));
873 cm.notify_compaction();
874
875 assert!(matches!(
877 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
878 CacheResult::Fresh { .. }
879 ));
880 assert!(matches!(
881 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
882 CacheResult::Fresh { .. }
883 ));
884 }
885
886 #[test]
887 fn ref_refreshed_after_resend() {
888 let (store, _dir) = in_memory_store();
889 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
892 let pipeline = make_pipeline();
893 let content = b"hello world";
894 let path = Path::new("file.txt");
895
896 cm.get_or_compress(path, content, &pipeline).unwrap();
897 std::thread::sleep(std::time::Duration::from_millis(10));
900 cm.notify_compaction();
901
902 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
905 assert!(matches!(result, CacheResult::Fresh { .. }));
906
907 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
909 assert!(
910 matches!(result, CacheResult::Dedup { .. }),
911 "ref should be fresh after re-send"
912 );
913 }
914
915 #[test]
916 fn check_dedup_returns_none_for_stale_ref() {
917 let pipeline = make_pipeline();
918 let content = b"test content";
919 let path = Path::new("file.txt");
920
921 let (store, _dir) = in_memory_store();
923 let fresh =
924 CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
925 fresh.get_or_compress(path, content, &pipeline).unwrap();
926 assert!(fresh.check_dedup(content).unwrap().is_some());
927
928 let (store2, _dir2) = in_memory_store();
930 let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
931 stale.get_or_compress(path, content, &pipeline).unwrap();
932 assert!(
933 stale.check_dedup(content).unwrap().is_none(),
934 "stale ref should not be returned by check_dedup"
935 );
936 }
937
938 #[test]
939 fn pinned_ref_stays_fresh_past_ttl() {
940 let (store, _dir) = in_memory_store();
941 let cm = CacheManager::with_ref_age_duration(
942 store,
943 u64::MAX,
944 Duration::from_millis(10),
945 );
946 let pipeline = make_pipeline();
947 let content = b"wiki: stable knowledge base content";
948 let path = Path::new("wiki.md");
949
950 cm.get_or_compress(path, content, &pipeline).unwrap();
952 let hash = CacheManager::sha256_hex(content);
953 assert!(cm.store.pin_cache_entry(&hash).unwrap());
954
955 std::thread::sleep(std::time::Duration::from_millis(40));
957
958 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
960 assert!(
961 matches!(result, CacheResult::Dedup { .. }),
962 "pinned entry should stay fresh past TTL"
963 );
964 assert!(cm.check_dedup(content).unwrap().is_some());
965 }
966
967 #[test]
968 fn unpin_returns_to_ttl_behaviour() {
969 let (store, _dir) = in_memory_store();
970 let cm = CacheManager::with_ref_age_duration(
971 store,
972 u64::MAX,
973 Duration::from_millis(10),
974 );
975 let pipeline = make_pipeline();
976 let content = b"transient session content";
977 let path = Path::new("file.txt");
978
979 cm.get_or_compress(path, content, &pipeline).unwrap();
980 let hash = CacheManager::sha256_hex(content);
981 cm.store.pin_cache_entry(&hash).unwrap();
982
983 std::thread::sleep(std::time::Duration::from_millis(25));
985 assert!(matches!(
986 cm.get_or_compress(path, content, &pipeline).unwrap(),
987 CacheResult::Dedup { .. }
988 ));
989
990 cm.store.unpin_cache_entry(&hash).unwrap();
992 std::thread::sleep(std::time::Duration::from_millis(25));
993 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
994 assert!(
995 matches!(result, CacheResult::Fresh { .. }),
996 "unpinned entry should obey TTL"
997 );
998 }
999
1000 #[test]
1001 fn pinned_ref_survives_compaction_marker() {
1002 let (store, _dir) = in_memory_store();
1003 let cm = CacheManager::with_ref_age_duration(
1004 store,
1005 u64::MAX,
1006 Duration::from_secs(3600),
1007 );
1008 let pipeline = make_pipeline();
1009 let content = b"system prompt: shared across agents";
1010 let path = Path::new("system.md");
1011
1012 cm.get_or_compress(path, content, &pipeline).unwrap();
1013 let hash = CacheManager::sha256_hex(content);
1014 cm.store.pin_cache_entry(&hash).unwrap();
1015
1016 std::thread::sleep(std::time::Duration::from_millis(10));
1018 cm.notify_compaction();
1019
1020 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1023 assert!(
1024 matches!(result, CacheResult::Dedup { .. }),
1025 "pinned entry should survive compaction"
1026 );
1027 }
1028
1029 #[test]
1030 fn pin_visible_across_cachemanager_instances() {
1031 let dir = tempfile::tempdir().unwrap();
1036 let path = dir.path().join("shared.db");
1037
1038 let store_a = SessionStore::open_or_create(&path).unwrap();
1039 let cm_a = CacheManager::with_ref_age_duration(
1040 store_a,
1041 u64::MAX,
1042 Duration::from_millis(5),
1043 );
1044 let pipeline = make_pipeline();
1045 let content = b"shared wiki content for multi-agent dedup";
1046
1047 cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1048 .unwrap();
1049 let hash = CacheManager::sha256_hex(content);
1050 cm_a.store.pin_cache_entry(&hash).unwrap();
1051
1052 drop(cm_a);
1054 std::thread::sleep(std::time::Duration::from_millis(20));
1055
1056 let store_b = SessionStore::open_or_create(&path).unwrap();
1057 let cm_b = CacheManager::with_ref_age_duration(
1058 store_b,
1059 u64::MAX,
1060 Duration::from_millis(5),
1061 );
1062 let result = cm_b
1063 .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1064 .unwrap();
1065 assert!(
1066 matches!(result, CacheResult::Dedup { .. }),
1067 "second agent should see the pin from the shared store"
1068 );
1069 }
1070
1071 #[test]
1072 fn advance_turn_increments_counter() {
1073 let (store, _dir) = in_memory_store();
1075 let cm = CacheManager::new(store, u64::MAX);
1076 assert_eq!(cm.current_turn(), 0);
1077 cm.advance_turn();
1078 assert_eq!(cm.current_turn(), 1);
1079 cm.advance_turn();
1080 assert_eq!(cm.current_turn(), 2);
1081 }
1082
1083 #[test]
1086 fn expand_returns_original_bytes_for_new_entry() {
1087 let (store, _dir) = in_memory_store();
1091 let cm = CacheManager::new(store, u64::MAX);
1092 let pipeline = make_pipeline();
1093 let path = Path::new("x.txt");
1094 let content = b"hello\nworld\nthis is the original\n";
1095
1096 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1098 let CacheResult::Fresh { .. } = first else {
1099 panic!("first read should be a Fresh miss, got something else");
1100 };
1101
1102 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1104 let inline_ref = match second {
1105 CacheResult::Dedup { inline_ref, .. } => inline_ref,
1106 _ => panic!("second read should dedup"),
1107 };
1108
1109 let prefix = inline_ref
1111 .strip_prefix("§ref:")
1112 .and_then(|s| s.strip_suffix('§'))
1113 .expect("unexpected ref format");
1114
1115 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1116 match result {
1117 ExpandResult::Original { bytes, hash } => {
1118 assert_eq!(bytes, content, "expand must return exact original bytes");
1119 assert!(
1120 hash.starts_with(prefix),
1121 "returned full hash {hash} must start with prefix {prefix}"
1122 );
1123 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1124 }
1125 ExpandResult::CompressedOnly { .. } => {
1126 panic!("new cache entries must carry the original bytes");
1127 }
1128 }
1129 }
1130
1131 #[test]
1132 fn expand_prefix_rejects_nonhex_input() {
1133 let (store, _dir) = in_memory_store();
1138 let cm = CacheManager::new(store, u64::MAX);
1139 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1140 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1141 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1142 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1143 }
1144
1145 #[test]
1146 fn expand_prefix_returns_none_for_unknown_prefix() {
1147 let (store, _dir) = in_memory_store();
1150 let cm = CacheManager::new(store, u64::MAX);
1151 let pipeline = make_pipeline();
1152 let _ = cm
1154 .get_or_compress(
1155 Path::new("y.txt"),
1156 b"some cached content that won't match the prefix below",
1157 &pipeline,
1158 )
1159 .unwrap();
1160 assert!(
1161 cm.expand_prefix("0000000000000000").unwrap().is_none(),
1162 "prefix that matches nothing must return None, not error"
1163 );
1164 }
1165
1166 #[test]
1167 fn expand_prefix_errors_on_ambiguous_match() {
1168 let (store, _dir) = in_memory_store();
1173 let compressed = crate::types::CompressedContent {
1175 data: "compressed-a".to_string(),
1176 tokens_compressed: 1,
1177 tokens_original: 10,
1178 stages_applied: vec![],
1179 compression_ratio: 0.1,
1180 provenance: Default::default(),
1181 verify: None,
1182 };
1183 store
1184 .save_cache_entry_with_original(
1185 &format!("ab{}", "0".repeat(62)),
1186 &compressed,
1187 Some(b"content a"),
1188 )
1189 .unwrap();
1190 store
1191 .save_cache_entry_with_original(
1192 &format!("ab{}", "1".repeat(62)),
1193 &compressed,
1194 Some(b"content b"),
1195 )
1196 .unwrap();
1197
1198 let cm = CacheManager::new(store, u64::MAX);
1199 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1201 assert!(matches!(ok_a, ExpandResult::Original { .. }));
1202 let err = cm.expand_prefix("ab").unwrap_err();
1204 let msg = err.to_string();
1205 assert!(
1206 msg.contains("multiple entries") || msg.contains("longer prefix"),
1207 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1208 );
1209 }
1210
1211 #[test]
1212 fn expand_returns_compressed_only_for_pre_migration_entry() {
1213 let (store, _dir) = in_memory_store();
1218 let compressed = crate::types::CompressedContent {
1219 data: "the compressed version".to_string(),
1220 tokens_compressed: 4,
1221 tokens_original: 100,
1222 stages_applied: vec!["condense".to_string()],
1223 compression_ratio: 0.04,
1224 provenance: Default::default(),
1225 verify: None,
1226 };
1227 let hash = format!("deadbeef{}", "0".repeat(56));
1229 store.save_cache_entry(&hash, &compressed).unwrap();
1230
1231 let cm = CacheManager::new(store, u64::MAX);
1232 let result = cm
1233 .expand_prefix("deadbeef")
1234 .unwrap()
1235 .expect("deadbeef prefix should match");
1236 match result {
1237 ExpandResult::CompressedOnly { compressed, hash: h } => {
1238 assert_eq!(compressed, "the compressed version");
1239 assert_eq!(h, hash);
1240 }
1241 ExpandResult::Original { .. } => {
1242 panic!("legacy entry without original bytes should return CompressedOnly");
1243 }
1244 }
1245 }
1246
1247 #[test]
1248 fn expand_preserves_non_utf8_original_bytes() {
1249 let (store, _dir) = in_memory_store();
1255 let cm = CacheManager::new(store, u64::MAX);
1256 let pipeline = make_pipeline();
1257
1258 let content: Vec<u8> = vec![
1260 b'h', b'e', b'l', b'l', b'o', b'\n',
1261 0xFF, 0xFE, 0xFD,
1262 b'\n',
1263 ];
1264 let path = Path::new("bin.dat");
1265 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1266 let hash = CacheManager::sha256_hex(&content);
1268 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1269 match result {
1270 ExpandResult::Original { bytes, .. } => {
1271 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1272 }
1273 ExpandResult::CompressedOnly { .. } => {
1274 panic!("fresh entry should have original bytes");
1275 }
1276 }
1277 }
1278
1279 #[test]
1280 fn dedup_survives_cache_manager_restart() {
1281 let dir = tempfile::tempdir().unwrap();
1287 let db_path = dir.path().join("cache.db");
1288 let pipeline = make_pipeline();
1289 let content = b"a substantial chunk of content to dedup";
1290 let path = Path::new("x.txt");
1291
1292 {
1294 let store = SessionStore::open_or_create(&db_path).unwrap();
1295 let cm = CacheManager::with_ref_age_duration(
1296 store,
1297 u64::MAX,
1298 Duration::from_secs(3600),
1299 );
1300 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1301 assert!(matches!(first, CacheResult::Fresh { .. }));
1302 }
1303
1304 {
1306 let store = SessionStore::open_or_create(&db_path).unwrap();
1307 let cm = CacheManager::with_ref_age_duration(
1308 store,
1309 u64::MAX,
1310 Duration::from_secs(3600),
1311 );
1312 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1313 assert!(
1314 matches!(second, CacheResult::Dedup { .. }),
1315 "second-process read must dedup — this was broken before the April 18 fix"
1316 );
1317 }
1318 }
1319
1320 #[test]
1321 fn compaction_from_one_process_invalidates_refs_in_another() {
1322 let dir = tempfile::tempdir().unwrap();
1334 let db_path = dir.path().join("cache.db");
1335 let pipeline = make_pipeline();
1336 let content = b"content that needs stale-marking after compaction";
1337 let path = Path::new("file.txt");
1338 let ttl = Duration::from_secs(3600);
1339
1340 {
1342 let store = SessionStore::open_or_create(&db_path).unwrap();
1343 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1344 cm.get_or_compress(path, content, &pipeline).unwrap();
1345 }
1346 std::thread::sleep(Duration::from_millis(10));
1348
1349 {
1352 let store = SessionStore::open_or_create(&db_path).unwrap();
1353 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1354 cm.notify_compaction();
1355 }
1356
1357 {
1361 let store = SessionStore::open_or_create(&db_path).unwrap();
1362 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1363 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1364 assert!(
1365 matches!(result, CacheResult::Fresh { .. }),
1366 "post-compaction read from a fresh process must re-send Fresh; \
1367 returning Dedup would be a dangling-ref bug"
1368 );
1369 }
1370 }
1371
1372 use proptest::prelude::*;
1373
1374 proptest! {
1382 #[test]
1387 fn prop_cache_deduplication(
1388 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1389 ) {
1390 let (store, _dir) = in_memory_store();
1391 let cm = CacheManager::new(store, u64::MAX);
1392 let pipeline = make_pipeline();
1393 let path = Path::new("file.txt");
1394
1395 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1397 prop_assert!(
1398 matches!(first, CacheResult::Fresh { .. }),
1399 "first read should be a cache miss"
1400 );
1401
1402 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1403 match second {
1404 CacheResult::Dedup { inline_ref, token_cost } => {
1405 prop_assert_eq!(
1406 token_cost, 13,
1407 "cache hit should report ~13 reference tokens"
1408 );
1409 prop_assert!(
1410 inline_ref.starts_with("§ref:"),
1411 "reference token should start with §ref:"
1412 );
1413 prop_assert!(
1414 inline_ref.ends_with('§'),
1415 "reference token should end with §"
1416 );
1417 }
1418 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1419 prop_assert!(false, "second read should be a cache hit, not a miss");
1420 }
1421 }
1422 }
1423 }
1424
1425 proptest! {
1433 #[test]
1438 fn prop_cache_invalidation_on_content_change(
1439 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1440 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1441 ) {
1442 prop_assume!(content_a != content_b);
1444
1445 let (store, _dir) = in_memory_store();
1446 let cm = CacheManager::new(store, u64::MAX);
1447 let pipeline = make_pipeline();
1448 let path = Path::new("file.txt");
1449
1450 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1452 prop_assert!(
1453 matches!(r1, CacheResult::Fresh { .. }),
1454 "first read of content_a should be a miss"
1455 );
1456
1457 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1458 prop_assert!(
1459 matches!(r2, CacheResult::Dedup { .. }),
1460 "second read of content_a should be a hit"
1461 );
1462
1463 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1464 prop_assert!(
1465 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1466 "read with changed content should be a cache miss or delta"
1467 );
1468 }
1469 }
1470
1471 proptest! {
1479 #[test]
1484 fn prop_cache_lru_eviction(
1485 entries in proptest::collection::vec(
1487 proptest::collection::vec(any::<u8>(), 10..=200usize),
1488 2..=8usize,
1489 ),
1490 ) {
1491 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1493 for e in &entries {
1494 if !unique_entries.contains(e) {
1495 unique_entries.push(e.clone());
1496 }
1497 }
1498 prop_assume!(unique_entries.len() >= 2);
1499
1500 let (store, _dir) = in_memory_store();
1501 let cm = CacheManager::new(store, 1);
1503 let pipeline = make_pipeline();
1504 let path = Path::new("f.txt");
1505
1506 for entry in &unique_entries {
1508 cm.get_or_compress(path, entry, &pipeline).unwrap();
1509 }
1510
1511 let freed = cm.evict_lru().unwrap();
1513
1514 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1516
1517 let freed_again = cm.evict_lru().unwrap();
1520 prop_assert_eq!(
1521 freed_again, 0,
1522 "second evict_lru call should free 0 bytes (already at or below limit)"
1523 );
1524 }
1525 }
1526
1527 proptest! {
1536 #[test]
1547 fn prop_cache_persistence_across_sessions(
1548 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1549 ) {
1550 use crate::session_store::SessionStore;
1551
1552 let dir = tempfile::tempdir().unwrap();
1553 let db_path = dir.path().join("cache.db");
1554 let path = Path::new("file.txt");
1555
1556 {
1558 let store = SessionStore::open_or_create(&db_path).unwrap();
1559 let cm = CacheManager::with_ref_age_duration(
1561 store,
1562 u64::MAX,
1563 Duration::from_secs(3600),
1564 );
1565 let pipeline = make_pipeline();
1566
1567 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1568 prop_assert!(
1569 matches!(r, CacheResult::Fresh { .. }),
1570 "first-ever read should be a miss"
1571 );
1572 }
1573
1574 {
1576 let store = SessionStore::open_or_create(&db_path).unwrap();
1577 let cm = CacheManager::with_ref_age_duration(
1578 store,
1579 u64::MAX,
1580 Duration::from_secs(3600),
1581 );
1582 let pipeline = make_pipeline();
1583
1584 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1589 match r {
1590 CacheResult::Dedup { token_cost, .. } => {
1591 prop_assert_eq!(
1592 token_cost, 13,
1593 "first read after restart must be a 13-token dedup ref"
1594 );
1595 }
1596 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1597 prop_assert!(
1598 false,
1599 "first read after restart must dedup — this was the \
1600 April 18 bug and its fix is the whole reason this \
1601 test exists"
1602 );
1603 }
1604 }
1605 }
1606 }
1607 }
1608}