Skip to main content

ipfrs_storage/
block_deduplicator.rs

1//! Content-aware block deduplication with variable-length chunking.
2//!
3//! Implements Content-Defined Chunking (CDC) using a Rabin-fingerprint-style
4//! rolling hash based on FNV-1a. Chunk boundaries are detected when the rolling
5//! hash of a sliding window satisfies `hash & mask == 0`, producing statistically
6//! independent chunk boundaries that average `2^target_bits` bytes apart.
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌──────────────┐     chunk_data()      ┌─────────────────────────────┐
12//! │  Raw Object  │ ──────────────────>   │  Vec<(ChunkHash, Vec<u8>)>  │
13//! └──────────────┘                       └─────────────────────────────┘
14//!        │                                          │
15//!        │  store_object()                          │ dedup lookup
16//!        ▼                                          ▼
17//! ┌──────────────────┐              ┌──────────────────────────────────┐
18//! │  ObjectManifest  │              │  chunk_store: HashMap<ChunkHash, │
19//! │  Vec<ChunkRef>   │              │    (Chunk, Vec<u8>)>             │
20//! └──────────────────┘              └──────────────────────────────────┘
21//! ```
22//!
23//! # Example
24//!
25//! ```rust
26//! use ipfrs_storage::block_deduplicator::{BlockDeduplicator, ChunkingConfig};
27//!
28//! let config = ChunkingConfig::default();
29//! let mut dedup = BlockDeduplicator::new(config);
30//!
31//! let data = b"Hello, World! This is some test data for deduplication.".to_vec();
32//! let manifest = dedup.store_object("obj1".to_string(), data.clone()).unwrap();
33//! let retrieved = dedup.retrieve_object("obj1").unwrap();
34//! assert_eq!(data, retrieved);
35//! ```
36
37use std::collections::HashMap;
38use std::fmt;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41// ─── FNV-1a helpers ──────────────────────────────────────────────────────────
42
43/// FNV-1a 64-bit hash — fast, non-cryptographic, excellent for CDC boundaries.
44///
45/// Uses the standard FNV prime and offset basis:
46/// - Offset basis: 14695981039346656037
47/// - Prime: 1099511628211
48#[inline]
49pub fn fnv1a_64(data: &[u8]) -> u64 {
50    let mut h: u64 = 14695981039346656037;
51    for &b in data {
52        h ^= b as u64;
53        h = h.wrapping_mul(1099511628211);
54    }
55    h
56}
57
58/// Rolling hash for CDC: compute hash over a sliding window of bytes.
59/// Uses FNV-1a as the window function — cheap to compute, good distribution.
60#[inline]
61fn rolling_hash(window: &[u8]) -> u64 {
62    fnv1a_64(window)
63}
64
65/// Current wall-clock time in seconds since UNIX epoch.
66/// Falls back to 0 if the system clock is unavailable.
67fn unix_timestamp() -> u64 {
68    SystemTime::now()
69        .duration_since(UNIX_EPOCH)
70        .map(|d| d.as_secs())
71        .unwrap_or(0)
72}
73
74// ─── ChunkHash ───────────────────────────────────────────────────────────────
75
76/// A compact 8-byte content hash derived from FNV-1a over chunk data.
77///
78/// Stored as little-endian bytes. Displayed as 16 lowercase hex characters.
79#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)]
80pub struct ChunkHash([u8; 8]);
81
82impl ChunkHash {
83    /// Create a `ChunkHash` directly from raw bytes.
84    #[inline]
85    pub fn from_bytes(bytes: [u8; 8]) -> Self {
86        Self(bytes)
87    }
88
89    /// Access the raw bytes.
90    #[inline]
91    pub fn as_bytes(&self) -> &[u8; 8] {
92        &self.0
93    }
94
95    /// Convert to `u64` (little-endian interpretation).
96    #[inline]
97    pub fn to_u64(self) -> u64 {
98        u64::from_le_bytes(self.0)
99    }
100}
101
102impl From<u64> for ChunkHash {
103    #[inline]
104    fn from(v: u64) -> Self {
105        Self(v.to_le_bytes())
106    }
107}
108
109impl fmt::Display for ChunkHash {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        for b in &self.0 {
112            write!(f, "{:02x}", b)?;
113        }
114        Ok(())
115    }
116}
117
118// ─── Chunk ───────────────────────────────────────────────────────────────────
119
120/// Metadata record for a single stored chunk.
121///
122/// Note: in `lib.rs` this is re-exported as `BddChunk` to avoid collision
123/// with `chunk_manager::Chunk`.
124#[derive(Debug, Clone)]
125pub struct Chunk {
126    /// Content-defined hash of this chunk's raw bytes.
127    pub hash: ChunkHash,
128    /// Byte offset of this chunk's *first occurrence* in the original object.
129    pub offset: u64,
130    /// Length in bytes of the raw (uncompressed) chunk data.
131    pub length: usize,
132    /// Number of live references to this chunk across all stored objects.
133    pub ref_count: u32,
134    /// Whether the stored bytes in the chunk store are compressed.
135    pub compressed: bool,
136}
137
138// ─── DeduplicationStats ──────────────────────────────────────────────────────
139
140/// Aggregate statistics for the [`BlockDeduplicator`].
141///
142/// Note: re-exported as `BddDeduplicationStats` in `lib.rs`.
143#[derive(Debug, Clone)]
144pub struct DeduplicationStats {
145    /// Total logical chunk references across all live objects (including duplicates).
146    pub total_chunks: u64,
147    /// Number of physically distinct chunks stored on disk.
148    pub unique_chunks: u64,
149    /// Logical references that point to already-stored chunks (`total - unique`).
150    pub duplicate_chunks: u64,
151    /// Total raw bytes ingested via `store_object` calls.
152    pub bytes_before: u64,
153    /// Physical bytes currently occupying the chunk store.
154    pub bytes_after: u64,
155    /// Fraction of bytes saved by deduplication: `1 - (bytes_after / bytes_before)`.
156    /// Range `[0.0, 1.0]`; higher is better.
157    pub dedup_ratio: f64,
158    /// Ratio `bytes_before / bytes_after`. Values > 1.0 mean space was saved.
159    pub compression_ratio: f64,
160}
161
162// ─── ChunkingConfig ──────────────────────────────────────────────────────────
163
164/// Configuration for the Content-Defined Chunking (CDC) algorithm.
165///
166/// Note: re-exported as `BddChunkingConfig` in `lib.rs` to avoid collision
167/// with `dedup::ChunkingConfig`.
168#[derive(Debug, Clone)]
169pub struct ChunkingConfig {
170    /// Minimum chunk size in bytes. Boundaries are not checked below this threshold.
171    pub min_chunk_size: usize,
172    /// Maximum chunk size in bytes. A boundary is forced if the current chunk
173    /// reaches this length without a natural hash-based split.
174    pub max_chunk_size: usize,
175    /// Controls the average target chunk size: avg ≈ `2^target_bits` bytes.
176    /// Default 13 → ~8 KiB average.
177    pub target_bits: u8,
178    /// Sliding window width used for rolling hash computation.
179    pub window_size: usize,
180    /// Whether to attempt compression on stored chunk data (currently marks metadata only).
181    pub enable_compression: bool,
182}
183
184impl Default for ChunkingConfig {
185    fn default() -> Self {
186        Self {
187            min_chunk_size: 2048,
188            max_chunk_size: 65536,
189            target_bits: 13,
190            window_size: 48,
191            enable_compression: false,
192        }
193    }
194}
195
196impl ChunkingConfig {
197    /// Returns the rolling-hash boundary mask: `(1 << target_bits) - 1`.
198    #[inline]
199    pub fn boundary_mask(&self) -> u64 {
200        (1u64 << self.target_bits).wrapping_sub(1)
201    }
202}
203
204// ─── ChunkRef ────────────────────────────────────────────────────────────────
205
206/// Reference to a chunk within an object manifest.
207///
208/// Describes one logical slice of the original object, pointing to the
209/// physical chunk in the chunk store by hash.
210#[derive(Debug, Clone)]
211pub struct ChunkRef {
212    /// Hash of the referenced chunk.
213    pub hash: ChunkHash,
214    /// Byte offset of this chunk's data within the reconstructed object.
215    pub offset_in_object: u64,
216    /// Length of this chunk's data in bytes.
217    pub chunk_length: usize,
218}
219
220// ─── ObjectManifest ──────────────────────────────────────────────────────────
221
222/// Ordered list of chunk references that fully describe a stored object.
223///
224/// Reassembling all chunks in order reconstructs the original byte sequence.
225#[derive(Debug, Clone)]
226pub struct ObjectManifest {
227    /// Unique identifier for this object.
228    pub object_id: String,
229    /// Total uncompressed size of the object in bytes.
230    pub total_size: u64,
231    /// Ordered sequence of chunk references.
232    pub chunks: Vec<ChunkRef>,
233    /// UNIX timestamp (seconds) when this manifest was created.
234    pub created_at: u64,
235}
236
237// ─── DeduplicatorError ───────────────────────────────────────────────────────
238
239/// Errors that may arise from [`BlockDeduplicator`] operations.
240#[derive(Debug, thiserror::Error)]
241pub enum DeduplicatorError {
242    /// The requested object ID does not exist in the manifest store.
243    #[error("object not found: {0}")]
244    ObjectNotFound(String),
245
246    /// The chunk identified by the given hash is not present in the store.
247    #[error("chunk not found: {0}")]
248    ChunkNotFound(ChunkHash),
249
250    /// A compression or decompression operation failed.
251    #[error("compression failed: {0}")]
252    CompressionFailed(String),
253
254    /// The manifest data is structurally invalid or inconsistent.
255    #[error("invalid manifest: {0}")]
256    InvalidManifest(String),
257
258    /// The chunk store has reached its capacity limit.
259    #[error("storage full")]
260    StorageFull,
261}
262
263// ─── BlockDeduplicator ───────────────────────────────────────────────────────
264
265/// Content-aware block deduplicator using variable-length CDC chunking.
266///
267/// Stores objects by splitting them into content-defined chunks, using the
268/// chunk hash as the storage key. Identical chunks are stored only once and
269/// reference-counted. When all objects referencing a chunk are deleted, the
270/// chunk data is freed.
271///
272/// # Algorithm
273///
274/// Content-Defined Chunking (CDC) uses a rolling hash over a sliding window:
275/// - For each byte position after `min_chunk_size` into the current chunk,
276///   compute `rolling_hash(data[pos-W..pos])` where `W = window_size`.
277/// - If `hash & mask == 0` (where `mask = 2^target_bits - 1`), emit a boundary.
278/// - Also emit a boundary when `chunk_length >= max_chunk_size`.
279/// - The final remaining bytes are always emitted as the last chunk.
280///
281/// # Thread safety
282///
283/// `BlockDeduplicator` is not `Sync`. Use external locking (e.g. `Mutex`) when
284/// sharing across threads.
285pub struct BlockDeduplicator {
286    config: ChunkingConfig,
287    /// Primary storage: maps chunk hash → (metadata, raw bytes).
288    chunk_store: HashMap<ChunkHash, (Chunk, Vec<u8>)>,
289    /// Object manifests: maps object ID → ordered list of chunk refs.
290    manifests: HashMap<String, ObjectManifest>,
291    /// Cumulative raw bytes passed to `store_object` (denominator for dedup ratio).
292    total_bytes_stored: u64,
293    /// Bytes that were deduplicated (i.e., stored in already-present chunks).
294    total_bytes_deduplicated: u64,
295}
296
297impl BlockDeduplicator {
298    /// Create a new `BlockDeduplicator` with the given configuration.
299    pub fn new(config: ChunkingConfig) -> Self {
300        Self {
301            config,
302            chunk_store: HashMap::new(),
303            manifests: HashMap::new(),
304            total_bytes_stored: 0,
305            total_bytes_deduplicated: 0,
306        }
307    }
308
309    /// Create a `BlockDeduplicator` with default configuration.
310    pub fn with_defaults() -> Self {
311        Self::new(ChunkingConfig::default())
312    }
313
314    /// Split `data` into content-defined chunks using the rolling-hash CDC algorithm.
315    ///
316    /// Returns an ordered list of `(hash, chunk_bytes)` pairs. The concatenation
317    /// of all `chunk_bytes` is guaranteed to equal `data` exactly.
318    ///
319    /// # Edge cases
320    /// - Empty input → returns an empty `Vec`.
321    /// - Input shorter than `min_chunk_size` → returns a single chunk.
322    /// - Input shorter than `window_size` → rolling hash uses the full available prefix.
323    pub fn chunk_data(&self, data: &[u8]) -> Vec<(ChunkHash, Vec<u8>)> {
324        if data.is_empty() {
325            return Vec::new();
326        }
327
328        let min_sz = self.config.min_chunk_size;
329        let max_sz = self.config.max_chunk_size;
330        let win = self.config.window_size;
331        let mask = self.config.boundary_mask();
332
333        let mut result = Vec::new();
334        let mut chunk_start = 0usize;
335
336        let mut pos = 0usize;
337        while pos < data.len() {
338            let chunk_len = pos - chunk_start;
339
340            // We need at least min_chunk_size bytes before we check for a boundary.
341            if chunk_len >= min_sz {
342                // Determine the window: use min(win, pos) bytes ending at `pos`.
343                let win_start = pos.saturating_sub(win);
344                let window = &data[win_start..pos];
345                let h = rolling_hash(window);
346
347                let is_hash_boundary = (h & mask) == 0;
348                let is_max_boundary = chunk_len >= max_sz;
349
350                if is_hash_boundary || is_max_boundary {
351                    // Emit chunk [chunk_start..pos]
352                    let chunk_bytes = data[chunk_start..pos].to_vec();
353                    let hash = ChunkHash::from(fnv1a_64(&chunk_bytes));
354                    result.push((hash, chunk_bytes));
355                    chunk_start = pos;
356                }
357            }
358
359            pos += 1;
360        }
361
362        // Emit the final (possibly only) chunk — always non-empty since data is non-empty.
363        if chunk_start < data.len() {
364            let chunk_bytes = data[chunk_start..].to_vec();
365            let hash = ChunkHash::from(fnv1a_64(&chunk_bytes));
366            result.push((hash, chunk_bytes));
367        }
368
369        result
370    }
371
372    /// Store an object, deduplicating its content-defined chunks.
373    ///
374    /// If an identical chunk (same hash) already exists, its `ref_count` is
375    /// incremented and the duplicate bytes are not stored again. A manifest is
376    /// built and stored internally.
377    ///
378    /// # Errors
379    ///
380    /// Currently infallible, but returns `Result` for future extensibility
381    /// (e.g., when storage-full limits are enforced).
382    pub fn store_object(
383        &mut self,
384        object_id: String,
385        data: Vec<u8>,
386    ) -> Result<ObjectManifest, DeduplicatorError> {
387        let total_size = data.len() as u64;
388        self.total_bytes_stored += total_size;
389
390        let chunks = self.chunk_data(&data);
391        let mut chunk_refs = Vec::with_capacity(chunks.len());
392        let mut offset_in_object: u64 = 0;
393        let mut chunk_offset_in_store: u64 = 0;
394
395        for (hash, chunk_bytes) in chunks {
396            let chunk_len = chunk_bytes.len();
397
398            if let Some((existing, _)) = self.chunk_store.get_mut(&hash) {
399                // Duplicate: increment reference count.
400                existing.ref_count = existing.ref_count.saturating_add(1);
401                self.total_bytes_deduplicated += chunk_len as u64;
402            } else {
403                // New unique chunk: store it.
404                let chunk = Chunk {
405                    hash,
406                    offset: chunk_offset_in_store,
407                    length: chunk_len,
408                    ref_count: 1,
409                    compressed: false,
410                };
411                self.chunk_store.insert(hash, (chunk, chunk_bytes));
412                chunk_offset_in_store += chunk_len as u64;
413            }
414
415            chunk_refs.push(ChunkRef {
416                hash,
417                offset_in_object,
418                chunk_length: chunk_len,
419            });
420
421            offset_in_object += chunk_len as u64;
422        }
423
424        let manifest = ObjectManifest {
425            object_id: object_id.clone(),
426            total_size,
427            chunks: chunk_refs,
428            created_at: unix_timestamp(),
429        };
430
431        self.manifests.insert(object_id, manifest.clone());
432        Ok(manifest)
433    }
434
435    /// Retrieve the full byte content of a stored object by reconstructing
436    /// it from its chunk manifest.
437    ///
438    /// # Errors
439    ///
440    /// - [`DeduplicatorError::ObjectNotFound`] if `object_id` has no manifest.
441    /// - [`DeduplicatorError::ChunkNotFound`] if a chunk referenced by the manifest
442    ///   is missing from the store (indicates data corruption).
443    /// - [`DeduplicatorError::InvalidManifest`] if the reconstructed size does not
444    ///   match the manifest's declared `total_size`.
445    pub fn retrieve_object(&self, object_id: &str) -> Result<Vec<u8>, DeduplicatorError> {
446        let manifest = self
447            .manifests
448            .get(object_id)
449            .ok_or_else(|| DeduplicatorError::ObjectNotFound(object_id.to_string()))?;
450
451        let mut result = Vec::with_capacity(manifest.total_size as usize);
452
453        for chunk_ref in &manifest.chunks {
454            let (_, data) = self
455                .chunk_store
456                .get(&chunk_ref.hash)
457                .ok_or(DeduplicatorError::ChunkNotFound(chunk_ref.hash))?;
458            result.extend_from_slice(data);
459        }
460
461        if result.len() as u64 != manifest.total_size {
462            return Err(DeduplicatorError::InvalidManifest(format!(
463                "object '{}': expected {} bytes, reconstructed {} bytes",
464                object_id,
465                manifest.total_size,
466                result.len()
467            )));
468        }
469
470        Ok(result)
471    }
472
473    /// Delete a stored object, decrementing ref counts of its chunks.
474    ///
475    /// Any chunk whose `ref_count` reaches zero is removed from the store.
476    /// Returns the hashes of all physically removed chunks.
477    ///
478    /// # Errors
479    ///
480    /// - [`DeduplicatorError::ObjectNotFound`] if `object_id` has no manifest.
481    pub fn delete_object(&mut self, object_id: &str) -> Result<Vec<ChunkHash>, DeduplicatorError> {
482        let manifest = self
483            .manifests
484            .remove(object_id)
485            .ok_or_else(|| DeduplicatorError::ObjectNotFound(object_id.to_string()))?;
486
487        let mut removed = Vec::new();
488
489        for chunk_ref in &manifest.chunks {
490            if let Some((meta, _)) = self.chunk_store.get_mut(&chunk_ref.hash) {
491                meta.ref_count = meta.ref_count.saturating_sub(1);
492                if meta.ref_count == 0 {
493                    self.chunk_store.remove(&chunk_ref.hash);
494                    removed.push(chunk_ref.hash);
495                }
496            }
497        }
498
499        Ok(removed)
500    }
501
502    /// Return a reference to the `Chunk` metadata for the given hash.
503    ///
504    /// # Errors
505    ///
506    /// - [`DeduplicatorError::ChunkNotFound`] if no chunk with that hash exists.
507    pub fn get_chunk(&self, hash: &ChunkHash) -> Result<&Chunk, DeduplicatorError> {
508        self.chunk_store
509            .get(hash)
510            .map(|(meta, _)| meta)
511            .ok_or(DeduplicatorError::ChunkNotFound(*hash))
512    }
513
514    /// Return `true` if a chunk with the given hash is present in the store.
515    pub fn chunk_exists(&self, hash: &ChunkHash) -> bool {
516        self.chunk_store.contains_key(hash)
517    }
518
519    /// Remove all chunks whose `ref_count == 0` from the store.
520    ///
521    /// This is a garbage-collection pass for chunks that were decremented to
522    /// zero during earlier `delete_object` calls but not yet physically removed
523    /// (e.g., if `delete_object` was called but removal was deferred). In the
524    /// current implementation, `delete_object` removes zero-ref chunks eagerly,
525    /// so this acts as a safety pass.
526    ///
527    /// Returns the number of chunks removed.
528    pub fn compact(&mut self) -> usize {
529        let before = self.chunk_store.len();
530        self.chunk_store.retain(|_, (meta, _)| meta.ref_count > 0);
531        before - self.chunk_store.len()
532    }
533
534    /// Compute and return a snapshot of current deduplication statistics.
535    pub fn stats(&self) -> DeduplicationStats {
536        let unique_chunks = self.chunk_store.len() as u64;
537        let total_chunks: u64 = self
538            .chunk_store
539            .values()
540            .map(|(meta, _)| meta.ref_count as u64)
541            .sum();
542        let duplicate_chunks = total_chunks.saturating_sub(unique_chunks);
543
544        let bytes_after: u64 = self
545            .chunk_store
546            .values()
547            .map(|(_, data)| data.len() as u64)
548            .sum();
549
550        let bytes_before = self.total_bytes_stored;
551
552        let dedup_ratio = if bytes_before > 0 {
553            1.0 - (bytes_after as f64 / bytes_before as f64)
554        } else {
555            0.0
556        };
557
558        let compression_ratio = bytes_before as f64 / bytes_after.max(1) as f64;
559
560        DeduplicationStats {
561            total_chunks,
562            unique_chunks,
563            duplicate_chunks,
564            bytes_before,
565            bytes_after,
566            dedup_ratio,
567            compression_ratio,
568        }
569    }
570
571    /// Return a sorted list of all stored object IDs.
572    pub fn list_objects(&self) -> Vec<String> {
573        let mut ids: Vec<String> = self.manifests.keys().cloned().collect();
574        ids.sort();
575        ids
576    }
577
578    /// Return the number of unique chunks currently in the store.
579    pub fn chunk_count(&self) -> usize {
580        self.chunk_store.len()
581    }
582
583    /// Return the number of objects currently stored.
584    pub fn object_count(&self) -> usize {
585        self.manifests.len()
586    }
587
588    /// Retrieve the manifest for a stored object without reconstructing data.
589    pub fn get_manifest(&self, object_id: &str) -> Option<&ObjectManifest> {
590        self.manifests.get(object_id)
591    }
592
593    /// Return a reference to the current chunking configuration.
594    pub fn config(&self) -> &ChunkingConfig {
595        &self.config
596    }
597}
598
599// ─── Tests ───────────────────────────────────────────────────────────────────
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    // ── xorshift64 PRNG for deterministic test data (no rand crate) ─────────
606
607    fn xorshift64(state: &mut u64) -> u64 {
608        let mut x = *state;
609        x ^= x << 13;
610        x ^= x >> 7;
611        x ^= x << 17;
612        *state = x;
613        x
614    }
615
616    fn gen_bytes(seed: u64, len: usize) -> Vec<u8> {
617        let mut state = seed;
618        let mut out = Vec::with_capacity(len);
619        while out.len() < len {
620            let v = xorshift64(&mut state);
621            let bytes = v.to_le_bytes();
622            let remaining = len - out.len();
623            let take = remaining.min(8);
624            out.extend_from_slice(&bytes[..take]);
625        }
626        out
627    }
628
629    fn default_dedup() -> BlockDeduplicator {
630        BlockDeduplicator::with_defaults()
631    }
632
633    // ── 1. CDC Chunking tests ────────────────────────────────────────────────
634
635    #[test]
636    fn test_single_chunk_small_data() {
637        let dedup = default_dedup();
638        // 100 bytes < min_chunk_size (2048) → must produce exactly 1 chunk
639        let data = gen_bytes(1, 100);
640        let chunks = dedup.chunk_data(&data);
641        assert_eq!(chunks.len(), 1);
642        assert_eq!(chunks[0].1, data);
643    }
644
645    #[test]
646    fn test_chunk_data_empty() {
647        let dedup = default_dedup();
648        let chunks = dedup.chunk_data(&[]);
649        assert!(chunks.is_empty(), "empty input should produce no chunks");
650    }
651
652    #[test]
653    fn test_chunk_data_exact_min() {
654        let config = ChunkingConfig {
655            min_chunk_size: 128,
656            max_chunk_size: 65536,
657            target_bits: 8, // low target bits → boundaries rare but possible
658            window_size: 48,
659            enable_compression: false,
660        };
661        let dedup = BlockDeduplicator::new(config);
662        // Exactly min_chunk_size bytes — may or may not split depending on hash,
663        // but the full data must be covered.
664        let data = gen_bytes(42, 128);
665        let chunks = dedup.chunk_data(&data);
666        assert!(!chunks.is_empty());
667        let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
668        assert_eq!(total, 128);
669    }
670
671    #[test]
672    fn test_chunk_data_max_size_boundary() {
673        let config = ChunkingConfig {
674            min_chunk_size: 512,
675            max_chunk_size: 1024,
676            target_bits: 20, // very high bits → hash boundary almost never fires
677            window_size: 32,
678            enable_compression: false,
679        };
680        let dedup = BlockDeduplicator::new(config);
681        // 3 * max_chunk_size bytes → at least 3 chunks forced by max boundary
682        let data = gen_bytes(7, 3 * 1024);
683        let chunks = dedup.chunk_data(&data);
684        assert!(
685            chunks.len() >= 3,
686            "expected at least 3 chunks, got {}",
687            chunks.len()
688        );
689    }
690
691    #[test]
692    fn test_chunk_data_deterministic() {
693        let dedup = default_dedup();
694        let data = gen_bytes(99, 200_000);
695        let c1 = dedup.chunk_data(&data);
696        let c2 = dedup.chunk_data(&data);
697        assert_eq!(c1.len(), c2.len());
698        for (a, b) in c1.iter().zip(c2.iter()) {
699            assert_eq!(a.0, b.0);
700            assert_eq!(a.1, b.1);
701        }
702    }
703
704    #[test]
705    fn test_chunk_data_window_smaller_than_data() {
706        let config = ChunkingConfig {
707            min_chunk_size: 4,
708            max_chunk_size: 65536,
709            target_bits: 4, // lots of boundaries
710            window_size: 48,
711            enable_compression: false,
712        };
713        let dedup = BlockDeduplicator::new(config);
714        // data shorter than window_size (48) but larger than min_chunk_size (4)
715        let data = gen_bytes(3, 20);
716        let chunks = dedup.chunk_data(&data);
717        // must cover all bytes
718        let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
719        assert_eq!(total, 20);
720    }
721
722    #[test]
723    fn test_chunk_data_covers_all_bytes() {
724        let dedup = default_dedup();
725        let data = gen_bytes(55, 500_000);
726        let chunks = dedup.chunk_data(&data);
727        let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
728        assert_eq!(total, data.len(), "chunks must cover all bytes exactly");
729    }
730
731    #[test]
732    fn test_chunk_hash_hex_display() {
733        let hash = ChunkHash::from(0xDEADBEEF_CAFEBABE_u64);
734        let s = format!("{}", hash);
735        assert_eq!(s.len(), 16, "ChunkHash hex must be exactly 16 chars");
736        assert!(s.chars().all(|c| c.is_ascii_hexdigit()), "must be hex");
737    }
738
739    #[test]
740    fn test_chunk_hash_from_u64_roundtrip() {
741        let v: u64 = 0x0102030405060708;
742        let hash = ChunkHash::from(v);
743        assert_eq!(hash.to_u64(), v);
744    }
745
746    #[test]
747    fn test_rolling_hash_produces_boundaries() {
748        // Craft data where we know a boundary fires by brute-forcing a window that
749        // produces a hash value satisfying `h & mask == 0` for a small mask.
750        let config = ChunkingConfig {
751            min_chunk_size: 4,
752            max_chunk_size: 65536,
753            target_bits: 4, // mask = 0xF → boundary every ~16 bytes on average
754            window_size: 4,
755            enable_compression: false,
756        };
757        let dedup = BlockDeduplicator::new(config);
758        // With 10,000 random bytes and target_bits=4, we expect many boundaries.
759        let data = gen_bytes(1234, 10_000);
760        let chunks = dedup.chunk_data(&data);
761        // There should be multiple chunks
762        assert!(
763            chunks.len() > 2,
764            "expected multiple hash-triggered boundaries, got {}",
765            chunks.len()
766        );
767        // All bytes must still be accounted for
768        let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
769        assert_eq!(total, 10_000);
770    }
771
772    #[test]
773    fn test_chunk_data_large_uniform_zeros() {
774        let config = ChunkingConfig {
775            min_chunk_size: 512,
776            max_chunk_size: 1024,
777            target_bits: 20, // hash boundary essentially never fires for uniform data
778            window_size: 32,
779            enable_compression: false,
780        };
781        let dedup = BlockDeduplicator::new(config);
782        // Uniform zeros — boundaries only triggered by max_chunk_size
783        let data = vec![0u8; 5 * 1024];
784        let chunks = dedup.chunk_data(&data);
785        // At least 5 chunks (max_chunk_size = 1024, data = 5120 bytes)
786        assert!(chunks.len() >= 5);
787        let total: usize = chunks.iter().map(|(_, b)| b.len()).sum();
788        assert_eq!(total, 5 * 1024);
789    }
790
791    #[test]
792    fn test_chunk_data_multiple_chunks() {
793        let config = ChunkingConfig {
794            min_chunk_size: 256,
795            max_chunk_size: 1024,
796            target_bits: 20, // force max-size splits
797            window_size: 32,
798            enable_compression: false,
799        };
800        let dedup = BlockDeduplicator::new(config);
801        let data = gen_bytes(11, 4096);
802        let chunks = dedup.chunk_data(&data);
803        assert!(
804            chunks.len() >= 4,
805            "expected ≥4 chunks from 4096 bytes with max_size=1024, got {}",
806            chunks.len()
807        );
808    }
809
810    #[test]
811    fn test_chunk_hash_from_bytes() {
812        let bytes = [0u8, 1, 2, 3, 4, 5, 6, 7];
813        let hash = ChunkHash::from_bytes(bytes);
814        assert_eq!(hash.as_bytes(), &bytes);
815    }
816
817    #[test]
818    fn test_chunk_data_each_chunk_nonempty() {
819        let dedup = default_dedup();
820        let data = gen_bytes(77, 300_000);
821        let chunks = dedup.chunk_data(&data);
822        for (i, (_, b)) in chunks.iter().enumerate() {
823            assert!(!b.is_empty(), "chunk {} must not be empty", i);
824        }
825    }
826
827    // ── 2. Store / Retrieve / Delete tests ──────────────────────────────────
828
829    #[test]
830    fn test_store_and_retrieve_small() {
831        let mut dedup = default_dedup();
832        let data = b"hello world".to_vec();
833        dedup
834            .store_object("obj1".to_string(), data.clone())
835            .unwrap();
836        let retrieved = dedup.retrieve_object("obj1").unwrap();
837        assert_eq!(data, retrieved);
838    }
839
840    #[test]
841    fn test_store_and_retrieve_large() {
842        let mut dedup = default_dedup();
843        let data = gen_bytes(1001, 500_000);
844        dedup
845            .store_object("large".to_string(), data.clone())
846            .unwrap();
847        let retrieved = dedup.retrieve_object("large").unwrap();
848        assert_eq!(data, retrieved, "large object round-trip failed");
849    }
850
851    #[test]
852    fn test_store_duplicate_objects() {
853        let mut dedup = default_dedup();
854        let data = gen_bytes(22, 100_000);
855        dedup.store_object("a".to_string(), data.clone()).unwrap();
856        dedup.store_object("b".to_string(), data.clone()).unwrap();
857        let ra = dedup.retrieve_object("a").unwrap();
858        let rb = dedup.retrieve_object("b").unwrap();
859        assert_eq!(data, ra);
860        assert_eq!(data, rb);
861    }
862
863    #[test]
864    fn test_store_two_different_objects() {
865        let mut dedup = default_dedup();
866        let d1 = gen_bytes(101, 50_000);
867        let d2 = gen_bytes(202, 60_000);
868        dedup.store_object("x".to_string(), d1.clone()).unwrap();
869        dedup.store_object("y".to_string(), d2.clone()).unwrap();
870        assert_eq!(dedup.retrieve_object("x").unwrap(), d1);
871        assert_eq!(dedup.retrieve_object("y").unwrap(), d2);
872    }
873
874    #[test]
875    fn test_delete_object_removes_manifest() {
876        let mut dedup = default_dedup();
877        let data = gen_bytes(5, 10_000);
878        dedup.store_object("del_me".to_string(), data).unwrap();
879        dedup.delete_object("del_me").unwrap();
880        let err = dedup.retrieve_object("del_me").unwrap_err();
881        assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
882    }
883
884    #[test]
885    fn test_delete_decrements_ref_count() {
886        let mut dedup = BlockDeduplicator::new(ChunkingConfig {
887            min_chunk_size: 256,
888            max_chunk_size: 65536,
889            target_bits: 20, // very few natural splits → objects share same chunks
890            window_size: 32,
891            enable_compression: false,
892        });
893        let data = gen_bytes(9, 1024); // fits in single chunk
894        dedup
895            .store_object("obj_a".to_string(), data.clone())
896            .unwrap();
897        dedup
898            .store_object("obj_b".to_string(), data.clone())
899            .unwrap();
900
901        // After storing twice, chunk should have ref_count == 2.
902        // Get the hash of the single chunk.
903        let manifest = dedup.get_manifest("obj_a").unwrap().clone();
904        let hash = manifest.chunks[0].hash;
905        assert_eq!(dedup.get_chunk(&hash).unwrap().ref_count, 2);
906
907        // Delete one object — ref_count should drop to 1, chunk still present.
908        let removed = dedup.delete_object("obj_a").unwrap();
909        assert!(
910            removed.is_empty(),
911            "chunk still referenced by obj_b, should not be removed"
912        );
913        assert_eq!(dedup.get_chunk(&hash).unwrap().ref_count, 1);
914    }
915
916    #[test]
917    fn test_delete_removes_unshared_chunks() {
918        let mut dedup = default_dedup();
919        let data = gen_bytes(13, 50_000);
920        let manifest = dedup.store_object("only_obj".to_string(), data).unwrap();
921        let n_chunks = manifest.chunks.len();
922        let removed = dedup.delete_object("only_obj").unwrap();
923        assert_eq!(
924            removed.len(),
925            n_chunks,
926            "all unshared chunks should be removed"
927        );
928    }
929
930    #[test]
931    fn test_delete_nonexistent_object() {
932        let mut dedup = default_dedup();
933        let err = dedup.delete_object("ghost").unwrap_err();
934        assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
935    }
936
937    #[test]
938    fn test_retrieve_nonexistent_object() {
939        let dedup = default_dedup();
940        let err = dedup.retrieve_object("ghost").unwrap_err();
941        assert!(matches!(err, DeduplicatorError::ObjectNotFound(_)));
942    }
943
944    #[test]
945    fn test_store_empty_data() {
946        let mut dedup = default_dedup();
947        let manifest = dedup.store_object("empty".to_string(), vec![]).unwrap();
948        assert_eq!(manifest.total_size, 0);
949        assert!(manifest.chunks.is_empty());
950        let retrieved = dedup.retrieve_object("empty").unwrap();
951        assert!(retrieved.is_empty());
952    }
953
954    #[test]
955    fn test_store_overwrite_same_id() {
956        let mut dedup = default_dedup();
957        let d1 = gen_bytes(1, 1000);
958        let d2 = gen_bytes(2, 2000);
959        dedup.store_object("obj".to_string(), d1).unwrap();
960        dedup.store_object("obj".to_string(), d2.clone()).unwrap();
961        // The manifest is overwritten; retrieve should return d2.
962        let retrieved = dedup.retrieve_object("obj").unwrap();
963        assert_eq!(retrieved, d2);
964    }
965
966    #[test]
967    fn test_manifest_chunk_order() {
968        let mut dedup = default_dedup();
969        let data = gen_bytes(33, 200_000);
970        let manifest = dedup.store_object("ordered".to_string(), data).unwrap();
971        let mut prev_offset = 0u64;
972        for (i, cr) in manifest.chunks.iter().enumerate() {
973            assert!(
974                cr.offset_in_object >= prev_offset || i == 0,
975                "chunk {} has non-monotonic offset",
976                i
977            );
978            prev_offset = cr.offset_in_object;
979        }
980    }
981
982    #[test]
983    fn test_manifest_total_size() {
984        let mut dedup = default_dedup();
985        let data = gen_bytes(44, 150_000);
986        let expected_size = data.len() as u64;
987        let manifest = dedup.store_object("sized".to_string(), data).unwrap();
988        assert_eq!(manifest.total_size, expected_size);
989    }
990
991    #[test]
992    fn test_retrieve_exact_bytes() {
993        let mut dedup = default_dedup();
994        let data: Vec<u8> = (0..=255u8).cycle().take(10_000).collect();
995        dedup
996            .store_object("pattern".to_string(), data.clone())
997            .unwrap();
998        let retrieved = dedup.retrieve_object("pattern").unwrap();
999        assert_eq!(data, retrieved, "byte-for-byte equality required");
1000    }
1001
1002    #[test]
1003    fn test_delete_returns_removed_hashes() {
1004        let mut dedup = default_dedup();
1005        let data = gen_bytes(66, 30_000);
1006        let manifest = dedup.store_object("ret_hashes".to_string(), data).unwrap();
1007        let expected_hashes: Vec<ChunkHash> = manifest.chunks.iter().map(|cr| cr.hash).collect();
1008        let removed = dedup.delete_object("ret_hashes").unwrap();
1009        for h in &expected_hashes {
1010            assert!(removed.contains(h), "hash {} should be in removed list", h);
1011        }
1012    }
1013
1014    #[test]
1015    fn test_manifest_chunk_offsets_are_contiguous() {
1016        let mut dedup = default_dedup();
1017        let data = gen_bytes(88, 300_000);
1018        let manifest = dedup.store_object("contiguous".to_string(), data).unwrap();
1019        let mut expected_offset = 0u64;
1020        for cr in &manifest.chunks {
1021            assert_eq!(
1022                cr.offset_in_object, expected_offset,
1023                "chunk offsets must be contiguous"
1024            );
1025            expected_offset += cr.chunk_length as u64;
1026        }
1027    }
1028
1029    // ── 3. Dedup ratio / ref counting tests ─────────────────────────────────
1030
1031    #[test]
1032    fn test_ref_count_increments_on_duplicate() {
1033        let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1034            min_chunk_size: 256,
1035            max_chunk_size: 65536,
1036            target_bits: 20,
1037            window_size: 32,
1038            enable_compression: false,
1039        });
1040        let data = gen_bytes(111, 1024); // single chunk
1041        dedup.store_object("d1".to_string(), data.clone()).unwrap();
1042        dedup.store_object("d2".to_string(), data.clone()).unwrap();
1043
1044        let manifest = dedup.get_manifest("d1").unwrap().clone();
1045        let hash = manifest.chunks[0].hash;
1046        assert_eq!(
1047            dedup.get_chunk(&hash).unwrap().ref_count,
1048            2,
1049            "ref_count should be 2 after two stores"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_ref_count_one_unique() {
1055        let mut dedup = default_dedup();
1056        let data = gen_bytes(222, 50_000);
1057        let manifest = dedup.store_object("unique_obj".to_string(), data).unwrap();
1058        for cr in &manifest.chunks {
1059            let chunk = dedup.get_chunk(&cr.hash).unwrap();
1060            assert_eq!(
1061                chunk.ref_count, 1,
1062                "unique chunk should have ref_count == 1"
1063            );
1064        }
1065    }
1066
1067    #[test]
1068    fn test_dedup_ratio_identical_objects() {
1069        let mut dedup = default_dedup();
1070        let data = gen_bytes(333, 200_000);
1071        dedup
1072            .store_object("copy1".to_string(), data.clone())
1073            .unwrap();
1074        dedup
1075            .store_object("copy2".to_string(), data.clone())
1076            .unwrap();
1077
1078        let stats = dedup.stats();
1079        // bytes_before = 2 * 200_000; bytes_after = ~200_000
1080        // dedup_ratio should be close to 0.5
1081        assert!(
1082            stats.dedup_ratio > 0.3,
1083            "expected dedup ratio > 0.3, got {}",
1084            stats.dedup_ratio
1085        );
1086    }
1087
1088    #[test]
1089    fn test_dedup_ratio_unique_objects() {
1090        let mut dedup = default_dedup();
1091        let d1 = gen_bytes(401, 100_000);
1092        let d2 = gen_bytes(402, 100_000);
1093        dedup.store_object("u1".to_string(), d1).unwrap();
1094        dedup.store_object("u2".to_string(), d2).unwrap();
1095
1096        let stats = dedup.stats();
1097        // No sharing expected → bytes_before ≈ bytes_after → ratio ≈ 0
1098        assert!(
1099            stats.dedup_ratio < 0.2,
1100            "expected low dedup ratio for unique data, got {}",
1101            stats.dedup_ratio
1102        );
1103    }
1104
1105    #[test]
1106    fn test_stats_total_chunks() {
1107        let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1108            min_chunk_size: 256,
1109            max_chunk_size: 65536,
1110            target_bits: 20,
1111            window_size: 32,
1112            enable_compression: false,
1113        });
1114        let data = gen_bytes(500, 1024); // single chunk, ref counted twice
1115        dedup.store_object("s1".to_string(), data.clone()).unwrap();
1116        dedup.store_object("s2".to_string(), data.clone()).unwrap();
1117
1118        let stats = dedup.stats();
1119        // 1 unique chunk with ref_count == 2 → total_chunks == 2
1120        assert_eq!(stats.total_chunks, 2);
1121        assert_eq!(stats.unique_chunks, 1);
1122        assert_eq!(stats.duplicate_chunks, 1);
1123    }
1124
1125    #[test]
1126    fn test_stats_unique_chunks_equals_store_size() {
1127        let mut dedup = default_dedup();
1128        let d1 = gen_bytes(601, 50_000);
1129        let d2 = gen_bytes(602, 50_000);
1130        dedup.store_object("a".to_string(), d1).unwrap();
1131        dedup.store_object("b".to_string(), d2).unwrap();
1132        let stats = dedup.stats();
1133        assert_eq!(stats.unique_chunks, dedup.chunk_count() as u64);
1134    }
1135
1136    #[test]
1137    fn test_stats_bytes_before_after() {
1138        let mut dedup = default_dedup();
1139        let data = gen_bytes(700, 100_000);
1140        dedup
1141            .store_object("ba_test".to_string(), data.clone())
1142            .unwrap();
1143        let stats = dedup.stats();
1144        assert_eq!(stats.bytes_before, 100_000);
1145        // bytes_after should equal the sum of stored chunk sizes
1146        let expected_after: u64 = dedup
1147            .chunk_store
1148            .values()
1149            .map(|(_, d)| d.len() as u64)
1150            .sum();
1151        assert_eq!(stats.bytes_after, expected_after);
1152    }
1153
1154    #[test]
1155    fn test_chunk_exists_true() {
1156        let mut dedup = default_dedup();
1157        let data = b"some data for hashing".to_vec();
1158        let manifest = dedup.store_object("ce_true".to_string(), data).unwrap();
1159        let hash = manifest.chunks[0].hash;
1160        assert!(dedup.chunk_exists(&hash));
1161    }
1162
1163    #[test]
1164    fn test_chunk_exists_false() {
1165        let dedup = default_dedup();
1166        let fake_hash = ChunkHash::from(0xFFFF_FFFF_FFFF_FFFFu64);
1167        assert!(!dedup.chunk_exists(&fake_hash));
1168    }
1169
1170    #[test]
1171    fn test_get_chunk_success() {
1172        let mut dedup = default_dedup();
1173        let data = b"chunk metadata test".to_vec();
1174        let expected_len = data.len();
1175        let manifest = dedup.store_object("gc_ok".to_string(), data).unwrap();
1176        let hash = manifest.chunks[0].hash;
1177        let chunk = dedup.get_chunk(&hash).unwrap();
1178        assert_eq!(chunk.hash, hash);
1179        assert_eq!(chunk.length, expected_len);
1180        assert_eq!(chunk.ref_count, 1);
1181    }
1182
1183    #[test]
1184    fn test_get_chunk_not_found() {
1185        let dedup = default_dedup();
1186        let missing = ChunkHash::from(0xDEAD_C0DE_0000_0001u64);
1187        let err = dedup.get_chunk(&missing).unwrap_err();
1188        assert!(matches!(err, DeduplicatorError::ChunkNotFound(_)));
1189    }
1190
1191    #[test]
1192    fn test_dedup_shared_chunks_across_objects() {
1193        // Two objects that are identical share all their chunks.
1194        // We store the same data under two different object IDs and confirm
1195        // every chunk has ref_count == 2.
1196        let config = ChunkingConfig {
1197            min_chunk_size: 512,
1198            max_chunk_size: 4096,
1199            target_bits: 20,
1200            window_size: 32,
1201            enable_compression: false,
1202        };
1203        let mut dedup = BlockDeduplicator::new(config);
1204        // Use 4096 bytes → exactly one max-size chunk, guaranteed identical for both objects.
1205        let data = gen_bytes(801, 4096);
1206
1207        dedup
1208            .store_object("obj1".to_string(), data.clone())
1209            .unwrap();
1210        dedup
1211            .store_object("obj2".to_string(), data.clone())
1212            .unwrap();
1213
1214        // All chunks should have ref_count == 2 (shared between both objects)
1215        let manifest1 = dedup.get_manifest("obj1").unwrap().clone();
1216        for cr in &manifest1.chunks {
1217            let chunk = dedup.get_chunk(&cr.hash).unwrap();
1218            assert_eq!(
1219                chunk.ref_count, 2,
1220                "shared chunk {} should have ref_count == 2",
1221                cr.hash
1222            );
1223        }
1224    }
1225
1226    // ── 4. Compact tests ─────────────────────────────────────────────────────
1227
1228    #[test]
1229    fn test_compact_removes_orphans() {
1230        let mut dedup = default_dedup();
1231        let data = gen_bytes(901, 50_000);
1232        dedup.store_object("to_compact".to_string(), data).unwrap();
1233        // Force ref_count to 0 by directly manipulating (simulate partial delete bug).
1234        // Instead, do a normal delete and then compact.
1235        dedup.delete_object("to_compact").unwrap();
1236        // After delete, chunks with ref_count=0 are already removed eagerly.
1237        // compact() should find nothing extra to remove.
1238        let removed = dedup.compact();
1239        // Whether 0 or more, the store must be empty afterwards.
1240        assert_eq!(dedup.chunk_count(), 0);
1241        let _ = removed; // count is valid either way
1242    }
1243
1244    #[test]
1245    fn test_compact_keeps_referenced() {
1246        let mut dedup = BlockDeduplicator::new(ChunkingConfig {
1247            min_chunk_size: 256,
1248            max_chunk_size: 65536,
1249            target_bits: 20,
1250            window_size: 32,
1251            enable_compression: false,
1252        });
1253        let data = gen_bytes(1001, 1024);
1254        dedup
1255            .store_object("keep_a".to_string(), data.clone())
1256            .unwrap();
1257        dedup
1258            .store_object("keep_b".to_string(), data.clone())
1259            .unwrap();
1260        dedup.delete_object("keep_a").unwrap();
1261
1262        // chunk still referenced by keep_b → compact must keep it
1263        let removed = dedup.compact();
1264        assert_eq!(removed, 0, "no orphans should exist");
1265        assert_eq!(dedup.chunk_count(), 1, "chunk still referenced by keep_b");
1266    }
1267
1268    #[test]
1269    fn test_compact_returns_count() {
1270        let mut dedup = default_dedup();
1271        let data = gen_bytes(1100, 50_000);
1272        let manifest = dedup.store_object("count_test".to_string(), data).unwrap();
1273        let n_chunks = manifest.chunks.len();
1274
1275        // Manually set all ref_counts to 0 to simulate orphaned chunks
1276        // (bypassing normal delete to test compact independently).
1277        for (meta, _) in dedup.chunk_store.values_mut() {
1278            meta.ref_count = 0;
1279        }
1280        // Also remove the manifest so list_objects is clean.
1281        dedup.manifests.remove("count_test");
1282
1283        let removed = dedup.compact();
1284        assert_eq!(
1285            removed, n_chunks,
1286            "compact should remove all orphaned chunks"
1287        );
1288        assert_eq!(dedup.chunk_count(), 0);
1289    }
1290
1291    #[test]
1292    fn test_compact_empty_store() {
1293        let mut dedup = default_dedup();
1294        let removed = dedup.compact();
1295        assert_eq!(removed, 0);
1296    }
1297
1298    #[test]
1299    fn test_compact_idempotent() {
1300        let mut dedup = default_dedup();
1301        let data = gen_bytes(1200, 50_000);
1302        dedup.store_object("idem".to_string(), data).unwrap();
1303        dedup.delete_object("idem").unwrap();
1304        let _r1 = dedup.compact();
1305        let r2 = dedup.compact();
1306        assert_eq!(r2, 0, "second compact should remove nothing");
1307    }
1308
1309    #[test]
1310    fn test_compact_after_delete_all() {
1311        let mut dedup = default_dedup();
1312        let d1 = gen_bytes(1301, 60_000);
1313        let d2 = gen_bytes(1302, 60_000);
1314        dedup.store_object("c1".to_string(), d1).unwrap();
1315        dedup.store_object("c2".to_string(), d2).unwrap();
1316        dedup.delete_object("c1").unwrap();
1317        dedup.delete_object("c2").unwrap();
1318        dedup.compact();
1319        assert_eq!(dedup.chunk_count(), 0, "all chunks should be gone");
1320        assert_eq!(dedup.object_count(), 0);
1321    }
1322
1323    // ── 5. Error cases ───────────────────────────────────────────────────────
1324
1325    #[test]
1326    fn test_error_object_not_found_display() {
1327        let err = DeduplicatorError::ObjectNotFound("my-special-object".to_string());
1328        let msg = format!("{}", err);
1329        assert!(
1330            msg.contains("my-special-object"),
1331            "error message must contain object id: {}",
1332            msg
1333        );
1334    }
1335
1336    #[test]
1337    fn test_error_chunk_not_found_display() {
1338        let hash = ChunkHash::from(0xABCD_EF01_2345_6789u64);
1339        let err = DeduplicatorError::ChunkNotFound(hash);
1340        let msg = format!("{}", err);
1341        // The display includes the hash hex
1342        assert!(!msg.is_empty(), "error message must not be empty");
1343    }
1344
1345    #[test]
1346    fn test_chunk_hash_equality() {
1347        let b = [1u8, 2, 3, 4, 5, 6, 7, 8];
1348        let h1 = ChunkHash::from_bytes(b);
1349        let h2 = ChunkHash::from_bytes(b);
1350        assert_eq!(h1, h2);
1351    }
1352
1353    #[test]
1354    fn test_deduplicator_list_objects_empty() {
1355        let dedup = default_dedup();
1356        let objs = dedup.list_objects();
1357        assert!(objs.is_empty());
1358    }
1359
1360    #[test]
1361    fn test_deduplicator_list_objects_sorted() {
1362        let mut dedup = default_dedup();
1363        let ids = ["zebra", "alpha", "mango", "beta"];
1364        for id in &ids {
1365            dedup
1366                .store_object((*id).to_string(), gen_bytes(42, 100))
1367                .unwrap();
1368        }
1369        let listed = dedup.list_objects();
1370        let mut expected: Vec<String> = ids.iter().map(|s| s.to_string()).collect();
1371        expected.sort();
1372        assert_eq!(listed, expected);
1373    }
1374
1375    // ── 6. Integration / misc tests ──────────────────────────────────────────
1376
1377    #[test]
1378    fn test_large_object_chunked_correctly() {
1379        let mut dedup = default_dedup();
1380        let data = gen_bytes(9999, 1_000_000); // 1 MB
1381        dedup
1382            .store_object("mega".to_string(), data.clone())
1383            .unwrap();
1384        let retrieved = dedup.retrieve_object("mega").unwrap();
1385        assert_eq!(data, retrieved, "1 MB round-trip failed");
1386    }
1387
1388    #[test]
1389    fn test_many_small_objects() {
1390        let mut dedup = default_dedup();
1391        let mut originals: Vec<Vec<u8>> = Vec::new();
1392        let mut seed = 12345u64;
1393        for i in 0..100 {
1394            let data = gen_bytes(seed, 500 + i * 7);
1395            seed = seed.wrapping_add(1);
1396            dedup
1397                .store_object(format!("small_{}", i), data.clone())
1398                .unwrap();
1399            originals.push(data);
1400        }
1401        for (i, original) in originals.iter().enumerate() {
1402            let retrieved = dedup.retrieve_object(&format!("small_{}", i)).unwrap();
1403            assert_eq!(*original, retrieved, "small object {} failed round-trip", i);
1404        }
1405    }
1406
1407    #[test]
1408    fn test_config_default_values() {
1409        let cfg = ChunkingConfig::default();
1410        assert_eq!(cfg.min_chunk_size, 2048);
1411        assert_eq!(cfg.max_chunk_size, 65536);
1412        assert_eq!(cfg.target_bits, 13);
1413        assert_eq!(cfg.window_size, 48);
1414        assert!(!cfg.enable_compression);
1415    }
1416
1417    #[test]
1418    fn test_deduplicator_new_starts_empty() {
1419        let dedup = default_dedup();
1420        assert_eq!(dedup.chunk_count(), 0);
1421        assert_eq!(dedup.object_count(), 0);
1422        assert!(dedup.list_objects().is_empty());
1423        let stats = dedup.stats();
1424        assert_eq!(stats.total_chunks, 0);
1425        assert_eq!(stats.bytes_before, 0);
1426    }
1427
1428    #[test]
1429    fn test_full_dedup_workflow() {
1430        let mut dedup = default_dedup();
1431
1432        // 1. Store two objects with overlapping content.
1433        let shared = gen_bytes(5555, 200_000);
1434        let extra1 = gen_bytes(6001, 50_000);
1435        let extra2 = gen_bytes(6002, 50_000);
1436
1437        let mut obj_a = shared.clone();
1438        obj_a.extend_from_slice(&extra1);
1439        let mut obj_b = shared.clone();
1440        obj_b.extend_from_slice(&extra2);
1441
1442        let m_a = dedup
1443            .store_object("workflow_a".to_string(), obj_a.clone())
1444            .unwrap();
1445        let m_b = dedup
1446            .store_object("workflow_b".to_string(), obj_b.clone())
1447            .unwrap();
1448
1449        // 2. Both objects retrievable and correct.
1450        assert_eq!(dedup.retrieve_object("workflow_a").unwrap(), obj_a);
1451        assert_eq!(dedup.retrieve_object("workflow_b").unwrap(), obj_b);
1452
1453        // 3. Objects are listed.
1454        let listed = dedup.list_objects();
1455        assert!(listed.contains(&"workflow_a".to_string()));
1456        assert!(listed.contains(&"workflow_b".to_string()));
1457
1458        // 4. Stats reflect two stores.
1459        let stats = dedup.stats();
1460        assert_eq!(stats.bytes_before, (obj_a.len() + obj_b.len()) as u64);
1461
1462        // 5. Delete one object.
1463        dedup.delete_object("workflow_a").unwrap();
1464        assert!(dedup
1465            .retrieve_object("workflow_a")
1466            .unwrap_err()
1467            .to_string()
1468            .contains("workflow_a"));
1469
1470        // 6. Other object still intact.
1471        assert_eq!(dedup.retrieve_object("workflow_b").unwrap(), obj_b);
1472
1473        // 7. Compact.
1474        dedup.compact();
1475
1476        // 8. Remaining manifest is consistent.
1477        let n_chunks_b = m_b.chunks.len();
1478        assert!(dedup.chunk_count() > 0, "workflow_b chunks still present");
1479        // But fewer than the combined chunk set.
1480        assert!(dedup.chunk_count() <= m_a.chunks.len() + n_chunks_b);
1481
1482        // 9. Delete last object and compact → empty store.
1483        dedup.delete_object("workflow_b").unwrap();
1484        dedup.compact();
1485        assert_eq!(dedup.chunk_count(), 0);
1486        assert_eq!(dedup.object_count(), 0);
1487    }
1488
1489    #[test]
1490    fn test_chunk_data_chunk_hashes_are_content_based() {
1491        let dedup = default_dedup();
1492        let d1 = vec![0u8; 100];
1493        let d2 = vec![1u8; 100];
1494        let c1 = dedup.chunk_data(&d1);
1495        let c2 = dedup.chunk_data(&d2);
1496        assert_ne!(
1497            c1[0].0, c2[0].0,
1498            "different content must produce different hashes"
1499        );
1500    }
1501
1502    #[test]
1503    fn test_boundary_mask_calculation() {
1504        let config = ChunkingConfig {
1505            target_bits: 13,
1506            ..ChunkingConfig::default()
1507        };
1508        let mask = config.boundary_mask();
1509        assert_eq!(mask, (1u64 << 13) - 1);
1510        assert_eq!(mask, 8191);
1511    }
1512
1513    #[test]
1514    fn test_store_single_byte() {
1515        let mut dedup = default_dedup();
1516        let data = vec![42u8];
1517        let manifest = dedup
1518            .store_object("single_byte".to_string(), data.clone())
1519            .unwrap();
1520        assert_eq!(manifest.total_size, 1);
1521        assert_eq!(manifest.chunks.len(), 1);
1522        let retrieved = dedup.retrieve_object("single_byte").unwrap();
1523        assert_eq!(retrieved, data);
1524    }
1525
1526    #[test]
1527    fn test_stats_compression_ratio_at_least_one() {
1528        let mut dedup = default_dedup();
1529        let data = gen_bytes(7777, 100_000);
1530        dedup.store_object("cr_test".to_string(), data).unwrap();
1531        let stats = dedup.stats();
1532        // With no actual compression, bytes_before == bytes_after → ratio == 1.0
1533        assert!(
1534            (stats.compression_ratio - 1.0).abs() < 1e-6,
1535            "expected ratio ~1.0, got {}",
1536            stats.compression_ratio
1537        );
1538    }
1539
1540    #[test]
1541    fn test_chunk_length_matches_metadata() {
1542        let mut dedup = default_dedup();
1543        let data = gen_bytes(8888, 200_000);
1544        let manifest = dedup.store_object("len_check".to_string(), data).unwrap();
1545        for cr in &manifest.chunks {
1546            let chunk_meta = dedup.get_chunk(&cr.hash).unwrap();
1547            assert_eq!(
1548                chunk_meta.length, cr.chunk_length,
1549                "chunk metadata length must match ChunkRef length"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn test_object_count_updates_correctly() {
1556        let mut dedup = default_dedup();
1557        assert_eq!(dedup.object_count(), 0);
1558        dedup
1559            .store_object("oc1".to_string(), gen_bytes(1, 1000))
1560            .unwrap();
1561        assert_eq!(dedup.object_count(), 1);
1562        dedup
1563            .store_object("oc2".to_string(), gen_bytes(2, 1000))
1564            .unwrap();
1565        assert_eq!(dedup.object_count(), 2);
1566        dedup.delete_object("oc1").unwrap();
1567        assert_eq!(dedup.object_count(), 1);
1568        dedup.delete_object("oc2").unwrap();
1569        assert_eq!(dedup.object_count(), 0);
1570    }
1571}