Skip to main content

ipfrs_storage/
content_dedup_index.rs

1//! Content deduplication index for tracking and merging identical content blocks.
2//!
3//! This module provides a hash-based content deduplication index that:
4//! - Tracks identical content blocks via 32-byte approximate hashes
5//! - Manages reference counts per unique content block
6//! - Reclaims storage by detecting and merging duplicates
7//! - Supports eviction when the index is full
8//!
9//! # Hash Implementation
10//!
11//! The 32-byte `ContentHash` is composed of four 8-byte segments:
12//! - Bytes  0– 7: FNV-1a 64-bit hash of the data
13//! - Bytes  8–15: DJB2 64-bit xorshifted hash of the data
14//! - Bytes 16–23: data length as little-endian u64
15//! - Bytes 24–31: FNV-1a 64-bit hash of the reversed data
16//!
17//! This is a fast approximate hash, not a cryptographic hash.
18
19use std::collections::HashMap;
20use thiserror::Error;
21
22// ─────────────────────────────────────────────────────────────────────────────
23// Errors
24// ─────────────────────────────────────────────────────────────────────────────
25
26/// Errors that can arise when operating on a [`ContentDeduplicationIndex`].
27#[derive(Debug, Error, PartialEq, Eq)]
28pub enum DedupIndexError {
29    /// The given key already exists in the index.
30    #[error("key already exists: {0}")]
31    KeyAlreadyExists(String),
32
33    /// No entry was found for the given key or hash.
34    #[error("entry not found: {0}")]
35    EntryNotFound(String),
36
37    /// The data size is outside the configured range.
38    #[error("invalid size {size}: must be in [{min}, {max}]")]
39    InvalidSize {
40        /// Actual size of the data.
41        size: usize,
42        /// Configured minimum block size.
43        min: usize,
44        /// Configured maximum block size.
45        max: usize,
46    },
47}
48
49// ─────────────────────────────────────────────────────────────────────────────
50// ContentHash
51// ─────────────────────────────────────────────────────────────────────────────
52
53/// A 32-byte approximate hash used to identify content blocks.
54///
55/// Not cryptographic — uses FNV-1a and DJB2 mixing for speed.
56#[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// ─────────────────────────────────────────────────────────────────────────────
79// Hash primitives
80// ─────────────────────────────────────────────────────────────────────────────
81
82/// FNV-1a 64-bit hash.
83#[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/// DJB2 64-bit hash (xorshift variant).
92#[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// ─────────────────────────────────────────────────────────────────────────────
100// DedupEntry
101// ─────────────────────────────────────────────────────────────────────────────
102
103/// A single entry in the deduplication index.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct DedupEntry {
106    /// Hash of the content.
107    pub hash: ContentHash,
108    /// The canonical storage key for this content.
109    pub canonical_key: String,
110    /// Number of keys that currently reference this content.
111    pub ref_count: u32,
112    /// Size of the content in bytes.
113    pub byte_size: u64,
114    /// Unix timestamp (seconds) when this entry was first seen.
115    pub first_seen: u64,
116    /// Unix timestamp (seconds) of the most recent access.
117    pub last_accessed: u64,
118}
119
120// ─────────────────────────────────────────────────────────────────────────────
121// DedupResult
122// ─────────────────────────────────────────────────────────────────────────────
123
124/// The outcome of an [`ContentDeduplicationIndex::insert`] call.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ContentDedupResult {
127    /// Content is new — no prior entry existed for this hash.
128    New {
129        /// The key that was inserted.
130        key: String,
131        /// Hash computed for the inserted content.
132        hash: ContentHash,
133    },
134    /// Content already existed — this insertion is a duplicate.
135    Duplicate {
136        /// The canonical key of the pre-existing entry.
137        original_key: String,
138        /// Bytes saved by not storing this content again.
139        saved_bytes: u64,
140    },
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// DedupConfig
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Configuration for [`ContentDeduplicationIndex`].
148#[derive(Debug, Clone)]
149pub struct ContentDedupConfig {
150    /// Maximum number of unique content entries held in the index.
151    pub max_entries: usize,
152    /// Minimum content size (in bytes) eligible for deduplication.
153    /// Blocks smaller than this are passed through as `New` without indexing.
154    pub min_block_size: usize,
155    /// Maximum content size (in bytes) eligible for deduplication.
156    /// Blocks larger than this are passed through as `New` without indexing.
157    pub max_block_size: usize,
158    /// When `true`, each duplicate insertion increments `ref_count`.
159    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, // 64 MiB
168            enable_ref_counting: true,
169        }
170    }
171}
172
173// ─────────────────────────────────────────────────────────────────────────────
174// DedupStats
175// ─────────────────────────────────────────────────────────────────────────────
176
177/// Aggregated statistics for a [`ContentDeduplicationIndex`].
178#[derive(Debug, Clone, PartialEq)]
179pub struct ContentDedupStats {
180    /// Number of unique content entries currently in the index.
181    pub total_entries: usize,
182    /// Total number of key→hash mappings (includes duplicates).
183    pub total_keys: usize,
184    /// Total bytes saved by deduplication across all duplicate insertions.
185    pub total_saved_bytes: u64,
186    /// Total number of insert calls (including out-of-range pass-throughs).
187    pub total_insertions: u64,
188    /// Number of insert calls that resolved to a duplicate.
189    pub total_duplicates: u64,
190    /// Ratio of duplicates to total insertions (`total_duplicates / max(1, total_insertions)`).
191    pub dedup_ratio: f64,
192}
193
194// ─────────────────────────────────────────────────────────────────────────────
195// ContentDeduplicationIndex
196// ─────────────────────────────────────────────────────────────────────────────
197
198/// Hash-based content deduplication index.
199///
200/// Tracks identical content blocks, reference counts, and can reclaim storage
201/// by merging duplicates.
202///
203/// # Example
204///
205/// ```rust
206/// use ipfrs_storage::content_dedup_index::{
207///     ContentDeduplicationIndex, ContentDedupConfig, ContentDedupResult,
208/// };
209///
210/// let config = ContentDedupConfig::default();
211/// let mut idx = ContentDeduplicationIndex::new(config);
212///
213/// let data = b"hello, world!";
214/// let result = idx.insert("key1".into(), data, 1000).unwrap();
215/// assert!(matches!(result, ContentDedupResult::New { .. }));
216///
217/// // Insert the same content under a different key — it's a duplicate
218/// let result2 = idx.insert("key2".into(), data, 1001).unwrap();
219/// assert!(matches!(result2, ContentDedupResult::Duplicate { .. }));
220/// ```
221#[derive(Debug)]
222pub struct ContentDeduplicationIndex {
223    /// Runtime configuration.
224    pub config: ContentDedupConfig,
225    /// Map from content hash to dedup entry.
226    entries: HashMap<ContentHash, DedupEntry>,
227    /// Map from storage key to content hash.
228    key_to_hash: HashMap<String, ContentHash>,
229    /// Cumulative bytes saved by deduplication.
230    total_saved_bytes: u64,
231    /// Total number of insert calls.
232    total_insertions: u64,
233    /// Number of insert calls that were duplicates.
234    total_duplicates: u64,
235}
236
237impl ContentDeduplicationIndex {
238    /// Create a new index with the supplied configuration.
239    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    /// Compute the 32-byte approximate `ContentHash` for `data`.
251    ///
252    /// Layout:
253    /// - Bytes  0– 7: FNV-1a 64-bit
254    /// - Bytes  8–15: DJB2 64-bit xorshifted
255    /// - Bytes 16–23: `data.len()` as little-endian u64
256    /// - Bytes 24–31: FNV-1a 64-bit of reversed data
257    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        // Compute FNV-1a over data traversed in reverse without allocating.
263        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    /// Insert `data` under `key` into the deduplication index.
275    ///
276    /// Returns:
277    /// - `Ok(ContentDedupResult::New)` when the content has not been seen before.
278    /// - `Ok(ContentDedupResult::Duplicate)` when identical content already exists.
279    ///
280    /// Blocks whose size falls outside `[min_block_size, max_block_size]` are
281    /// returned as `New` without modifying the index (pass-through behaviour).
282    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        // Pass-through for out-of-range blocks — don't index them.
293        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        // Check whether this hash is already known.
298        if let Some(entry) = self.entries.get_mut(&hash) {
299            // Duplicate found.
300            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        // New content — evict if at capacity.
318        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    /// Remove the key from the index.
337    ///
338    /// Decrements the reference count of the associated entry; if the count
339    /// reaches zero and no other key points to the same hash, the entry is
340    /// removed entirely.
341    ///
342    /// Returns `true` if `key` was present, `false` otherwise.
343    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        // Count remaining keys that still point to this hash.
350        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            // Remove the entry only when no keys (including the one we just
357            // removed) still reference it.
358            if remaining_refs == 0 {
359                self.entries.remove(&hash);
360            }
361        }
362
363        true
364    }
365
366    /// Look up a dedup entry by its storage key.
367    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    /// Look up a dedup entry directly by its content hash.
373    pub fn lookup_by_hash(&self, hash: &ContentHash) -> Option<&DedupEntry> {
374        self.entries.get(hash)
375    }
376
377    /// Return `true` if `data` has been seen before (i.e., a matching hash
378    /// exists in the index and the block size is within range).
379    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    /// Scan `key_to_hash` and count keys whose hash is already represented by
388    /// a canonical key that differs from themselves.  Returns the total number
389    /// of duplicate key→hash mappings merged (conceptually deduplicated).
390    ///
391    /// This method does not remove any data; it reports how many duplicate
392    /// entries currently exist and is idempotent.
393    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    /// Return all `(duplicate_key, canonical_key)` pairs where the duplicate
406    /// key differs from the canonical key.
407    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    /// Return a snapshot of index statistics.
421    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    /// Return the number of unique content hashes in the index.
436    pub fn len(&self) -> usize {
437        self.entries.len()
438    }
439
440    /// Return `true` when the index contains no entries.
441    pub fn is_empty(&self) -> bool {
442        self.entries.is_empty()
443    }
444
445    /// Return the number of key→hash mappings (including duplicates).
446    pub fn key_count(&self) -> usize {
447        self.key_to_hash.len()
448    }
449
450    // ─────────────────────────────────────────────────────────────────────────
451    // Private helpers
452    // ─────────────────────────────────────────────────────────────────────────
453
454    /// Evict the entry with the smallest ref_count, breaking ties by choosing
455    /// the oldest entry (`first_seen`).
456    fn evict_one(&mut self) {
457        // Find the candidate hash.
458        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            // Remove all key→hash mappings that pointed to the evicted entry.
471            self.key_to_hash.retain(|_, h| *h != victim_hash);
472        }
473    }
474}
475
476// ─────────────────────────────────────────────────────────────────────────────
477// Tests
478// ─────────────────────────────────────────────────────────────────────────────
479
480#[cfg(test)]
481mod tests {
482    use crate::content_dedup_index::{
483        ContentDedupConfig, ContentDedupResult, ContentDeduplicationIndex, ContentHash,
484        DedupIndexError,
485    };
486
487    // ── helpers ──────────────────────────────────────────────────────────────
488
489    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    // ── ContentHash tests ─────────────────────────────────────────────────────
507
508    #[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); // 32 bytes * 2 hex chars
555        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    // ── Basic insert/lookup tests ─────────────────────────────────────────────
566
567    #[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    // ── is_duplicate tests ────────────────────────────────────────────────────
635
636    #[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    // ── out-of-range pass-through tests ──────────────────────────────────────
651
652    #[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        // data.len() = 10 < 64
660        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        // Still not a duplicate because it was never indexed.
690        let result = idx.insert("b".into(), tiny, 1).unwrap();
691        assert!(matches!(result, ContentDedupResult::New { .. }));
692    }
693
694    // ── remove tests ─────────────────────────────────────────────────────────
695
696    #[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        // Entry still present because "a" still holds a reference.
728        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(); // ref_count = 2
741        idx.remove("x2"); // ref_count should drop to 1
742        let entry = idx.lookup_by_key("x1").unwrap();
743        assert_eq!(entry.ref_count, 1);
744    }
745
746    // ── ref counting tests ────────────────────────────────────────────────────
747
748    #[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    // ── eviction tests ────────────────────────────────────────────────────────
778
779    #[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        // Inserting a third unique entry triggers eviction.
786        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        // Boost ref_count for data2.
808        idx.insert("b_dup".into(), &data2, 3).unwrap();
809        // data1 has ref_count=1, data2 has ref_count=2 — next unique evicts data1.
810        let data3 = make_data(22, 10);
811        idx.insert("c".into(), &data3, 4).unwrap();
812        // data2 and data3 should survive; data1 was evicted.
813        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    // ── merge_duplicates / deduplicated_keys tests ───────────────────────────
822
823    #[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        // "dup1" and "dup2" differ from canonical "orig"
839        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    // ── stats tests ───────────────────────────────────────────────────────────
863
864    #[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        // Two duplicate insertions each save 200 bytes.
896        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(); // 1 duplicate / 2 insertions
905        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        // dedup_ratio = 0 / max(1,0) = 0
915        assert_eq!(s.dedup_ratio, 0.0);
916    }
917
918    // ── is_empty / len / key_count ────────────────────────────────────────────
919
920    #[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        // 1 unique entry, but 2 keys.
941        assert_eq!(idx.len(), 1);
942        assert_eq!(idx.key_count(), 2);
943    }
944
945    // ── DedupIndexError tests ─────────────────────────────────────────────────
946
947    #[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    // ── last_accessed timestamp test ──────────────────────────────────────────
973
974    #[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}