1use std::collections::HashMap;
20use thiserror::Error;
21
22#[derive(Debug, Error, PartialEq, Eq)]
28pub enum DedupIndexError {
29 #[error("key already exists: {0}")]
31 KeyAlreadyExists(String),
32
33 #[error("entry not found: {0}")]
35 EntryNotFound(String),
36
37 #[error("invalid size {size}: must be in [{min}, {max}]")]
39 InvalidSize {
40 size: usize,
42 min: usize,
44 max: usize,
46 },
47}
48
49#[derive(Clone, PartialEq, Eq, Hash)]
57pub struct ContentHash(pub [u8; 32]);
58
59impl std::fmt::Debug for ContentHash {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "ContentHash(")?;
62 for byte in &self.0 {
63 write!(f, "{:02x}", byte)?;
64 }
65 write!(f, ")")
66 }
67}
68
69impl std::fmt::Display for ContentHash {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 for byte in &self.0 {
72 write!(f, "{:02x}", byte)?;
73 }
74 Ok(())
75 }
76}
77
78#[inline]
84fn fnv1a_64(data: &[u8]) -> u64 {
85 const OFFSET: u64 = 14695981039346656037;
86 const PRIME: u64 = 1099511628211;
87 data.iter()
88 .fold(OFFSET, |h, &b| (h ^ (b as u64)).wrapping_mul(PRIME))
89}
90
91#[inline]
93fn djb2_64(data: &[u8]) -> u64 {
94 data.iter().fold(5381u64, |h, &b| {
95 h.wrapping_shl(5).wrapping_add(h).wrapping_add(b as u64) ^ (h >> 33)
96 })
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct DedupEntry {
106 pub hash: ContentHash,
108 pub canonical_key: String,
110 pub ref_count: u32,
112 pub byte_size: u64,
114 pub first_seen: u64,
116 pub last_accessed: u64,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ContentDedupResult {
127 New {
129 key: String,
131 hash: ContentHash,
133 },
134 Duplicate {
136 original_key: String,
138 saved_bytes: u64,
140 },
141}
142
143#[derive(Debug, Clone)]
149pub struct ContentDedupConfig {
150 pub max_entries: usize,
152 pub min_block_size: usize,
155 pub max_block_size: usize,
158 pub enable_ref_counting: bool,
160}
161
162impl Default for ContentDedupConfig {
163 fn default() -> Self {
164 Self {
165 max_entries: 100_000,
166 min_block_size: 64,
167 max_block_size: 64 * 1024 * 1024, enable_ref_counting: true,
169 }
170 }
171}
172
173#[derive(Debug, Clone, PartialEq)]
179pub struct ContentDedupStats {
180 pub total_entries: usize,
182 pub total_keys: usize,
184 pub total_saved_bytes: u64,
186 pub total_insertions: u64,
188 pub total_duplicates: u64,
190 pub dedup_ratio: f64,
192}
193
194#[derive(Debug)]
222pub struct ContentDeduplicationIndex {
223 pub config: ContentDedupConfig,
225 entries: HashMap<ContentHash, DedupEntry>,
227 key_to_hash: HashMap<String, ContentHash>,
229 total_saved_bytes: u64,
231 total_insertions: u64,
233 total_duplicates: u64,
235}
236
237impl ContentDeduplicationIndex {
238 pub fn new(config: ContentDedupConfig) -> Self {
240 Self {
241 entries: HashMap::new(),
242 key_to_hash: HashMap::new(),
243 total_saved_bytes: 0,
244 total_insertions: 0,
245 total_duplicates: 0,
246 config,
247 }
248 }
249
250 pub fn compute_hash(data: &[u8]) -> ContentHash {
258 let fnv_forward = fnv1a_64(data);
259 let djb2 = djb2_64(data);
260 let length = data.len() as u64;
261
262 let fnv_reverse = data.iter().rev().copied().collect::<Vec<u8>>();
264 let fnv_rev = fnv1a_64(&fnv_reverse);
265
266 let mut raw = [0u8; 32];
267 raw[0..8].copy_from_slice(&fnv_forward.to_le_bytes());
268 raw[8..16].copy_from_slice(&djb2.to_le_bytes());
269 raw[16..24].copy_from_slice(&length.to_le_bytes());
270 raw[24..32].copy_from_slice(&fnv_rev.to_le_bytes());
271 ContentHash(raw)
272 }
273
274 pub fn insert(
283 &mut self,
284 key: String,
285 data: &[u8],
286 now: u64,
287 ) -> Result<ContentDedupResult, DedupIndexError> {
288 self.total_insertions += 1;
289
290 let hash = Self::compute_hash(data);
291
292 if data.len() < self.config.min_block_size || data.len() > self.config.max_block_size {
294 return Ok(ContentDedupResult::New { key, hash });
295 }
296
297 if let Some(entry) = self.entries.get_mut(&hash) {
299 if self.config.enable_ref_counting {
301 entry.ref_count += 1;
302 }
303 entry.last_accessed = now;
304 let original_key = entry.canonical_key.clone();
305 let saved_bytes = data.len() as u64;
306
307 self.key_to_hash.insert(key, hash);
308 self.total_saved_bytes += saved_bytes;
309 self.total_duplicates += 1;
310
311 return Ok(ContentDedupResult::Duplicate {
312 original_key,
313 saved_bytes,
314 });
315 }
316
317 if self.entries.len() >= self.config.max_entries {
319 self.evict_one();
320 }
321
322 let entry = DedupEntry {
323 hash: hash.clone(),
324 canonical_key: key.clone(),
325 ref_count: 1,
326 byte_size: data.len() as u64,
327 first_seen: now,
328 last_accessed: now,
329 };
330 self.entries.insert(hash.clone(), entry);
331 self.key_to_hash.insert(key.clone(), hash.clone());
332
333 Ok(ContentDedupResult::New { key, hash })
334 }
335
336 pub fn remove(&mut self, key: &str) -> bool {
344 let hash = match self.key_to_hash.remove(key) {
345 Some(h) => h,
346 None => return false,
347 };
348
349 let remaining_refs = self.key_to_hash.values().filter(|h| **h == hash).count();
351
352 if let Some(entry) = self.entries.get_mut(&hash) {
353 if self.config.enable_ref_counting && entry.ref_count > 0 {
354 entry.ref_count -= 1;
355 }
356 if remaining_refs == 0 {
359 self.entries.remove(&hash);
360 }
361 }
362
363 true
364 }
365
366 pub fn lookup_by_key(&self, key: &str) -> Option<&DedupEntry> {
368 let hash = self.key_to_hash.get(key)?;
369 self.entries.get(hash)
370 }
371
372 pub fn lookup_by_hash(&self, hash: &ContentHash) -> Option<&DedupEntry> {
374 self.entries.get(hash)
375 }
376
377 pub fn is_duplicate(&self, data: &[u8]) -> bool {
380 if data.len() < self.config.min_block_size || data.len() > self.config.max_block_size {
381 return false;
382 }
383 let hash = Self::compute_hash(data);
384 self.entries.contains_key(&hash)
385 }
386
387 pub fn merge_duplicates(&mut self) -> u64 {
394 let mut count = 0u64;
395 for (key, hash) in &self.key_to_hash {
396 if let Some(entry) = self.entries.get(hash) {
397 if entry.canonical_key != *key {
398 count += 1;
399 }
400 }
401 }
402 count
403 }
404
405 pub fn deduplicated_keys(&self) -> Vec<(&str, &str)> {
408 self.key_to_hash
409 .iter()
410 .filter_map(|(key, hash)| {
411 let entry = self.entries.get(hash)?;
412 if entry.canonical_key == *key {
413 return None;
414 }
415 Some((key.as_str(), entry.canonical_key.as_str()))
416 })
417 .collect()
418 }
419
420 pub fn stats(&self) -> ContentDedupStats {
422 let total_insertions = self.total_insertions;
423 let total_duplicates = self.total_duplicates;
424 let dedup_ratio = total_duplicates as f64 / total_insertions.max(1) as f64;
425 ContentDedupStats {
426 total_entries: self.entries.len(),
427 total_keys: self.key_to_hash.len(),
428 total_saved_bytes: self.total_saved_bytes,
429 total_insertions,
430 total_duplicates,
431 dedup_ratio,
432 }
433 }
434
435 pub fn len(&self) -> usize {
437 self.entries.len()
438 }
439
440 pub fn is_empty(&self) -> bool {
442 self.entries.is_empty()
443 }
444
445 pub fn key_count(&self) -> usize {
447 self.key_to_hash.len()
448 }
449
450 fn evict_one(&mut self) {
457 let victim = self
459 .entries
460 .iter()
461 .min_by(|a, b| {
462 a.1.ref_count
463 .cmp(&b.1.ref_count)
464 .then_with(|| a.1.first_seen.cmp(&b.1.first_seen))
465 })
466 .map(|(hash, _)| hash.clone());
467
468 if let Some(victim_hash) = victim {
469 self.entries.remove(&victim_hash);
470 self.key_to_hash.retain(|_, h| *h != victim_hash);
472 }
473 }
474}
475
476#[cfg(test)]
481mod tests {
482 use crate::content_dedup_index::{
483 ContentDedupConfig, ContentDedupResult, ContentDeduplicationIndex, ContentHash,
484 DedupIndexError,
485 };
486
487 fn default_config() -> ContentDedupConfig {
490 ContentDedupConfig::default()
491 }
492
493 fn small_config(max_entries: usize) -> ContentDedupConfig {
494 ContentDedupConfig {
495 max_entries,
496 min_block_size: 4,
497 max_block_size: 1024,
498 enable_ref_counting: true,
499 }
500 }
501
502 fn make_data(seed: u8, len: usize) -> Vec<u8> {
503 (0..len).map(|i| seed.wrapping_add(i as u8)).collect()
504 }
505
506 #[test]
509 fn test_content_hash_is_32_bytes() {
510 let h = ContentDeduplicationIndex::compute_hash(b"hello");
511 assert_eq!(h.0.len(), 32);
512 }
513
514 #[test]
515 fn test_same_data_same_hash() {
516 let h1 = ContentDeduplicationIndex::compute_hash(b"deterministic");
517 let h2 = ContentDeduplicationIndex::compute_hash(b"deterministic");
518 assert_eq!(h1, h2);
519 }
520
521 #[test]
522 fn test_different_data_different_hash() {
523 let h1 = ContentDeduplicationIndex::compute_hash(b"foo");
524 let h2 = ContentDeduplicationIndex::compute_hash(b"bar");
525 assert_ne!(h1, h2);
526 }
527
528 #[test]
529 fn test_hash_encodes_length_in_bytes_16_to_23() {
530 let h = ContentDeduplicationIndex::compute_hash(b"abcd");
531 let len = u64::from_le_bytes(h.0[16..24].try_into().unwrap());
532 assert_eq!(len, 4u64);
533 }
534
535 #[test]
536 fn test_hash_empty_data() {
537 let h = ContentDeduplicationIndex::compute_hash(b"");
538 let len = u64::from_le_bytes(h.0[16..24].try_into().unwrap());
539 assert_eq!(len, 0u64);
540 }
541
542 #[test]
543 fn test_content_hash_debug_format() {
544 let h = ContentHash([0u8; 32]);
545 let s = format!("{:?}", h);
546 assert!(s.starts_with("ContentHash("));
547 assert!(s.ends_with(')'));
548 }
549
550 #[test]
551 fn test_content_hash_display_is_hex() {
552 let h = ContentHash([0xABu8; 32]);
553 let s = format!("{}", h);
554 assert_eq!(s.len(), 64); assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
556 }
557
558 #[test]
559 fn test_content_hash_clone_eq() {
560 let h = ContentDeduplicationIndex::compute_hash(b"clone-me");
561 let h2 = h.clone();
562 assert_eq!(h, h2);
563 }
564
565 #[test]
568 fn test_insert_new_returns_new_variant() {
569 let mut idx = ContentDeduplicationIndex::new(default_config());
570 let result = idx.insert("k1".into(), b"some data here!!", 100).unwrap();
571 assert!(matches!(result, ContentDedupResult::New { .. }));
572 }
573
574 #[test]
575 fn test_insert_duplicate_returns_duplicate_variant() {
576 let mut idx = ContentDeduplicationIndex::new(default_config());
577 let data = make_data(7, 100);
578 idx.insert("k1".into(), &data, 100).unwrap();
579 let result = idx.insert("k2".into(), &data, 200).unwrap();
580 assert!(matches!(result, ContentDedupResult::Duplicate { .. }));
581 }
582
583 #[test]
584 fn test_duplicate_saved_bytes_matches_data_len() {
585 let mut idx = ContentDeduplicationIndex::new(default_config());
586 let data = make_data(1, 512);
587 idx.insert("k1".into(), &data, 100).unwrap();
588 let result = idx.insert("k2".into(), &data, 101).unwrap();
589 if let ContentDedupResult::Duplicate { saved_bytes, .. } = result {
590 assert_eq!(saved_bytes, 512);
591 } else {
592 panic!("expected Duplicate");
593 }
594 }
595
596 #[test]
597 fn test_duplicate_original_key_is_canonical() {
598 let mut idx = ContentDeduplicationIndex::new(default_config());
599 let data = make_data(2, 256);
600 idx.insert("canonical".into(), &data, 1).unwrap();
601 let result = idx.insert("alias".into(), &data, 2).unwrap();
602 if let ContentDedupResult::Duplicate { original_key, .. } = result {
603 assert_eq!(original_key, "canonical");
604 } else {
605 panic!("expected Duplicate");
606 }
607 }
608
609 #[test]
610 fn test_lookup_by_key_after_insert() {
611 let mut idx = ContentDeduplicationIndex::new(default_config());
612 let data = make_data(3, 128);
613 idx.insert("mykey".into(), &data, 10).unwrap();
614 let entry = idx.lookup_by_key("mykey").unwrap();
615 assert_eq!(entry.canonical_key, "mykey");
616 assert_eq!(entry.byte_size, 128);
617 }
618
619 #[test]
620 fn test_lookup_by_hash_after_insert() {
621 let mut idx = ContentDeduplicationIndex::new(default_config());
622 let data = make_data(4, 64);
623 let hash = ContentDeduplicationIndex::compute_hash(&data);
624 idx.insert("hkey".into(), &data, 5).unwrap();
625 assert!(idx.lookup_by_hash(&hash).is_some());
626 }
627
628 #[test]
629 fn test_lookup_by_key_missing_returns_none() {
630 let idx = ContentDeduplicationIndex::new(default_config());
631 assert!(idx.lookup_by_key("nonexistent").is_none());
632 }
633
634 #[test]
637 fn test_is_duplicate_false_before_insert() {
638 let idx = ContentDeduplicationIndex::new(default_config());
639 assert!(!idx.is_duplicate(b"some bytes that are long enough to matter!"));
640 }
641
642 #[test]
643 fn test_is_duplicate_true_after_insert() {
644 let mut idx = ContentDeduplicationIndex::new(default_config());
645 let data = make_data(5, 200);
646 idx.insert("k1".into(), &data, 1).unwrap();
647 assert!(idx.is_duplicate(&data));
648 }
649
650 #[test]
653 fn test_below_min_block_size_passthrough() {
654 let config = ContentDedupConfig {
655 min_block_size: 64,
656 ..default_config()
657 };
658 let mut idx = ContentDeduplicationIndex::new(config);
659 let result = idx.insert("tiny".into(), b"tooshort!!", 0).unwrap();
661 assert!(matches!(result, ContentDedupResult::New { .. }));
662 assert_eq!(idx.len(), 0, "tiny block must not be indexed");
663 }
664
665 #[test]
666 fn test_above_max_block_size_passthrough() {
667 let config = ContentDedupConfig {
668 max_block_size: 128,
669 min_block_size: 4,
670 ..default_config()
671 };
672 let mut idx = ContentDeduplicationIndex::new(config);
673 let big = make_data(9, 256);
674 let result = idx.insert("big".into(), &big, 0).unwrap();
675 assert!(matches!(result, ContentDedupResult::New { .. }));
676 assert_eq!(idx.len(), 0, "oversized block must not be indexed");
677 }
678
679 #[test]
680 fn test_passthrough_does_not_mark_duplicate() {
681 let config = ContentDedupConfig {
682 min_block_size: 64,
683 max_block_size: 1024,
684 ..default_config()
685 };
686 let mut idx = ContentDeduplicationIndex::new(config);
687 let tiny = b"abc";
688 idx.insert("a".into(), tiny, 0).unwrap();
689 let result = idx.insert("b".into(), tiny, 1).unwrap();
691 assert!(matches!(result, ContentDedupResult::New { .. }));
692 }
693
694 #[test]
697 fn test_remove_existing_key_returns_true() {
698 let mut idx = ContentDeduplicationIndex::new(default_config());
699 let data = make_data(6, 100);
700 idx.insert("rem".into(), &data, 0).unwrap();
701 assert!(idx.remove("rem"));
702 }
703
704 #[test]
705 fn test_remove_nonexistent_key_returns_false() {
706 let mut idx = ContentDeduplicationIndex::new(default_config());
707 assert!(!idx.remove("ghost"));
708 }
709
710 #[test]
711 fn test_remove_last_ref_deletes_entry() {
712 let mut idx = ContentDeduplicationIndex::new(default_config());
713 let data = make_data(7, 80);
714 idx.insert("only".into(), &data, 0).unwrap();
715 idx.remove("only");
716 assert!(idx.lookup_by_key("only").is_none());
717 assert_eq!(idx.len(), 0);
718 }
719
720 #[test]
721 fn test_remove_one_of_two_refs_keeps_entry() {
722 let mut idx = ContentDeduplicationIndex::new(default_config());
723 let data = make_data(8, 80);
724 idx.insert("a".into(), &data, 0).unwrap();
725 idx.insert("b".into(), &data, 1).unwrap();
726 idx.remove("b");
727 assert_eq!(idx.len(), 1);
729 }
730
731 #[test]
732 fn test_remove_decrements_ref_count() {
733 let config = ContentDedupConfig {
734 enable_ref_counting: true,
735 ..small_config(100)
736 };
737 let mut idx = ContentDeduplicationIndex::new(config);
738 let data = make_data(9, 80);
739 idx.insert("x1".into(), &data, 0).unwrap();
740 idx.insert("x2".into(), &data, 1).unwrap(); idx.remove("x2"); let entry = idx.lookup_by_key("x1").unwrap();
743 assert_eq!(entry.ref_count, 1);
744 }
745
746 #[test]
749 fn test_ref_counting_increments_on_duplicate() {
750 let config = ContentDedupConfig {
751 enable_ref_counting: true,
752 ..small_config(100)
753 };
754 let mut idx = ContentDeduplicationIndex::new(config);
755 let data = make_data(10, 80);
756 idx.insert("r1".into(), &data, 0).unwrap();
757 idx.insert("r2".into(), &data, 1).unwrap();
758 idx.insert("r3".into(), &data, 2).unwrap();
759 let entry = idx.lookup_by_key("r1").unwrap();
760 assert_eq!(entry.ref_count, 3);
761 }
762
763 #[test]
764 fn test_ref_counting_disabled_stays_at_one() {
765 let config = ContentDedupConfig {
766 enable_ref_counting: false,
767 ..small_config(100)
768 };
769 let mut idx = ContentDeduplicationIndex::new(config);
770 let data = make_data(11, 80);
771 idx.insert("d1".into(), &data, 0).unwrap();
772 idx.insert("d2".into(), &data, 1).unwrap();
773 let entry = idx.lookup_by_key("d1").unwrap();
774 assert_eq!(entry.ref_count, 1);
775 }
776
777 #[test]
780 fn test_eviction_at_capacity() {
781 let config = small_config(2);
782 let mut idx = ContentDeduplicationIndex::new(config);
783 idx.insert("e1".into(), &make_data(1, 10), 1).unwrap();
784 idx.insert("e2".into(), &make_data(2, 10), 2).unwrap();
785 idx.insert("e3".into(), &make_data(3, 10), 3).unwrap();
787 assert_eq!(
788 idx.len(),
789 2,
790 "index must stay at max_entries=2 after eviction"
791 );
792 }
793
794 #[test]
795 fn test_eviction_removes_lowest_ref_count() {
796 let config = ContentDedupConfig {
797 max_entries: 2,
798 min_block_size: 4,
799 max_block_size: 1024,
800 enable_ref_counting: true,
801 };
802 let mut idx = ContentDeduplicationIndex::new(config);
803 let data1 = make_data(20, 10);
804 let data2 = make_data(21, 10);
805 idx.insert("a".into(), &data1, 1).unwrap();
806 idx.insert("b".into(), &data2, 2).unwrap();
807 idx.insert("b_dup".into(), &data2, 3).unwrap();
809 let data3 = make_data(22, 10);
811 idx.insert("c".into(), &data3, 4).unwrap();
812 assert!(idx
814 .lookup_by_hash(&ContentDeduplicationIndex::compute_hash(&data2))
815 .is_some());
816 assert!(idx
817 .lookup_by_hash(&ContentDeduplicationIndex::compute_hash(&data3))
818 .is_some());
819 }
820
821 #[test]
824 fn test_merge_duplicates_returns_zero_when_no_dups() {
825 let mut idx = ContentDeduplicationIndex::new(default_config());
826 idx.insert("u1".into(), &make_data(30, 100), 1).unwrap();
827 idx.insert("u2".into(), &make_data(31, 100), 2).unwrap();
828 assert_eq!(idx.merge_duplicates(), 0);
829 }
830
831 #[test]
832 fn test_merge_duplicates_counts_duplicates() {
833 let mut idx = ContentDeduplicationIndex::new(default_config());
834 let data = make_data(32, 100);
835 idx.insert("orig".into(), &data, 1).unwrap();
836 idx.insert("dup1".into(), &data, 2).unwrap();
837 idx.insert("dup2".into(), &data, 3).unwrap();
838 assert_eq!(idx.merge_duplicates(), 2);
840 }
841
842 #[test]
843 fn test_deduplicated_keys_correct_pairs() {
844 let mut idx = ContentDeduplicationIndex::new(default_config());
845 let data = make_data(33, 100);
846 idx.insert("canon".into(), &data, 1).unwrap();
847 idx.insert("alias".into(), &data, 2).unwrap();
848 let pairs = idx.deduplicated_keys();
849 assert_eq!(pairs.len(), 1);
850 assert_eq!(pairs[0].0, "alias");
851 assert_eq!(pairs[0].1, "canon");
852 }
853
854 #[test]
855 fn test_deduplicated_keys_empty_when_all_unique() {
856 let mut idx = ContentDeduplicationIndex::new(default_config());
857 idx.insert("u1".into(), &make_data(40, 100), 1).unwrap();
858 idx.insert("u2".into(), &make_data(41, 100), 2).unwrap();
859 assert!(idx.deduplicated_keys().is_empty());
860 }
861
862 #[test]
865 fn test_stats_initial_zero() {
866 let idx = ContentDeduplicationIndex::new(default_config());
867 let s = idx.stats();
868 assert_eq!(s.total_entries, 0);
869 assert_eq!(s.total_keys, 0);
870 assert_eq!(s.total_saved_bytes, 0);
871 assert_eq!(s.total_insertions, 0);
872 assert_eq!(s.total_duplicates, 0);
873 assert_eq!(s.dedup_ratio, 0.0);
874 }
875
876 #[test]
877 fn test_stats_after_insert() {
878 let mut idx = ContentDeduplicationIndex::new(default_config());
879 idx.insert("s1".into(), &make_data(50, 128), 1).unwrap();
880 let s = idx.stats();
881 assert_eq!(s.total_entries, 1);
882 assert_eq!(s.total_keys, 1);
883 assert_eq!(s.total_insertions, 1);
884 assert_eq!(s.total_duplicates, 0);
885 }
886
887 #[test]
888 fn test_stats_total_saved_bytes_accumulates() {
889 let mut idx = ContentDeduplicationIndex::new(default_config());
890 let data = make_data(51, 200);
891 idx.insert("p1".into(), &data, 1).unwrap();
892 idx.insert("p2".into(), &data, 2).unwrap();
893 idx.insert("p3".into(), &data, 3).unwrap();
894 let s = idx.stats();
895 assert_eq!(s.total_saved_bytes, 400);
897 }
898
899 #[test]
900 fn test_stats_dedup_ratio_calculation() {
901 let mut idx = ContentDeduplicationIndex::new(default_config());
902 let data = make_data(52, 200);
903 idx.insert("q1".into(), &data, 1).unwrap();
904 idx.insert("q2".into(), &data, 2).unwrap(); let s = idx.stats();
906 let expected = 1.0 / 2.0;
907 assert!((s.dedup_ratio - expected).abs() < 1e-10);
908 }
909
910 #[test]
911 fn test_stats_dedup_ratio_zero_insertions() {
912 let idx = ContentDeduplicationIndex::new(default_config());
913 let s = idx.stats();
914 assert_eq!(s.dedup_ratio, 0.0);
916 }
917
918 #[test]
921 fn test_is_empty_on_new_index() {
922 let idx = ContentDeduplicationIndex::new(default_config());
923 assert!(idx.is_empty());
924 }
925
926 #[test]
927 fn test_len_after_inserts() {
928 let mut idx = ContentDeduplicationIndex::new(default_config());
929 idx.insert("L1".into(), &make_data(60, 100), 1).unwrap();
930 idx.insert("L2".into(), &make_data(61, 100), 2).unwrap();
931 assert_eq!(idx.len(), 2);
932 }
933
934 #[test]
935 fn test_key_count_includes_duplicates() {
936 let mut idx = ContentDeduplicationIndex::new(default_config());
937 let data = make_data(62, 100);
938 idx.insert("K1".into(), &data, 1).unwrap();
939 idx.insert("K2".into(), &data, 2).unwrap();
940 assert_eq!(idx.len(), 1);
942 assert_eq!(idx.key_count(), 2);
943 }
944
945 #[test]
948 fn test_error_display_key_already_exists() {
949 let e = DedupIndexError::KeyAlreadyExists("foo".into());
950 assert!(e.to_string().contains("foo"));
951 }
952
953 #[test]
954 fn test_error_display_entry_not_found() {
955 let e = DedupIndexError::EntryNotFound("bar".into());
956 assert!(e.to_string().contains("bar"));
957 }
958
959 #[test]
960 fn test_error_display_invalid_size() {
961 let e = DedupIndexError::InvalidSize {
962 size: 10,
963 min: 64,
964 max: 1024,
965 };
966 let s = e.to_string();
967 assert!(s.contains("10"));
968 assert!(s.contains("64"));
969 assert!(s.contains("1024"));
970 }
971
972 #[test]
975 fn test_last_accessed_updates_on_duplicate() {
976 let mut idx = ContentDeduplicationIndex::new(default_config());
977 let data = make_data(70, 100);
978 idx.insert("t1".into(), &data, 1000).unwrap();
979 idx.insert("t2".into(), &data, 2000).unwrap();
980 let entry = idx.lookup_by_key("t1").unwrap();
981 assert_eq!(entry.last_accessed, 2000);
982 }
983
984 #[test]
985 fn test_first_seen_preserved_on_duplicate() {
986 let mut idx = ContentDeduplicationIndex::new(default_config());
987 let data = make_data(71, 100);
988 idx.insert("fs1".into(), &data, 500).unwrap();
989 idx.insert("fs2".into(), &data, 600).unwrap();
990 let entry = idx.lookup_by_key("fs1").unwrap();
991 assert_eq!(entry.first_seen, 500);
992 }
993}