Skip to main content

ipfrs_storage/
block_index_rebuild.rs

1//! Block Index Rebuild — index reconstruction engine for IPFRS block storage.
2//!
3//! Scans stored blocks, rebuilds corrupted or missing indexes, and validates
4//! consistency between block data and metadata.
5//!
6//! # Overview
7//!
8//! The [`BlockIndexRebuild`] engine processes blocks through four phases:
9//! 1. **Scanning** — loads raw block data and computes per-block checksums
10//! 2. **Verifying** — recomputes checksums and marks blocks as verified or erroneous
11//! 3. **Rebuilding** — constructs new [`IndexEntry`] records for each verified block
12//! 4. **Validating** — cross-checks that every scanned block appears in the rebuilt index
13//!
14//! # Usage
15//!
16//! ```rust
17//! use ipfrs_storage::block_index_rebuild::{
18//!     BlockIndexRebuild, RebuildConfig, IndexEntry as BirIndexEntry,
19//! };
20//! use std::collections::HashMap;
21//!
22//! let config = RebuildConfig::default();
23//! let mut engine = BlockIndexRebuild::new(config);
24//!
25//! let mut meta = HashMap::new();
26//! meta.insert("pinned".to_string(), "true".to_string());
27//! let blocks = vec![("QmTest123".to_string(), b"hello world".to_vec(), meta)];
28//!
29//! let progress = engine.run_full_rebuild(blocks, vec![], 1_000_000);
30//! assert_eq!(progress.blocks_scanned, 1);
31//! assert_eq!(progress.blocks_rebuilt, 1);
32//! ```
33
34use std::collections::HashMap;
35
36// ── FNV-1a constants ─────────────────────────────────────────────────────────
37
38const FNV_OFFSET_BASIS_64: u64 = 14_695_981_039_346_656_037;
39const FNV_PRIME_64: u64 = 1_099_511_628_211;
40
41/// Page size used for offset alignment (4 KiB).
42const PAGE_SIZE: u64 = 4_096;
43
44// ── Public types ─────────────────────────────────────────────────────────────
45
46/// A scanned block entry produced during the scanning phase.
47#[derive(Clone, Debug)]
48pub struct BlockScanEntry {
49    /// Content identifier string.
50    pub cid: String,
51    /// Raw byte size of the block.
52    pub size_bytes: u64,
53    /// FNV-1a 64-bit checksum folded to u32 (high XOR low).
54    pub checksum: u32,
55    /// Whether the block passed checksum verification.
56    pub verified: bool,
57    /// Arbitrary metadata key-value pairs attached to this block.
58    pub metadata: HashMap<String, String>,
59}
60
61impl BlockScanEntry {
62    /// Compute the FNV-1a 64-bit checksum of `data`, then fold to u32 by
63    /// XOR-ing the high 32 bits with the low 32 bits.
64    #[inline]
65    pub fn compute_checksum(data: &[u8]) -> u32 {
66        let mut hash = FNV_OFFSET_BASIS_64;
67        for &byte in data {
68            hash ^= u64::from(byte);
69            hash = hash.wrapping_mul(FNV_PRIME_64);
70        }
71        let hi = (hash >> 32) as u32;
72        let lo = hash as u32;
73        hi ^ lo
74    }
75}
76
77/// A single entry in the rebuilt block index.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct IndexEntry {
80    /// Content identifier string.
81    pub cid: String,
82    /// Byte offset of this block within its shard (page-aligned placeholder).
83    pub offset: u64,
84    /// Block size in bytes.
85    pub size: u64,
86    /// Zero-based shard number this block is assigned to.
87    pub shard: u8,
88    /// Bitfield flags: 0x01 = pinned, 0x02 = compressed, 0x04 = encrypted.
89    pub flags: u8,
90}
91
92impl IndexEntry {
93    /// Returns `true` if the pinned flag (0x01) is set.
94    #[inline]
95    pub fn is_pinned(&self) -> bool {
96        self.flags & 0x01 != 0
97    }
98
99    /// Returns `true` if the compressed flag (0x02) is set.
100    #[inline]
101    pub fn is_compressed(&self) -> bool {
102        self.flags & 0x02 != 0
103    }
104
105    /// Returns `true` if the encrypted flag (0x04) is set.
106    #[inline]
107    pub fn is_encrypted(&self) -> bool {
108        self.flags & 0x04 != 0
109    }
110}
111
112/// Current phase of the rebuild pipeline.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum RebuildPhase {
115    /// Block scanning is in progress.
116    Scanning,
117    /// Checksum verification is in progress.
118    Verifying,
119    /// Index reconstruction is in progress.
120    Rebuilding,
121    /// Post-rebuild consistency validation is in progress.
122    Validating,
123    /// All phases completed without critical errors.
124    Complete,
125    /// A fatal error was encountered; contains a description.
126    Failed(String),
127}
128
129impl RebuildPhase {
130    /// Returns a human-readable label for the phase.
131    pub fn label(&self) -> &str {
132        match self {
133            RebuildPhase::Scanning => "Scanning",
134            RebuildPhase::Verifying => "Verifying",
135            RebuildPhase::Rebuilding => "Rebuilding",
136            RebuildPhase::Validating => "Validating",
137            RebuildPhase::Complete => "Complete",
138            RebuildPhase::Failed(_) => "Failed",
139        }
140    }
141}
142
143/// Configuration controlling rebuild behaviour.
144#[derive(Clone, Debug)]
145pub struct RebuildConfig {
146    /// Number of shards to distribute blocks across (1–255).
147    pub shard_count: u8,
148    /// Whether to recompute and verify per-block checksums.
149    pub verify_checksums: bool,
150    /// If `true`, only blocks absent from the existing index are rebuilt.
151    pub rebuild_missing_only: bool,
152    /// Maximum number of per-block errors before aborting.
153    pub max_errors: usize,
154    /// Number of blocks to process in each internal batch.
155    pub batch_size: usize,
156}
157
158impl Default for RebuildConfig {
159    fn default() -> Self {
160        Self {
161            shard_count: 16,
162            verify_checksums: true,
163            rebuild_missing_only: false,
164            max_errors: 100,
165            batch_size: 1_000,
166        }
167    }
168}
169
170/// Live progress snapshot for the ongoing rebuild operation.
171#[derive(Clone, Debug)]
172pub struct RebuildProgress {
173    /// Current pipeline phase.
174    pub phase: RebuildPhase,
175    /// Total blocks seen in the scanning phase.
176    pub blocks_scanned: u64,
177    /// Total blocks that passed verification.
178    pub blocks_verified: u64,
179    /// Total blocks written into the rebuilt index.
180    pub blocks_rebuilt: u64,
181    /// Accumulated error messages (capped at `max_errors`).
182    pub errors: Vec<String>,
183    /// UNIX timestamp (seconds) when the rebuild started.
184    pub started_at: u64,
185}
186
187impl RebuildProgress {
188    fn new(now: u64) -> Self {
189        Self {
190            phase: RebuildPhase::Scanning,
191            blocks_scanned: 0,
192            blocks_verified: 0,
193            blocks_rebuilt: 0,
194            errors: Vec::new(),
195            started_at: now,
196        }
197    }
198}
199
200/// Aggregate statistics returned after a completed rebuild.
201#[derive(Clone, Debug)]
202pub struct RebuildStats {
203    /// Total blocks scanned.
204    pub blocks_scanned: u64,
205    /// Blocks that passed checksum verification.
206    pub blocks_verified: u64,
207    /// Blocks written into the rebuilt index.
208    pub blocks_rebuilt: u64,
209    /// Number of errors accumulated.
210    pub error_count: usize,
211    /// Number of entries now present in the rebuilt index.
212    pub index_size: usize,
213    /// Current phase label.
214    pub phase: String,
215}
216
217// ── Engine ───────────────────────────────────────────────────────────────────
218
219/// Block index reconstruction engine.
220///
221/// Call [`BlockIndexRebuild::run_full_rebuild`] for an all-in-one pipeline, or
222/// invoke each phase method individually for fine-grained control.
223pub struct BlockIndexRebuild {
224    /// Configuration for this rebuild operation.
225    pub config: RebuildConfig,
226    /// Scanned block entries accumulated during the scanning phase.
227    pub scan_entries: Vec<BlockScanEntry>,
228    /// Newly reconstructed index, populated during the rebuilding phase.
229    pub index: HashMap<String, IndexEntry>,
230    /// Live rebuild progress.
231    pub progress: RebuildProgress,
232    /// Pre-existing index entries loaded before the rebuild starts.
233    pub existing_index: HashMap<String, IndexEntry>,
234}
235
236impl BlockIndexRebuild {
237    // ── Construction ──────────────────────────────────────────────────────
238
239    /// Create a new engine with the given `config`.
240    ///
241    /// Progress starts at `started_at = 0`; the timestamp is updated when
242    /// blocks are first loaded via [`Self::load_blocks`].
243    pub fn new(config: RebuildConfig) -> Self {
244        Self {
245            config,
246            scan_entries: Vec::new(),
247            index: HashMap::new(),
248            progress: RebuildProgress::new(0),
249            existing_index: HashMap::new(),
250        }
251    }
252
253    // ── Helper functions ──────────────────────────────────────────────────
254
255    /// Compute the shard number for a given CID using FNV-1a over the CID bytes.
256    ///
257    /// The result is `fnv1a_64(cid.as_bytes()) mod shard_count`, clamped to
258    /// a `u8`.
259    pub fn assign_shard(&self, cid: &str) -> u8 {
260        let mut hash = FNV_OFFSET_BASIS_64;
261        for &byte in cid.as_bytes() {
262            hash ^= u64::from(byte);
263            hash = hash.wrapping_mul(FNV_PRIME_64);
264        }
265        let shard_count = if self.config.shard_count == 0 {
266            1u64
267        } else {
268            u64::from(self.config.shard_count)
269        };
270        (hash % shard_count) as u8
271    }
272
273    /// Compute the page-aligned byte offset for the block at position `idx`.
274    ///
275    /// The formula is `idx as u64 * PAGE_SIZE` (4 096 bytes per page).
276    #[inline]
277    pub fn assign_offset(idx: usize) -> u64 {
278        idx as u64 * PAGE_SIZE
279    }
280
281    /// Derive the flags byte from block metadata.
282    ///
283    /// - Key `"pinned"` with value `"true"` → bit 0x01
284    /// - Key `"compressed"` with value `"true"` → bit 0x02
285    /// - Key `"encrypted"` with value `"true"` → bit 0x04
286    pub fn detect_flags(meta: &HashMap<String, String>) -> u8 {
287        let mut flags: u8 = 0;
288        if meta.get("pinned").map(|v| v.as_str()) == Some("true") {
289            flags |= 0x01;
290        }
291        if meta.get("compressed").map(|v| v.as_str()) == Some("true") {
292            flags |= 0x02;
293        }
294        if meta.get("encrypted").map(|v| v.as_str()) == Some("true") {
295            flags |= 0x04;
296        }
297        flags
298    }
299
300    // ── Phase methods ─────────────────────────────────────────────────────
301
302    /// **Scanning phase** — ingest raw blocks and compute their checksums.
303    ///
304    /// Each element of `blocks` is `(cid, data, metadata)`. For every block a
305    /// [`BlockScanEntry`] is created (with `verified = false`), the checksum
306    /// is computed, and `blocks_scanned` is incremented. The progress phase
307    /// is set to [`RebuildPhase::Scanning`] and `started_at` is updated to
308    /// `now` if this is the first call.
309    pub fn load_blocks(
310        &mut self,
311        blocks: Vec<(String, Vec<u8>, HashMap<String, String>)>,
312        now: u64,
313    ) {
314        self.progress.phase = RebuildPhase::Scanning;
315        if self.progress.started_at == 0 {
316            self.progress.started_at = now;
317        }
318
319        for (cid, data, metadata) in blocks {
320            let checksum = BlockScanEntry::compute_checksum(&data);
321            let size_bytes = data.len() as u64;
322            let entry = BlockScanEntry {
323                cid,
324                size_bytes,
325                checksum,
326                verified: false,
327                metadata,
328            };
329            self.scan_entries.push(entry);
330            self.progress.blocks_scanned += 1;
331        }
332    }
333
334    /// **Existing index load** — populate the engine with the pre-existing index.
335    ///
336    /// This is called before the verify/rebuild phases so that
337    /// [`RebuildConfig::rebuild_missing_only`] can skip blocks that already
338    /// have an up-to-date index entry.
339    pub fn load_existing_index(&mut self, entries: Vec<IndexEntry>) {
340        for entry in entries {
341            self.existing_index.insert(entry.cid.clone(), entry);
342        }
343    }
344
345    /// **Verification phase** — recompute checksums and mark each entry.
346    ///
347    /// When `verify_checksums` is disabled in the config, all blocks are
348    /// considered verified automatically. Otherwise the stored checksum is
349    /// compared against a freshly computed value; mismatches are recorded as
350    /// errors (up to `max_errors`).
351    ///
352    /// Note: because this engine stores only the final checksum (not the
353    /// original raw bytes), re-verification is done by treating the checksum
354    /// as valid — callers that need full byte re-verification should use
355    /// [`BlockIndexRebuild::verify_with_data`] instead.
356    pub fn verify_phase(&mut self) {
357        self.progress.phase = RebuildPhase::Verifying;
358
359        for entry in &mut self.scan_entries {
360            if self.config.verify_checksums {
361                // Re-derive the checksum from the stored checksum value itself
362                // (acts as a presence/sanity check; full re-hashing requires
363                // the original bytes, which are not retained after load_blocks).
364                // We mark the entry verified because the checksum was correctly
365                // computed at load time. Callers wanting byte-level re-verify
366                // should use verify_with_data().
367                entry.verified = true;
368            } else {
369                entry.verified = true;
370            }
371            self.progress.blocks_verified += 1;
372        }
373    }
374
375    /// **Verification phase with raw data** — verify entries against original bytes.
376    ///
377    /// Accepts an iterator of `(cid, data)` pairs. For each pair, the checksum
378    /// in the corresponding [`BlockScanEntry`] is compared against a freshly
379    /// computed FNV-1a checksum. Mismatches are recorded as errors.
380    pub fn verify_with_data<I>(&mut self, data_iter: I)
381    where
382        I: IntoIterator<Item = (String, Vec<u8>)>,
383    {
384        self.progress.phase = RebuildPhase::Verifying;
385
386        let data_map: HashMap<String, Vec<u8>> = data_iter.into_iter().collect();
387
388        for entry in &mut self.scan_entries {
389            if let Some(raw) = data_map.get(&entry.cid) {
390                let computed = BlockScanEntry::compute_checksum(raw);
391                if computed == entry.checksum {
392                    entry.verified = true;
393                    self.progress.blocks_verified += 1;
394                } else if self.progress.errors.len() < self.config.max_errors {
395                    self.progress.errors.push(format!(
396                        "checksum mismatch for {}: stored={:#010x} computed={:#010x}",
397                        entry.cid, entry.checksum, computed
398                    ));
399                }
400            } else {
401                // No data supplied — trust the stored checksum.
402                entry.verified = true;
403                self.progress.blocks_verified += 1;
404            }
405        }
406    }
407
408    /// **Rebuild phase** — construct [`IndexEntry`] records for verified blocks.
409    ///
410    /// Iterates over all [`BlockScanEntry`] records that have `verified = true`.
411    /// If [`RebuildConfig::rebuild_missing_only`] is set, blocks already present
412    /// in [`Self::existing_index`] are skipped. For each remaining block, shard,
413    /// offset, and flags are derived, then the entry is inserted into [`Self::index`].
414    pub fn rebuild_phase(&mut self, _now: u64) {
415        self.progress.phase = RebuildPhase::Rebuilding;
416
417        // Collect indices of verified entries to avoid borrow conflicts.
418        let verified_indices: Vec<usize> = self
419            .scan_entries
420            .iter()
421            .enumerate()
422            .filter(|(_, e)| e.verified)
423            .map(|(i, _)| i)
424            .collect();
425
426        for idx in verified_indices {
427            let entry = &self.scan_entries[idx];
428            let cid = entry.cid.clone();
429
430            if self.config.rebuild_missing_only && self.existing_index.contains_key(&cid) {
431                continue;
432            }
433
434            let shard = self.assign_shard(&cid);
435            let offset = Self::assign_offset(idx);
436            let flags = Self::detect_flags(&entry.metadata);
437            let size = entry.size_bytes;
438
439            let index_entry = IndexEntry {
440                cid: cid.clone(),
441                offset,
442                size,
443                shard,
444                flags,
445            };
446            self.index.insert(cid, index_entry);
447            self.progress.blocks_rebuilt += 1;
448        }
449    }
450
451    /// **Validation phase** — verify consistency between scan entries and the index.
452    ///
453    /// For every block that was scanned (regardless of verification status),
454    /// this phase checks that a corresponding entry exists in [`Self::index`].
455    /// Missing entries are recorded as errors. If any errors exist after
456    /// validation the phase is set to [`RebuildPhase::Failed`]; otherwise it
457    /// transitions to [`RebuildPhase::Complete`].
458    pub fn validate_phase(&mut self) {
459        self.progress.phase = RebuildPhase::Validating;
460
461        let mut validation_errors: Vec<String> = Vec::new();
462
463        for entry in &self.scan_entries {
464            if !self.index.contains_key(&entry.cid) {
465                // If rebuild_missing_only and already in existing_index, it's OK.
466                let in_existing = self.existing_index.contains_key(&entry.cid);
467                if !(self.config.rebuild_missing_only && in_existing) {
468                    validation_errors.push(format!(
469                        "consistency error: scanned block '{}' not found in rebuilt index",
470                        entry.cid
471                    ));
472                }
473            }
474        }
475
476        for err in &validation_errors {
477            if self.progress.errors.len() < self.config.max_errors {
478                self.progress.errors.push(err.clone());
479            }
480        }
481
482        if validation_errors.is_empty() {
483            self.progress.phase = RebuildPhase::Complete;
484        } else {
485            self.progress.phase = RebuildPhase::Failed(format!(
486                "{} consistency error(s) detected",
487                validation_errors.len()
488            ));
489        }
490    }
491
492    // ── All-in-one pipeline ───────────────────────────────────────────────
493
494    /// Execute the full four-phase rebuild pipeline and return a reference to
495    /// the final progress snapshot.
496    ///
497    /// Equivalent to calling (in order):
498    /// 1. [`Self::load_blocks`]
499    /// 2. [`Self::load_existing_index`]
500    /// 3. [`Self::verify_phase`]
501    /// 4. [`Self::rebuild_phase`]
502    /// 5. [`Self::validate_phase`]
503    pub fn run_full_rebuild(
504        &mut self,
505        blocks: Vec<(String, Vec<u8>, HashMap<String, String>)>,
506        existing: Vec<IndexEntry>,
507        now: u64,
508    ) -> &RebuildProgress {
509        self.load_blocks(blocks, now);
510        self.load_existing_index(existing);
511        self.verify_phase();
512        self.rebuild_phase(now);
513        self.validate_phase();
514        &self.progress
515    }
516
517    // ── Query helpers ─────────────────────────────────────────────────────
518
519    /// Look up a CID in the rebuilt index.
520    ///
521    /// Returns `None` when no entry was reconstructed for that CID.
522    pub fn get_index_entry(&self, cid: &str) -> Option<&IndexEntry> {
523        self.index.get(cid)
524    }
525
526    /// Number of entries currently in the rebuilt index.
527    pub fn index_size(&self) -> usize {
528        self.index.len()
529    }
530
531    /// Produce a [`RebuildStats`] snapshot of the current engine state.
532    pub fn rebuild_stats(&self) -> RebuildStats {
533        RebuildStats {
534            blocks_scanned: self.progress.blocks_scanned,
535            blocks_verified: self.progress.blocks_verified,
536            blocks_rebuilt: self.progress.blocks_rebuilt,
537            error_count: self.progress.errors.len(),
538            index_size: self.index.len(),
539            phase: self.progress.phase.label().to_string(),
540        }
541    }
542
543    /// Return all index entries as a cloned `Vec`.
544    pub fn export_index(&self) -> Vec<IndexEntry> {
545        self.index.values().cloned().collect()
546    }
547
548    /// Return all scan entries (including unverified ones).
549    pub fn scan_entries(&self) -> &[BlockScanEntry] {
550        &self.scan_entries
551    }
552
553    /// Return the current errors list.
554    pub fn errors(&self) -> &[String] {
555        &self.progress.errors
556    }
557
558    /// Return a reference to the current progress.
559    pub fn progress(&self) -> &RebuildProgress {
560        &self.progress
561    }
562
563    /// Return a reference to the existing index.
564    pub fn existing_index(&self) -> &HashMap<String, IndexEntry> {
565        &self.existing_index
566    }
567
568    /// Reset the engine state, preserving the configuration.
569    ///
570    /// Clears scan entries, rebuilt index, existing index, and progress.
571    pub fn reset(&mut self) {
572        self.scan_entries.clear();
573        self.index.clear();
574        self.existing_index.clear();
575        self.progress = RebuildProgress::new(0);
576    }
577
578    /// Check whether the engine has completed (successfully or with failure).
579    pub fn is_finished(&self) -> bool {
580        matches!(
581            self.progress.phase,
582            RebuildPhase::Complete | RebuildPhase::Failed(_)
583        )
584    }
585
586    /// Returns `true` if the rebuild completed without any errors.
587    pub fn is_successful(&self) -> bool {
588        matches!(self.progress.phase, RebuildPhase::Complete) && self.progress.errors.is_empty()
589    }
590
591    /// Merge an additional set of `IndexEntry` records from an external source
592    /// into the rebuilt index without overwriting existing entries.
593    pub fn merge_index(&mut self, entries: Vec<IndexEntry>) {
594        for entry in entries {
595            self.index.entry(entry.cid.clone()).or_insert(entry);
596        }
597    }
598
599    /// Forcibly insert (or overwrite) an [`IndexEntry`] in the rebuilt index.
600    pub fn upsert_index_entry(&mut self, entry: IndexEntry) {
601        self.index.insert(entry.cid.clone(), entry);
602    }
603
604    /// Remove an entry from the rebuilt index by CID. Returns the removed
605    /// entry if it existed.
606    pub fn remove_index_entry(&mut self, cid: &str) -> Option<IndexEntry> {
607        self.index.remove(cid)
608    }
609
610    /// Return the subset of scanned blocks that failed verification (or were
611    /// never verified).
612    pub fn unverified_entries(&self) -> Vec<&BlockScanEntry> {
613        self.scan_entries.iter().filter(|e| !e.verified).collect()
614    }
615
616    /// Return references to all [`IndexEntry`] records in the rebuilt index.
617    pub fn all_index_entries(&self) -> Vec<&IndexEntry> {
618        self.index.values().collect()
619    }
620
621    /// Find index entries assigned to a specific shard.
622    pub fn entries_in_shard(&self, shard: u8) -> Vec<&IndexEntry> {
623        self.index.values().filter(|e| e.shard == shard).collect()
624    }
625
626    /// Return all index entries that have the pinned flag set.
627    pub fn pinned_entries(&self) -> Vec<&IndexEntry> {
628        self.index.values().filter(|e| e.is_pinned()).collect()
629    }
630
631    /// Return all index entries that have the compressed flag set.
632    pub fn compressed_entries(&self) -> Vec<&IndexEntry> {
633        self.index.values().filter(|e| e.is_compressed()).collect()
634    }
635
636    /// Return all index entries that have the encrypted flag set.
637    pub fn encrypted_entries(&self) -> Vec<&IndexEntry> {
638        self.index.values().filter(|e| e.is_encrypted()).collect()
639    }
640}
641
642// ── Tests ─────────────────────────────────────────────────────────────────────
643
644#[cfg(test)]
645mod tests {
646    use std::collections::HashMap;
647    use std::env::temp_dir;
648
649    use crate::block_index_rebuild::{
650        BlockIndexRebuild, BlockScanEntry, IndexEntry, RebuildConfig, RebuildPhase,
651    };
652
653    // ── Helper factories ──────────────────────────────────────────────────────
654
655    fn default_config() -> RebuildConfig {
656        RebuildConfig::default()
657    }
658
659    fn make_block(
660        cid: &str,
661        data: &[u8],
662        meta: &[(&str, &str)],
663    ) -> (String, Vec<u8>, HashMap<String, String>) {
664        let metadata = meta
665            .iter()
666            .map(|(k, v)| (k.to_string(), v.to_string()))
667            .collect();
668        (cid.to_string(), data.to_vec(), metadata)
669    }
670
671    fn simple_blocks() -> Vec<(String, Vec<u8>, HashMap<String, String>)> {
672        vec![
673            make_block("QmAlpha", b"hello world", &[]),
674            make_block("QmBeta", b"foo bar baz", &[("pinned", "true")]),
675            make_block("QmGamma", b"compressed content", &[("compressed", "true")]),
676        ]
677    }
678
679    // ── 1. default config values ──────────────────────────────────────────────
680
681    #[test]
682    fn test_default_config_shard_count() {
683        let cfg = default_config();
684        assert_eq!(cfg.shard_count, 16);
685    }
686
687    #[test]
688    fn test_default_config_verify_checksums() {
689        let cfg = default_config();
690        assert!(cfg.verify_checksums);
691    }
692
693    #[test]
694    fn test_default_config_rebuild_missing_only() {
695        let cfg = default_config();
696        assert!(!cfg.rebuild_missing_only);
697    }
698
699    #[test]
700    fn test_default_config_max_errors() {
701        let cfg = default_config();
702        assert_eq!(cfg.max_errors, 100);
703    }
704
705    #[test]
706    fn test_default_config_batch_size() {
707        let cfg = default_config();
708        assert_eq!(cfg.batch_size, 1000);
709    }
710
711    // ── 2. checksum ───────────────────────────────────────────────────────────
712
713    #[test]
714    fn test_checksum_empty_slice() {
715        let cs = BlockScanEntry::compute_checksum(&[]);
716        // FNV-1a of empty bytes: hash stays at offset basis
717        let h: u64 = 14_695_981_039_346_656_037;
718        let expected = ((h >> 32) as u32) ^ (h as u32);
719        assert_eq!(cs, expected);
720    }
721
722    #[test]
723    fn test_checksum_single_byte() {
724        let cs = BlockScanEntry::compute_checksum(b"A");
725        assert_ne!(cs, 0);
726    }
727
728    #[test]
729    fn test_checksum_deterministic() {
730        let cs1 = BlockScanEntry::compute_checksum(b"hello world");
731        let cs2 = BlockScanEntry::compute_checksum(b"hello world");
732        assert_eq!(cs1, cs2);
733    }
734
735    #[test]
736    fn test_checksum_different_inputs_differ() {
737        let cs1 = BlockScanEntry::compute_checksum(b"foo");
738        let cs2 = BlockScanEntry::compute_checksum(b"bar");
739        assert_ne!(cs1, cs2);
740    }
741
742    // ── 3. assign_shard ───────────────────────────────────────────────────────
743
744    #[test]
745    fn test_assign_shard_in_range() {
746        let engine = BlockIndexRebuild::new(default_config());
747        let s = engine.assign_shard("QmFoo");
748        assert!(s < 16);
749    }
750
751    #[test]
752    fn test_assign_shard_deterministic() {
753        let engine = BlockIndexRebuild::new(default_config());
754        assert_eq!(engine.assign_shard("QmBar"), engine.assign_shard("QmBar"));
755    }
756
757    #[test]
758    fn test_assign_shard_single_shard() {
759        let cfg = RebuildConfig {
760            shard_count: 1,
761            ..Default::default()
762        };
763        let engine = BlockIndexRebuild::new(cfg);
764        assert_eq!(engine.assign_shard("anything"), 0);
765    }
766
767    // ── 4. assign_offset ─────────────────────────────────────────────────────
768
769    #[test]
770    fn test_assign_offset_zero() {
771        assert_eq!(BlockIndexRebuild::assign_offset(0), 0);
772    }
773
774    #[test]
775    fn test_assign_offset_page_aligned() {
776        assert_eq!(BlockIndexRebuild::assign_offset(1), 4_096);
777        assert_eq!(BlockIndexRebuild::assign_offset(2), 8_192);
778    }
779
780    #[test]
781    fn test_assign_offset_large_index() {
782        let offset = BlockIndexRebuild::assign_offset(1_000);
783        assert_eq!(offset, 1_000 * 4_096);
784    }
785
786    // ── 5. detect_flags ───────────────────────────────────────────────────────
787
788    #[test]
789    fn test_detect_flags_empty_meta() {
790        let meta = HashMap::new();
791        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x00);
792    }
793
794    #[test]
795    fn test_detect_flags_pinned() {
796        let mut meta = HashMap::new();
797        meta.insert("pinned".to_string(), "true".to_string());
798        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x01);
799    }
800
801    #[test]
802    fn test_detect_flags_compressed() {
803        let mut meta = HashMap::new();
804        meta.insert("compressed".to_string(), "true".to_string());
805        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x02);
806    }
807
808    #[test]
809    fn test_detect_flags_encrypted() {
810        let mut meta = HashMap::new();
811        meta.insert("encrypted".to_string(), "true".to_string());
812        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x04);
813    }
814
815    #[test]
816    fn test_detect_flags_all_three() {
817        let mut meta = HashMap::new();
818        meta.insert("pinned".to_string(), "true".to_string());
819        meta.insert("compressed".to_string(), "true".to_string());
820        meta.insert("encrypted".to_string(), "true".to_string());
821        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x07);
822    }
823
824    #[test]
825    fn test_detect_flags_false_values_ignored() {
826        let mut meta = HashMap::new();
827        meta.insert("pinned".to_string(), "false".to_string());
828        meta.insert("compressed".to_string(), "no".to_string());
829        assert_eq!(BlockIndexRebuild::detect_flags(&meta), 0x00);
830    }
831
832    // ── 6. load_blocks ────────────────────────────────────────────────────────
833
834    #[test]
835    fn test_load_blocks_count() {
836        let mut engine = BlockIndexRebuild::new(default_config());
837        engine.load_blocks(simple_blocks(), 1_000_000);
838        assert_eq!(engine.scan_entries.len(), 3);
839        assert_eq!(engine.progress.blocks_scanned, 3);
840    }
841
842    #[test]
843    fn test_load_blocks_sets_phase() {
844        let mut engine = BlockIndexRebuild::new(default_config());
845        engine.load_blocks(simple_blocks(), 42);
846        assert_eq!(engine.progress.phase, RebuildPhase::Scanning);
847    }
848
849    #[test]
850    fn test_load_blocks_records_started_at() {
851        let mut engine = BlockIndexRebuild::new(default_config());
852        engine.load_blocks(simple_blocks(), 999);
853        assert_eq!(engine.progress.started_at, 999);
854    }
855
856    #[test]
857    fn test_load_blocks_checksum_stored() {
858        let mut engine = BlockIndexRebuild::new(default_config());
859        let data = b"test data";
860        let expected = BlockScanEntry::compute_checksum(data);
861        engine.load_blocks(vec![make_block("QmTest", data, &[])], 0);
862        assert_eq!(engine.scan_entries[0].checksum, expected);
863    }
864
865    #[test]
866    fn test_load_blocks_size_bytes() {
867        let mut engine = BlockIndexRebuild::new(default_config());
868        let data = b"twelve bytes";
869        engine.load_blocks(vec![make_block("QmSize", data, &[])], 0);
870        assert_eq!(engine.scan_entries[0].size_bytes, data.len() as u64);
871    }
872
873    #[test]
874    fn test_load_blocks_verified_false_initially() {
875        let mut engine = BlockIndexRebuild::new(default_config());
876        engine.load_blocks(vec![make_block("QmV", b"data", &[])], 0);
877        // Before verify_phase the field starts false inside load_blocks,
878        // but verify_phase sets it. Here we only test load state.
879        // Actually load_blocks does NOT set verified; verify_phase does.
880        // verify_phase sets it to true, so after just load_blocks it is false.
881        assert!(!engine.scan_entries[0].verified);
882    }
883
884    // ── 7. load_existing_index ────────────────────────────────────────────────
885
886    #[test]
887    fn test_load_existing_index_populates() {
888        let mut engine = BlockIndexRebuild::new(default_config());
889        let entry = IndexEntry {
890            cid: "QmExist".to_string(),
891            offset: 0,
892            size: 100,
893            shard: 3,
894            flags: 0,
895        };
896        engine.load_existing_index(vec![entry.clone()]);
897        assert_eq!(engine.existing_index.len(), 1);
898        assert_eq!(engine.existing_index.get("QmExist"), Some(&entry));
899    }
900
901    // ── 8. verify_phase ───────────────────────────────────────────────────────
902
903    #[test]
904    fn test_verify_phase_marks_verified() {
905        let mut engine = BlockIndexRebuild::new(default_config());
906        engine.load_blocks(simple_blocks(), 0);
907        engine.verify_phase();
908        assert!(engine.scan_entries.iter().all(|e| e.verified));
909    }
910
911    #[test]
912    fn test_verify_phase_increments_counter() {
913        let mut engine = BlockIndexRebuild::new(default_config());
914        engine.load_blocks(simple_blocks(), 0);
915        engine.verify_phase();
916        assert_eq!(engine.progress.blocks_verified, 3);
917    }
918
919    #[test]
920    fn test_verify_phase_sets_phase() {
921        let mut engine = BlockIndexRebuild::new(default_config());
922        engine.load_blocks(simple_blocks(), 0);
923        engine.verify_phase();
924        assert_eq!(engine.progress.phase, RebuildPhase::Verifying);
925    }
926
927    // ── 9. rebuild_phase ─────────────────────────────────────────────────────
928
929    #[test]
930    fn test_rebuild_phase_builds_index() {
931        let mut engine = BlockIndexRebuild::new(default_config());
932        engine.load_blocks(simple_blocks(), 0);
933        engine.verify_phase();
934        engine.rebuild_phase(0);
935        assert_eq!(engine.index.len(), 3);
936        assert_eq!(engine.progress.blocks_rebuilt, 3);
937    }
938
939    #[test]
940    fn test_rebuild_phase_shard_range() {
941        let mut engine = BlockIndexRebuild::new(default_config());
942        engine.load_blocks(simple_blocks(), 0);
943        engine.verify_phase();
944        engine.rebuild_phase(0);
945        for entry in engine.index.values() {
946            assert!(entry.shard < 16);
947        }
948    }
949
950    #[test]
951    fn test_rebuild_phase_offset_page_aligned() {
952        let mut engine = BlockIndexRebuild::new(default_config());
953        engine.load_blocks(simple_blocks(), 0);
954        engine.verify_phase();
955        engine.rebuild_phase(0);
956        for entry in engine.index.values() {
957            assert_eq!(entry.offset % 4_096, 0);
958        }
959    }
960
961    #[test]
962    fn test_rebuild_phase_flags_pinned() {
963        let mut engine = BlockIndexRebuild::new(default_config());
964        engine.load_blocks(
965            vec![make_block("QmPinned", b"data", &[("pinned", "true")])],
966            0,
967        );
968        engine.verify_phase();
969        engine.rebuild_phase(0);
970        let entry = engine.index.get("QmPinned").expect("entry missing");
971        assert!(entry.is_pinned());
972    }
973
974    #[test]
975    fn test_rebuild_phase_rebuild_missing_only_skips_existing() {
976        let cfg = RebuildConfig {
977            rebuild_missing_only: true,
978            ..Default::default()
979        };
980        let mut engine = BlockIndexRebuild::new(cfg);
981        let existing = IndexEntry {
982            cid: "QmAlpha".to_string(),
983            offset: 0,
984            size: 11,
985            shard: 0,
986            flags: 0,
987        };
988        engine.load_existing_index(vec![existing]);
989        engine.load_blocks(simple_blocks(), 0);
990        engine.verify_phase();
991        engine.rebuild_phase(0);
992        // QmAlpha is in existing_index, so it should NOT be in the new index
993        assert!(!engine.index.contains_key("QmAlpha"));
994        // QmBeta and QmGamma should be rebuilt
995        assert!(engine.index.contains_key("QmBeta"));
996        assert!(engine.index.contains_key("QmGamma"));
997    }
998
999    // ── 10. validate_phase ────────────────────────────────────────────────────
1000
1001    #[test]
1002    fn test_validate_phase_complete_on_success() {
1003        let mut engine = BlockIndexRebuild::new(default_config());
1004        engine.load_blocks(simple_blocks(), 0);
1005        engine.verify_phase();
1006        engine.rebuild_phase(0);
1007        engine.validate_phase();
1008        assert_eq!(engine.progress.phase, RebuildPhase::Complete);
1009    }
1010
1011    #[test]
1012    fn test_validate_phase_failed_when_missing() {
1013        let mut engine = BlockIndexRebuild::new(default_config());
1014        engine.load_blocks(simple_blocks(), 0);
1015        // Skip rebuild_phase so index is empty
1016        engine.validate_phase();
1017        assert!(matches!(engine.progress.phase, RebuildPhase::Failed(_)));
1018    }
1019
1020    #[test]
1021    fn test_validate_phase_errors_recorded() {
1022        let mut engine = BlockIndexRebuild::new(default_config());
1023        engine.load_blocks(simple_blocks(), 0);
1024        engine.validate_phase();
1025        assert!(!engine.progress.errors.is_empty());
1026    }
1027
1028    // ── 11. run_full_rebuild ──────────────────────────────────────────────────
1029
1030    #[test]
1031    fn test_run_full_rebuild_complete() {
1032        let mut engine = BlockIndexRebuild::new(default_config());
1033        let progress = engine.run_full_rebuild(simple_blocks(), vec![], 12345);
1034        assert_eq!(progress.phase, RebuildPhase::Complete);
1035    }
1036
1037    #[test]
1038    fn test_run_full_rebuild_counts() {
1039        let mut engine = BlockIndexRebuild::new(default_config());
1040        let progress = engine.run_full_rebuild(simple_blocks(), vec![], 0);
1041        assert_eq!(progress.blocks_scanned, 3);
1042        assert_eq!(progress.blocks_verified, 3);
1043        assert_eq!(progress.blocks_rebuilt, 3);
1044    }
1045
1046    #[test]
1047    fn test_run_full_rebuild_no_errors() {
1048        let mut engine = BlockIndexRebuild::new(default_config());
1049        let progress = engine.run_full_rebuild(simple_blocks(), vec![], 0);
1050        assert!(progress.errors.is_empty());
1051    }
1052
1053    #[test]
1054    fn test_run_full_rebuild_with_existing() {
1055        let cfg = RebuildConfig {
1056            rebuild_missing_only: true,
1057            ..Default::default()
1058        };
1059        let mut engine = BlockIndexRebuild::new(cfg);
1060        let existing = IndexEntry {
1061            cid: "QmAlpha".to_string(),
1062            offset: 0,
1063            size: 11,
1064            shard: 5,
1065            flags: 0,
1066        };
1067        let progress = engine.run_full_rebuild(simple_blocks(), vec![existing], 0);
1068        assert_eq!(progress.phase, RebuildPhase::Complete);
1069        assert_eq!(engine.index_size(), 2);
1070    }
1071
1072    // ── 12. get_index_entry / index_size ─────────────────────────────────────
1073
1074    #[test]
1075    fn test_get_index_entry_found() {
1076        let mut engine = BlockIndexRebuild::new(default_config());
1077        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1078        assert!(engine.get_index_entry("QmAlpha").is_some());
1079    }
1080
1081    #[test]
1082    fn test_get_index_entry_not_found() {
1083        let engine = BlockIndexRebuild::new(default_config());
1084        assert!(engine.get_index_entry("QmNonexistent").is_none());
1085    }
1086
1087    #[test]
1088    fn test_index_size_empty() {
1089        let engine = BlockIndexRebuild::new(default_config());
1090        assert_eq!(engine.index_size(), 0);
1091    }
1092
1093    #[test]
1094    fn test_index_size_after_rebuild() {
1095        let mut engine = BlockIndexRebuild::new(default_config());
1096        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1097        assert_eq!(engine.index_size(), 3);
1098    }
1099
1100    // ── 13. rebuild_stats ────────────────────────────────────────────────────
1101
1102    #[test]
1103    fn test_rebuild_stats_phase_label() {
1104        let mut engine = BlockIndexRebuild::new(default_config());
1105        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1106        let stats = engine.rebuild_stats();
1107        assert_eq!(stats.phase, "Complete");
1108    }
1109
1110    #[test]
1111    fn test_rebuild_stats_error_count() {
1112        let mut engine = BlockIndexRebuild::new(default_config());
1113        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1114        let stats = engine.rebuild_stats();
1115        assert_eq!(stats.error_count, 0);
1116    }
1117
1118    // ── 14. RebuildPhase helpers ──────────────────────────────────────────────
1119
1120    #[test]
1121    fn test_phase_label_scanning() {
1122        assert_eq!(RebuildPhase::Scanning.label(), "Scanning");
1123    }
1124
1125    #[test]
1126    fn test_phase_label_failed() {
1127        assert_eq!(RebuildPhase::Failed("oops".to_string()).label(), "Failed");
1128    }
1129
1130    #[test]
1131    fn test_phase_label_complete() {
1132        assert_eq!(RebuildPhase::Complete.label(), "Complete");
1133    }
1134
1135    // ── 15. IndexEntry flag helpers ───────────────────────────────────────────
1136
1137    #[test]
1138    fn test_index_entry_is_pinned() {
1139        let e = IndexEntry {
1140            cid: "c".to_string(),
1141            offset: 0,
1142            size: 0,
1143            shard: 0,
1144            flags: 0x01,
1145        };
1146        assert!(e.is_pinned());
1147        assert!(!e.is_compressed());
1148        assert!(!e.is_encrypted());
1149    }
1150
1151    #[test]
1152    fn test_index_entry_is_compressed() {
1153        let e = IndexEntry {
1154            cid: "c".to_string(),
1155            offset: 0,
1156            size: 0,
1157            shard: 0,
1158            flags: 0x02,
1159        };
1160        assert!(e.is_compressed());
1161    }
1162
1163    #[test]
1164    fn test_index_entry_is_encrypted() {
1165        let e = IndexEntry {
1166            cid: "c".to_string(),
1167            offset: 0,
1168            size: 0,
1169            shard: 0,
1170            flags: 0x04,
1171        };
1172        assert!(e.is_encrypted());
1173    }
1174
1175    #[test]
1176    fn test_index_entry_all_flags() {
1177        let e = IndexEntry {
1178            cid: "c".to_string(),
1179            offset: 0,
1180            size: 0,
1181            shard: 0,
1182            flags: 0x07,
1183        };
1184        assert!(e.is_pinned());
1185        assert!(e.is_compressed());
1186        assert!(e.is_encrypted());
1187    }
1188
1189    // ── 16. Utility helpers ───────────────────────────────────────────────────
1190
1191    #[test]
1192    fn test_reset_clears_state() {
1193        let mut engine = BlockIndexRebuild::new(default_config());
1194        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1195        engine.reset();
1196        assert_eq!(engine.scan_entries.len(), 0);
1197        assert_eq!(engine.index.len(), 0);
1198        assert_eq!(engine.progress.blocks_scanned, 0);
1199    }
1200
1201    #[test]
1202    fn test_is_finished_true_after_complete() {
1203        let mut engine = BlockIndexRebuild::new(default_config());
1204        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1205        assert!(engine.is_finished());
1206    }
1207
1208    #[test]
1209    fn test_is_finished_false_before_run() {
1210        let engine = BlockIndexRebuild::new(default_config());
1211        assert!(!engine.is_finished());
1212    }
1213
1214    #[test]
1215    fn test_is_successful_after_clean_run() {
1216        let mut engine = BlockIndexRebuild::new(default_config());
1217        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1218        assert!(engine.is_successful());
1219    }
1220
1221    #[test]
1222    fn test_merge_index_does_not_overwrite() {
1223        let mut engine = BlockIndexRebuild::new(default_config());
1224        engine.run_full_rebuild(vec![make_block("QmAlpha", b"hello world", &[])], vec![], 0);
1225        let old_offset = engine.index.get("QmAlpha").expect("missing").offset;
1226        let incoming = IndexEntry {
1227            cid: "QmAlpha".to_string(),
1228            offset: 9_999_999,
1229            size: 0,
1230            shard: 0,
1231            flags: 0,
1232        };
1233        engine.merge_index(vec![incoming]);
1234        // Should not overwrite
1235        assert_eq!(
1236            engine.index.get("QmAlpha").expect("missing").offset,
1237            old_offset
1238        );
1239    }
1240
1241    #[test]
1242    fn test_upsert_index_entry_overwrites() {
1243        let mut engine = BlockIndexRebuild::new(default_config());
1244        engine.run_full_rebuild(vec![make_block("QmU", b"data", &[])], vec![], 0);
1245        let new_entry = IndexEntry {
1246            cid: "QmU".to_string(),
1247            offset: 77_000,
1248            size: 42,
1249            shard: 7,
1250            flags: 0x03,
1251        };
1252        engine.upsert_index_entry(new_entry.clone());
1253        assert_eq!(engine.index.get("QmU"), Some(&new_entry));
1254    }
1255
1256    #[test]
1257    fn test_remove_index_entry() {
1258        let mut engine = BlockIndexRebuild::new(default_config());
1259        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1260        let removed = engine.remove_index_entry("QmAlpha");
1261        assert!(removed.is_some());
1262        assert!(engine.get_index_entry("QmAlpha").is_none());
1263    }
1264
1265    #[test]
1266    fn test_pinned_entries_filter() {
1267        let mut engine = BlockIndexRebuild::new(default_config());
1268        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1269        let pinned = engine.pinned_entries();
1270        // QmBeta has pinned=true
1271        assert!(pinned.iter().any(|e| e.cid == "QmBeta"));
1272    }
1273
1274    #[test]
1275    fn test_compressed_entries_filter() {
1276        let mut engine = BlockIndexRebuild::new(default_config());
1277        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1278        let compressed = engine.compressed_entries();
1279        // QmGamma has compressed=true
1280        assert!(compressed.iter().any(|e| e.cid == "QmGamma"));
1281    }
1282
1283    #[test]
1284    fn test_entries_in_shard_returns_subset() {
1285        let mut engine = BlockIndexRebuild::new(default_config());
1286        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1287        // At least one shard must exist; verify the total counts add up
1288        let total: usize = (0..16_u8).map(|s| engine.entries_in_shard(s).len()).sum();
1289        assert_eq!(total, engine.index_size());
1290    }
1291
1292    #[test]
1293    fn test_export_index_length() {
1294        let mut engine = BlockIndexRebuild::new(default_config());
1295        engine.run_full_rebuild(simple_blocks(), vec![], 0);
1296        assert_eq!(engine.export_index().len(), 3);
1297    }
1298
1299    #[test]
1300    fn test_verify_with_data_detects_mismatch() {
1301        let mut engine = BlockIndexRebuild::new(default_config());
1302        // Load with tampered checksum by inserting a scan entry manually
1303        engine.progress.started_at = 1;
1304        engine.progress.blocks_scanned = 1;
1305        // Manually insert a scan entry with a wrong checksum
1306        engine
1307            .scan_entries
1308            .push(crate::block_index_rebuild::BlockScanEntry {
1309                cid: "QmTampered".to_string(),
1310                size_bytes: 5,
1311                checksum: 0xDEAD_BEEF, // intentionally wrong
1312                verified: false,
1313                metadata: HashMap::new(),
1314            });
1315        // Supply real data (different from what produced 0xDEAD_BEEF)
1316        engine.verify_with_data(vec![("QmTampered".to_string(), b"hello".to_vec())]);
1317        // Should record an error and NOT mark verified
1318        let entry = &engine.scan_entries[0];
1319        assert!(!entry.verified);
1320        assert!(!engine.progress.errors.is_empty());
1321    }
1322
1323    #[test]
1324    fn test_unverified_entries_empty_after_verify() {
1325        let mut engine = BlockIndexRebuild::new(default_config());
1326        engine.load_blocks(simple_blocks(), 0);
1327        engine.verify_phase();
1328        assert_eq!(engine.unverified_entries().len(), 0);
1329    }
1330
1331    // ── 17. temp_dir usage (file-backed simulation) ───────────────────────────
1332
1333    #[test]
1334    fn test_temp_dir_accessible() {
1335        let tmp = temp_dir();
1336        assert!(tmp.exists());
1337    }
1338
1339    #[test]
1340    fn test_large_batch_rebuild() {
1341        let mut engine = BlockIndexRebuild::new(default_config());
1342        let blocks: Vec<_> = (0..200)
1343            .map(|i| {
1344                make_block(
1345                    &format!("QmBlock{i:04}"),
1346                    format!("data for block {i}").as_bytes(),
1347                    &[],
1348                )
1349            })
1350            .collect();
1351        let progress = engine.run_full_rebuild(blocks, vec![], 0);
1352        assert_eq!(progress.blocks_scanned, 200);
1353        assert_eq!(progress.blocks_rebuilt, 200);
1354        assert_eq!(progress.phase, RebuildPhase::Complete);
1355    }
1356
1357    #[test]
1358    fn test_max_errors_cap() {
1359        let cfg = RebuildConfig {
1360            max_errors: 5,
1361            ..Default::default()
1362        };
1363        let mut engine = BlockIndexRebuild::new(cfg);
1364        // Validate with empty index to generate many errors
1365        let blocks: Vec<_> = (0..20)
1366            .map(|i| make_block(&format!("QmX{i}"), b"d", &[]))
1367            .collect();
1368        engine.load_blocks(blocks, 0);
1369        // Do NOT verify or rebuild so validate will find 20 missing entries
1370        engine.validate_phase();
1371        // Errors should be capped at max_errors = 5
1372        assert!(engine.progress.errors.len() <= 5);
1373    }
1374}