Skip to main content

ipfrs_storage/
mirror_sync.rs

1//! Bidirectional sync between storage mirrors with conflict detection, resolution, and audit.
2//!
3//! # Overview
4//!
5//! `StorageMirrorSync` manages synchronization between a local mirror and one or more remote
6//! mirrors. It computes diff plans, resolves conflicts according to configurable policies,
7//! applies operations, and maintains a capped audit log for observability.
8//!
9//! # Example
10//!
11//! ```rust
12//! use ipfrs_storage::mirror_sync::{
13//!     StorageMirrorSync, MirrorId, SyncItem, MsConflictResolution,
14//! };
15//!
16//! let local_id = MirrorId("local".to_string());
17//! let remote_id = MirrorId("remote-1".to_string());
18//! let mut sync = StorageMirrorSync::new(local_id, MsConflictResolution::TakeNewest);
19//! sync.register_mirror(remote_id.clone());
20//!
21//! let item = SyncItem::new("Qm1234".to_string(), 100, 0, MirrorId("local".to_string()));
22//! sync.update_local(item);
23//! let plan = sync.diff(&remote_id);
24//! assert_eq!(plan.operations.len(), 1);
25//! ```
26
27use std::collections::{HashMap, VecDeque};
28
29// ── Audit-log capacity ────────────────────────────────────────────────────────
30const AUDIT_LOG_CAPACITY: usize = 1000;
31
32// ── FNV-1a 64-bit hash ────────────────────────────────────────────────────────
33
34/// Computes the FNV-1a 64-bit checksum of a byte slice.
35#[inline]
36pub fn fnv1a_64(data: &[u8]) -> u64 {
37    const OFFSET: u64 = 14_695_981_039_346_656_037;
38    const PRIME: u64 = 1_099_511_628_211;
39    data.iter()
40        .fold(OFFSET, |acc, &b| (acc ^ u64::from(b)).wrapping_mul(PRIME))
41}
42
43// ── MirrorId ──────────────────────────────────────────────────────────────────
44
45/// Newtype wrapper for mirror identifiers.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
47pub struct MirrorId(pub String);
48
49impl MirrorId {
50    /// Creates a new `MirrorId` from a string.
51    pub fn new(id: impl Into<String>) -> Self {
52        Self(id.into())
53    }
54
55    /// Returns the inner string slice.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl std::fmt::Display for MirrorId {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.0)
64    }
65}
66
67// ── SyncItem ──────────────────────────────────────────────────────────────────
68
69/// A single item tracked for synchronisation across mirrors.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct SyncItem {
72    /// Content Identifier (CID) of the block.
73    pub cid: String,
74    /// FNV-1a-64 checksum of the CID bytes.
75    pub checksum: u64,
76    /// Block size in bytes.
77    pub size_bytes: u64,
78    /// Monotonically increasing version number.
79    pub version: u64,
80    /// UNIX timestamp (ms) of the last modification.
81    pub last_modified: u64,
82    /// The mirror that owns / sourced this item.
83    pub mirror_id: MirrorId,
84}
85
86impl SyncItem {
87    /// Creates a `SyncItem`, computing the FNV-1a-64 checksum from the CID bytes.
88    pub fn new(cid: String, size_bytes: u64, last_modified: u64, mirror_id: MirrorId) -> Self {
89        let checksum = fnv1a_64(cid.as_bytes());
90        Self {
91            cid,
92            checksum,
93            size_bytes,
94            version: 1,
95            last_modified,
96            mirror_id,
97        }
98    }
99
100    /// Creates a `SyncItem` with all fields specified explicitly.
101    pub fn with_version(
102        cid: String,
103        size_bytes: u64,
104        version: u64,
105        last_modified: u64,
106        mirror_id: MirrorId,
107    ) -> Self {
108        let checksum = fnv1a_64(cid.as_bytes());
109        Self {
110            cid,
111            checksum,
112            size_bytes,
113            version,
114            last_modified,
115            mirror_id,
116        }
117    }
118
119    /// Creates a `SyncItem` with an explicitly provided checksum (for testing / deserialization).
120    pub fn with_checksum(
121        cid: String,
122        checksum: u64,
123        size_bytes: u64,
124        version: u64,
125        last_modified: u64,
126        mirror_id: MirrorId,
127    ) -> Self {
128        Self {
129            cid,
130            checksum,
131            size_bytes,
132            version,
133            last_modified,
134            mirror_id,
135        }
136    }
137}
138
139// ── ConflictType ──────────────────────────────────────────────────────────────
140
141/// Classifies the nature of a synchronisation conflict.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ConflictType {
144    /// Local and remote have different version numbers.
145    VersionConflict,
146    /// Local and remote have different checksums.
147    ChecksumMismatch,
148    /// Local and remote have different sizes.
149    SizeConflict,
150    /// One side deleted the item while the other modified it.
151    DeleteVsModify,
152}
153
154// ── MsConflictResolution ──────────────────────────────────────────────────────
155
156/// Strategy for automatically resolving a sync conflict.
157///
158/// Prefixed with `Ms` to avoid clashing with the crate-level re-export of
159/// `eventual_consistency::ConflictResolution`.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum MsConflictResolution {
162    /// Keep the local copy; upload to remote.
163    TakeLocal,
164    /// Keep the remote copy; download to local.
165    TakeRemote,
166    /// Keep whichever has the newer `last_modified` timestamp.
167    TakeNewest,
168    /// Keep whichever has the larger `size_bytes`.
169    TakeLargest,
170    /// Do nothing — leave the conflict unresolved in the plan.
171    Skip,
172}
173
174// ── SyncConflict ─────────────────────────────────────────────────────────────
175
176/// Describes a conflict between local and remote versions of the same CID.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SyncConflict {
179    /// The conflicting CID.
180    pub cid: String,
181    /// Local copy of the item.
182    pub local_item: SyncItem,
183    /// Remote copy of the item.
184    pub remote_item: SyncItem,
185    /// Classification of the conflict.
186    pub conflict_type: ConflictType,
187}
188
189impl SyncConflict {
190    /// Classifies a conflict between two `SyncItem`s that share the same CID.
191    pub fn classify(local_item: SyncItem, remote_item: SyncItem) -> Self {
192        let conflict_type = if local_item.checksum != remote_item.checksum {
193            ConflictType::ChecksumMismatch
194        } else if local_item.version != remote_item.version {
195            ConflictType::VersionConflict
196        } else if local_item.size_bytes != remote_item.size_bytes {
197            ConflictType::SizeConflict
198        } else {
199            ConflictType::ChecksumMismatch
200        };
201        let cid = local_item.cid.clone();
202        Self {
203            cid,
204            local_item,
205            remote_item,
206            conflict_type,
207        }
208    }
209}
210
211// ── SyncOperation ─────────────────────────────────────────────────────────────
212
213/// A single operation within a `SyncPlan`.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum SyncOperation {
216    /// Send a block from local to a remote mirror.
217    Upload {
218        /// CID of the block to send.
219        cid: String,
220        /// Destination mirror.
221        to_mirror: MirrorId,
222    },
223    /// Fetch a block from a remote mirror to local.
224    Download {
225        /// CID of the block to fetch.
226        cid: String,
227        /// Source mirror.
228        from_mirror: MirrorId,
229    },
230    /// Remove a block from a mirror.
231    Delete {
232        /// CID of the block to remove.
233        cid: String,
234        /// Mirror to remove it from.
235        from_mirror: MirrorId,
236    },
237    /// Resolve a conflict according to a specified strategy.
238    Resolve {
239        /// The detected conflict.
240        conflict: SyncConflict,
241        /// The chosen resolution strategy.
242        resolution: MsConflictResolution,
243    },
244}
245
246// ── SyncPlan ─────────────────────────────────────────────────────────────────
247
248/// The result of a `diff` computation — a list of operations and unresolved conflicts.
249#[derive(Debug, Clone)]
250pub struct SyncPlan {
251    /// Ordered list of operations to execute.
252    pub operations: Vec<SyncOperation>,
253    /// Conflicts that were left unresolved (e.g. `Skip` strategy).
254    pub conflicts: Vec<SyncConflict>,
255    /// Total bytes that would be transferred if the plan is applied.
256    pub estimated_bytes: u64,
257}
258
259impl SyncPlan {
260    /// Returns `true` when there are no operations and no unresolved conflicts.
261    pub fn is_empty(&self) -> bool {
262        self.operations.is_empty() && self.conflicts.is_empty()
263    }
264}
265
266// ── MsSyncResult ─────────────────────────────────────────────────────────────
267
268/// Summary of what happened after applying a `SyncPlan`.
269///
270/// Prefixed with `Ms` to avoid clashing with the crate-level re-export of
271/// `replication::SyncResult`.
272#[derive(Debug, Clone, Default)]
273pub struct MsSyncResult {
274    /// Number of operations that were executed.
275    pub operations_executed: usize,
276    /// Total bytes transferred.
277    pub bytes_transferred: u64,
278    /// Number of conflicts that were resolved.
279    pub conflicts_resolved: usize,
280    /// Non-fatal errors encountered during execution.
281    pub errors: Vec<String>,
282    /// Wall-clock duration of the apply call (in milliseconds).
283    pub duration_ms: u64,
284}
285
286// ── MirrorSyncStats ───────────────────────────────────────────────────────────
287
288/// Aggregate statistics tracked across all `apply_plan` calls.
289#[derive(Debug, Clone, Default)]
290pub struct MirrorSyncStats {
291    /// Number of items in the local state.
292    pub local_items: usize,
293    /// Number of registered remote mirrors.
294    pub remote_mirrors: usize,
295    /// Total number of conflicts detected across all diffs.
296    pub total_conflicts_detected: u64,
297    /// Total number of operations executed across all apply calls.
298    pub total_operations: u64,
299    /// Total bytes transferred across all apply calls.
300    pub bytes_transferred: u64,
301}
302
303// ── StorageMirrorSync ─────────────────────────────────────────────────────────
304
305/// Bidirectional synchronisation between storage mirrors.
306///
307/// Maintains the local state, one or more remote mirror states, resolves
308/// conflicts, executes sync plans, and retains a bounded audit log.
309pub struct StorageMirrorSync {
310    /// The identifier for the local mirror.
311    pub local_id: MirrorId,
312    /// Current state of the local mirror (CID → SyncItem).
313    pub local_state: HashMap<String, SyncItem>,
314    /// States of all registered remote mirrors.
315    pub remote_states: HashMap<MirrorId, HashMap<String, SyncItem>>,
316    /// Default strategy for automatically resolving conflicts.
317    pub default_resolution: MsConflictResolution,
318    /// Bounded audit log of executed operations (newest at the back).
319    pub audit_log: VecDeque<SyncOperation>,
320
321    // ── aggregate counters ──
322    total_conflicts_detected: u64,
323    total_operations: u64,
324    total_bytes_transferred: u64,
325}
326
327impl StorageMirrorSync {
328    // ── Constructors ─────────────────────────────────────────────────────────
329
330    /// Creates a new `StorageMirrorSync` with the given local identifier and default conflict
331    /// resolution strategy.
332    pub fn new(local_id: MirrorId, default_resolution: MsConflictResolution) -> Self {
333        Self {
334            local_id,
335            local_state: HashMap::new(),
336            remote_states: HashMap::new(),
337            default_resolution,
338            audit_log: VecDeque::new(),
339            total_conflicts_detected: 0,
340            total_operations: 0,
341            total_bytes_transferred: 0,
342        }
343    }
344
345    // ── Mirror registration ───────────────────────────────────────────────────
346
347    /// Registers a remote mirror.  If the mirror is already registered this is a no-op.
348    pub fn register_mirror(&mut self, mirror_id: MirrorId) {
349        self.remote_states.entry(mirror_id).or_default();
350    }
351
352    // ── Local state mutations ─────────────────────────────────────────────────
353
354    /// Inserts or updates an item in the local state.
355    ///
356    /// Returns `true` if the CID was not previously present (new insertion).
357    pub fn update_local(&mut self, item: SyncItem) -> bool {
358        let is_new = !self.local_state.contains_key(&item.cid);
359        self.local_state.insert(item.cid.clone(), item);
360        is_new
361    }
362
363    /// Removes an item from the local state by CID.
364    ///
365    /// Returns `true` if the item existed and was removed.
366    pub fn remove_local(&mut self, cid: &str) -> bool {
367        self.local_state.remove(cid).is_some()
368    }
369
370    // ── Remote state mutations ────────────────────────────────────────────────
371
372    /// Inserts or updates an item in a remote mirror's state.
373    ///
374    /// Returns `true` if the CID was not previously present in that mirror.
375    /// Returns `false` if the mirror is not registered.
376    pub fn update_remote(&mut self, mirror_id: &MirrorId, item: SyncItem) -> bool {
377        match self.remote_states.get_mut(mirror_id) {
378            Some(state) => {
379                let is_new = !state.contains_key(&item.cid);
380                state.insert(item.cid.clone(), item);
381                is_new
382            }
383            None => false,
384        }
385    }
386
387    // ── Diff ──────────────────────────────────────────────────────────────────
388
389    /// Computes the set of operations needed to synchronise the local mirror with `mirror_id`.
390    ///
391    /// Conflict resolution follows `self.default_resolution`.
392    pub fn diff(&self, mirror_id: &MirrorId) -> SyncPlan {
393        let remote_state = match self.remote_states.get(mirror_id) {
394            Some(s) => s,
395            None => {
396                return SyncPlan {
397                    operations: Vec::new(),
398                    conflicts: Vec::new(),
399                    estimated_bytes: 0,
400                }
401            }
402        };
403
404        let mut operations: Vec<SyncOperation> = Vec::new();
405        let mut conflicts: Vec<SyncConflict> = Vec::new();
406        let mut estimated_bytes: u64 = 0;
407
408        // Items in local but not remote → Upload
409        for (cid, local_item) in &self.local_state {
410            if !remote_state.contains_key(cid) {
411                estimated_bytes = estimated_bytes.saturating_add(local_item.size_bytes);
412                operations.push(SyncOperation::Upload {
413                    cid: cid.clone(),
414                    to_mirror: mirror_id.clone(),
415                });
416            }
417        }
418
419        // Items in remote but not local → Download
420        // Items in both with different checksums → conflict
421        for (cid, remote_item) in remote_state {
422            match self.local_state.get(cid) {
423                None => {
424                    estimated_bytes = estimated_bytes.saturating_add(remote_item.size_bytes);
425                    operations.push(SyncOperation::Download {
426                        cid: cid.clone(),
427                        from_mirror: mirror_id.clone(),
428                    });
429                }
430                Some(local_item) => {
431                    if local_item.checksum != remote_item.checksum {
432                        let conflict =
433                            SyncConflict::classify(local_item.clone(), remote_item.clone());
434                        // (conflict counter is updated in apply_plan, not here)
435                        let resolution = self.resolve_conflict(&conflict);
436                        match &resolution {
437                            MsConflictResolution::TakeLocal => {
438                                estimated_bytes =
439                                    estimated_bytes.saturating_add(local_item.size_bytes);
440                                operations.push(SyncOperation::Resolve {
441                                    conflict: conflict.clone(),
442                                    resolution: MsConflictResolution::TakeLocal,
443                                });
444                            }
445                            MsConflictResolution::TakeRemote => {
446                                estimated_bytes =
447                                    estimated_bytes.saturating_add(remote_item.size_bytes);
448                                operations.push(SyncOperation::Resolve {
449                                    conflict: conflict.clone(),
450                                    resolution: MsConflictResolution::TakeRemote,
451                                });
452                            }
453                            MsConflictResolution::TakeNewest => {
454                                let winner_bytes =
455                                    if local_item.last_modified >= remote_item.last_modified {
456                                        local_item.size_bytes
457                                    } else {
458                                        remote_item.size_bytes
459                                    };
460                                estimated_bytes = estimated_bytes.saturating_add(winner_bytes);
461                                operations.push(SyncOperation::Resolve {
462                                    conflict: conflict.clone(),
463                                    resolution: MsConflictResolution::TakeNewest,
464                                });
465                            }
466                            MsConflictResolution::TakeLargest => {
467                                let winner_bytes =
468                                    local_item.size_bytes.max(remote_item.size_bytes);
469                                estimated_bytes = estimated_bytes.saturating_add(winner_bytes);
470                                operations.push(SyncOperation::Resolve {
471                                    conflict: conflict.clone(),
472                                    resolution: MsConflictResolution::TakeLargest,
473                                });
474                            }
475                            MsConflictResolution::Skip => {
476                                conflicts.push(conflict);
477                            }
478                        }
479                    }
480                }
481            }
482        }
483
484        SyncPlan {
485            operations,
486            conflicts,
487            estimated_bytes,
488        }
489    }
490
491    /// Chooses a `MsConflictResolution` for a conflict, honouring `default_resolution`.
492    fn resolve_conflict(&self, _conflict: &SyncConflict) -> MsConflictResolution {
493        self.default_resolution.clone()
494    }
495
496    // ── Apply plan ────────────────────────────────────────────────────────────
497
498    /// Simulates executing all operations in `plan`, updating local/remote states and audit log.
499    ///
500    /// `now` is the UNIX timestamp (ms) to stamp on newly created items.
501    pub fn apply_plan(&mut self, plan: &SyncPlan, now: u64) -> MsSyncResult {
502        let start = now;
503        let mut result = MsSyncResult::default();
504
505        for op in &plan.operations {
506            match op {
507                SyncOperation::Upload { cid, to_mirror } => {
508                    if let Some(local_item) = self.local_state.get(cid).cloned() {
509                        let size = local_item.size_bytes;
510                        let mut remote_item = local_item;
511                        remote_item.mirror_id = to_mirror.clone();
512                        if let Some(remote_state) = self.remote_states.get_mut(to_mirror) {
513                            remote_state.insert(cid.clone(), remote_item);
514                        } else {
515                            result.errors.push(format!(
516                                "Upload failed: mirror '{}' not registered",
517                                to_mirror
518                            ));
519                            continue;
520                        }
521                        result.bytes_transferred = result.bytes_transferred.saturating_add(size);
522                    } else {
523                        result
524                            .errors
525                            .push(format!("Upload failed: CID '{}' not in local state", cid));
526                        continue;
527                    }
528                }
529
530                SyncOperation::Download { cid, from_mirror } => {
531                    // Retrieve from remote and store locally
532                    let remote_item_opt = self
533                        .remote_states
534                        .get(from_mirror)
535                        .and_then(|s| s.get(cid))
536                        .cloned();
537
538                    if let Some(remote_item) = remote_item_opt {
539                        let size = remote_item.size_bytes;
540                        let mut local_item = remote_item;
541                        local_item.mirror_id = self.local_id.clone();
542                        self.local_state.insert(cid.clone(), local_item);
543                        result.bytes_transferred = result.bytes_transferred.saturating_add(size);
544                    } else {
545                        result.errors.push(format!(
546                            "Download failed: CID '{}' not in remote mirror '{}'",
547                            cid, from_mirror
548                        ));
549                        continue;
550                    }
551                }
552
553                SyncOperation::Delete { cid, from_mirror } => {
554                    let removed = if *from_mirror == self.local_id {
555                        self.local_state.remove(cid).is_some()
556                    } else if let Some(remote_state) = self.remote_states.get_mut(from_mirror) {
557                        remote_state.remove(cid).is_some()
558                    } else {
559                        false
560                    };
561                    if !removed {
562                        result.errors.push(format!(
563                            "Delete failed: CID '{}' not found in mirror '{}'",
564                            cid, from_mirror
565                        ));
566                        continue;
567                    }
568                }
569
570                SyncOperation::Resolve {
571                    conflict,
572                    resolution,
573                } => {
574                    match resolution {
575                        MsConflictResolution::TakeLocal => {
576                            // Upload local to remote (all remotes that have this CID)
577                            let local_opt = self.local_state.get(&conflict.cid).cloned();
578                            if let Some(local_item) = local_opt {
579                                let size = local_item.size_bytes;
580                                let remote_mirror = conflict.remote_item.mirror_id.clone();
581                                if let Some(remote_state) =
582                                    self.remote_states.get_mut(&remote_mirror)
583                                {
584                                    let mut item = local_item;
585                                    item.mirror_id = remote_mirror.clone();
586                                    remote_state.insert(conflict.cid.clone(), item);
587                                }
588                                result.bytes_transferred =
589                                    result.bytes_transferred.saturating_add(size);
590                            }
591                        }
592                        MsConflictResolution::TakeRemote => {
593                            // Download remote to local
594                            let remote_item_opt = self
595                                .remote_states
596                                .get(&conflict.remote_item.mirror_id)
597                                .and_then(|s| s.get(&conflict.cid))
598                                .cloned();
599                            if let Some(remote_item) = remote_item_opt {
600                                let size = remote_item.size_bytes;
601                                let mut item = remote_item;
602                                item.mirror_id = self.local_id.clone();
603                                self.local_state.insert(conflict.cid.clone(), item);
604                                result.bytes_transferred =
605                                    result.bytes_transferred.saturating_add(size);
606                            }
607                        }
608                        MsConflictResolution::TakeNewest => {
609                            let local_item = self.local_state.get(&conflict.cid).cloned();
610                            let remote_item = self
611                                .remote_states
612                                .get(&conflict.remote_item.mirror_id)
613                                .and_then(|s| s.get(&conflict.cid))
614                                .cloned();
615
616                            match (local_item, remote_item) {
617                                (Some(l), Some(r)) => {
618                                    if l.last_modified >= r.last_modified {
619                                        // local wins → push to remote
620                                        let size = l.size_bytes;
621                                        let remote_mirror = r.mirror_id.clone();
622                                        if let Some(rs) = self.remote_states.get_mut(&remote_mirror)
623                                        {
624                                            let mut item = l;
625                                            item.mirror_id = remote_mirror.clone();
626                                            rs.insert(conflict.cid.clone(), item);
627                                        }
628                                        result.bytes_transferred =
629                                            result.bytes_transferred.saturating_add(size);
630                                    } else {
631                                        // remote wins → pull to local
632                                        let size = r.size_bytes;
633                                        let mut item = r;
634                                        item.mirror_id = self.local_id.clone();
635                                        self.local_state.insert(conflict.cid.clone(), item);
636                                        result.bytes_transferred =
637                                            result.bytes_transferred.saturating_add(size);
638                                    }
639                                }
640                                _ => {
641                                    result.errors.push(format!(
642                                        "TakeNewest: item '{}' missing from one side",
643                                        conflict.cid
644                                    ));
645                                    continue;
646                                }
647                            }
648                        }
649                        MsConflictResolution::TakeLargest => {
650                            let local_item = self.local_state.get(&conflict.cid).cloned();
651                            let remote_item = self
652                                .remote_states
653                                .get(&conflict.remote_item.mirror_id)
654                                .and_then(|s| s.get(&conflict.cid))
655                                .cloned();
656
657                            match (local_item, remote_item) {
658                                (Some(l), Some(r)) => {
659                                    if l.size_bytes >= r.size_bytes {
660                                        // local wins → push to remote
661                                        let size = l.size_bytes;
662                                        let remote_mirror = r.mirror_id.clone();
663                                        if let Some(rs) = self.remote_states.get_mut(&remote_mirror)
664                                        {
665                                            let mut item = l;
666                                            item.mirror_id = remote_mirror.clone();
667                                            rs.insert(conflict.cid.clone(), item);
668                                        }
669                                        result.bytes_transferred =
670                                            result.bytes_transferred.saturating_add(size);
671                                    } else {
672                                        // remote wins → pull to local
673                                        let size = r.size_bytes;
674                                        let mut item = r;
675                                        item.mirror_id = self.local_id.clone();
676                                        self.local_state.insert(conflict.cid.clone(), item);
677                                        result.bytes_transferred =
678                                            result.bytes_transferred.saturating_add(size);
679                                    }
680                                }
681                                _ => {
682                                    result.errors.push(format!(
683                                        "TakeLargest: item '{}' missing from one side",
684                                        conflict.cid
685                                    ));
686                                    continue;
687                                }
688                            }
689                        }
690                        MsConflictResolution::Skip => {
691                            // No state change; still record in audit
692                        }
693                    }
694
695                    result.conflicts_resolved += 1;
696                }
697            }
698
699            // Append to audit log, capping at AUDIT_LOG_CAPACITY
700            self.audit_log.push_back(op.clone());
701            if self.audit_log.len() > AUDIT_LOG_CAPACITY {
702                self.audit_log.pop_front();
703            }
704            result.operations_executed += 1;
705        }
706
707        // Accumulate global counters
708        self.total_conflicts_detected = self
709            .total_conflicts_detected
710            .saturating_add(plan.conflicts.len() as u64);
711        self.total_operations = self
712            .total_operations
713            .saturating_add(result.operations_executed as u64);
714        self.total_bytes_transferred = self
715            .total_bytes_transferred
716            .saturating_add(result.bytes_transferred);
717
718        result.duration_ms = now.saturating_sub(start); // Simulated; single-threaded ≡ 0
719        result
720    }
721
722    // ── Conflict detection ────────────────────────────────────────────────────
723
724    /// Returns all CIDs present in both local and `mirror_id` that have differing checksums.
725    pub fn detect_conflicts(&self, mirror_id: &MirrorId) -> Vec<SyncConflict> {
726        let remote_state = match self.remote_states.get(mirror_id) {
727            Some(s) => s,
728            None => return Vec::new(),
729        };
730
731        let mut result: Vec<SyncConflict> = Vec::new();
732        for (cid, remote_item) in remote_state {
733            if let Some(local_item) = self.local_state.get(cid) {
734                if local_item.checksum != remote_item.checksum {
735                    result.push(SyncConflict::classify(
736                        local_item.clone(),
737                        remote_item.clone(),
738                    ));
739                }
740            }
741        }
742        result.sort_by(|a, b| a.cid.cmp(&b.cid));
743        result
744    }
745
746    // ── CID queries ───────────────────────────────────────────────────────────
747
748    /// Returns all CIDs present only in the local state (not in `mirror_id`), sorted.
749    pub fn local_only_cids(&self, mirror_id: &MirrorId) -> Vec<&str> {
750        let remote_state = match self.remote_states.get(mirror_id) {
751            Some(s) => s,
752            None => {
753                let mut all: Vec<&str> = self.local_state.keys().map(|s| s.as_str()).collect();
754                all.sort_unstable();
755                return all;
756            }
757        };
758
759        let mut result: Vec<&str> = self
760            .local_state
761            .keys()
762            .filter(|cid| !remote_state.contains_key(*cid))
763            .map(|s| s.as_str())
764            .collect();
765        result.sort_unstable();
766        result
767    }
768
769    /// Returns all CIDs present only in `mirror_id` (not in local state), sorted.
770    pub fn remote_only_cids(&self, mirror_id: &MirrorId) -> Vec<&str> {
771        let remote_state = match self.remote_states.get(mirror_id) {
772            Some(s) => s,
773            None => return Vec::new(),
774        };
775
776        let mut result: Vec<&str> = remote_state
777            .keys()
778            .filter(|cid| !self.local_state.contains_key(*cid))
779            .map(|s| s.as_str())
780            .collect();
781        result.sort_unstable();
782        result
783    }
784
785    // ── Sync-state checks ─────────────────────────────────────────────────────
786
787    /// Returns `true` when every CID in both local and `mirror_id` has the same checksum.
788    ///
789    /// Returns `false` for unregistered mirrors.
790    pub fn in_sync_with(&self, mirror_id: &MirrorId) -> bool {
791        let remote_state = match self.remote_states.get(mirror_id) {
792            Some(s) => s,
793            None => return false,
794        };
795
796        if self.local_state.len() != remote_state.len() {
797            return false;
798        }
799
800        for (cid, local_item) in &self.local_state {
801            match remote_state.get(cid) {
802                Some(remote_item) if remote_item.checksum == local_item.checksum => {}
803                _ => return false,
804            }
805        }
806        true
807    }
808
809    // ── Audit log ─────────────────────────────────────────────────────────────
810
811    /// Returns up to the `n` most-recently logged operations (newest last).
812    pub fn audit_log_tail(&self, n: usize) -> Vec<&SyncOperation> {
813        let len = self.audit_log.len();
814        let skip = len.saturating_sub(n);
815        self.audit_log.iter().skip(skip).collect()
816    }
817
818    // ── Stats ─────────────────────────────────────────────────────────────────
819
820    /// Returns aggregate statistics for this instance.
821    pub fn stats(&self) -> MirrorSyncStats {
822        MirrorSyncStats {
823            local_items: self.local_state.len(),
824            remote_mirrors: self.remote_states.len(),
825            total_conflicts_detected: self.total_conflicts_detected,
826            total_operations: self.total_operations,
827            bytes_transferred: self.total_bytes_transferred,
828        }
829    }
830}
831
832// ─────────────────────────────────────────────────────────────────────────────
833// Tests
834// ─────────────────────────────────────────────────────────────────────────────
835
836#[cfg(test)]
837mod tests {
838    use crate::mirror_sync::{
839        fnv1a_64, ConflictType, MirrorId, MirrorSyncStats, MsConflictResolution, MsSyncResult,
840        StorageMirrorSync, SyncConflict, SyncItem, SyncOperation, SyncPlan,
841    };
842
843    // ── helpers ───────────────────────────────────────────────────────────────
844
845    fn local_id() -> MirrorId {
846        MirrorId::new("local")
847    }
848
849    fn remote_id() -> MirrorId {
850        MirrorId::new("remote-1")
851    }
852
853    fn make_item(cid: &str, size: u64, ts: u64, mirror: MirrorId) -> SyncItem {
854        SyncItem::new(cid.to_string(), size, ts, mirror)
855    }
856
857    fn make_item_v(cid: &str, size: u64, version: u64, ts: u64, mirror: MirrorId) -> SyncItem {
858        SyncItem::with_version(cid.to_string(), size, version, ts, mirror)
859    }
860
861    fn default_sync() -> StorageMirrorSync {
862        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeNewest);
863        s.register_mirror(remote_id());
864        s
865    }
866
867    // ── 1. fnv1a_64 ───────────────────────────────────────────────────────────
868
869    #[test]
870    fn test_fnv1a_empty() {
871        let h = fnv1a_64(b"");
872        assert_eq!(h, 14_695_981_039_346_656_037);
873    }
874
875    #[test]
876    fn test_fnv1a_known_value() {
877        let h = fnv1a_64(b"hello");
878        // FNV-1a 64 of "hello" = 0xa430d84680aabd0b
879        assert_eq!(h, 0xa430d84680aabd0b);
880    }
881
882    #[test]
883    fn test_fnv1a_differs_by_input() {
884        assert_ne!(fnv1a_64(b"abc"), fnv1a_64(b"xyz"));
885    }
886
887    // ── 2. MirrorId ───────────────────────────────────────────────────────────
888
889    #[test]
890    fn test_mirror_id_new_and_as_str() {
891        let id = MirrorId::new("mirror-a");
892        assert_eq!(id.as_str(), "mirror-a");
893    }
894
895    #[test]
896    fn test_mirror_id_display() {
897        let id = MirrorId::new("m1");
898        assert_eq!(format!("{}", id), "m1");
899    }
900
901    #[test]
902    fn test_mirror_id_equality() {
903        assert_eq!(MirrorId::new("x"), MirrorId::new("x"));
904        assert_ne!(MirrorId::new("x"), MirrorId::new("y"));
905    }
906
907    // ── 3. SyncItem ───────────────────────────────────────────────────────────
908
909    #[test]
910    fn test_sync_item_checksum_matches_fnv1a() {
911        let item = make_item("QmTest", 128, 0, local_id());
912        assert_eq!(item.checksum, fnv1a_64(b"QmTest"));
913    }
914
915    #[test]
916    fn test_sync_item_with_version() {
917        let item = make_item_v("Qm1", 64, 5, 100, local_id());
918        assert_eq!(item.version, 5);
919        assert_eq!(item.last_modified, 100);
920    }
921
922    #[test]
923    fn test_sync_item_with_checksum() {
924        let item = SyncItem::with_checksum("Qm2".to_string(), 999, 32, 2, 200, local_id());
925        assert_eq!(item.checksum, 999);
926    }
927
928    // ── 4. register_mirror ────────────────────────────────────────────────────
929
930    #[test]
931    fn test_register_mirror_creates_empty_state() {
932        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeLocal);
933        let rid = MirrorId::new("remote");
934        s.register_mirror(rid.clone());
935        assert!(s.remote_states.contains_key(&rid));
936        assert!(s.remote_states[&rid].is_empty());
937    }
938
939    #[test]
940    fn test_register_mirror_idempotent() {
941        let mut s = default_sync();
942        s.update_remote(&remote_id(), make_item("Qm1", 10, 0, remote_id()));
943        s.register_mirror(remote_id()); // should not clear existing state
944        assert!(s.remote_states[&remote_id()].contains_key("Qm1"));
945    }
946
947    // ── 5. update_local / remove_local ────────────────────────────────────────
948
949    #[test]
950    fn test_update_local_returns_true_for_new() {
951        let mut s = default_sync();
952        let item = make_item("Qm1", 10, 0, local_id());
953        assert!(s.update_local(item));
954    }
955
956    #[test]
957    fn test_update_local_returns_false_for_update() {
958        let mut s = default_sync();
959        s.update_local(make_item("Qm1", 10, 0, local_id()));
960        assert!(!s.update_local(make_item("Qm1", 20, 1, local_id())));
961    }
962
963    #[test]
964    fn test_remove_local_returns_true_when_present() {
965        let mut s = default_sync();
966        s.update_local(make_item("Qm1", 10, 0, local_id()));
967        assert!(s.remove_local("Qm1"));
968        assert!(!s.local_state.contains_key("Qm1"));
969    }
970
971    #[test]
972    fn test_remove_local_returns_false_when_absent() {
973        let mut s = default_sync();
974        assert!(!s.remove_local("nonexistent"));
975    }
976
977    // ── 6. update_remote ─────────────────────────────────────────────────────
978
979    #[test]
980    fn test_update_remote_returns_false_for_unknown_mirror() {
981        let mut s = default_sync();
982        let unknown = MirrorId::new("ghost");
983        assert!(!s.update_remote(&unknown, make_item("Qm1", 10, 0, unknown.clone())));
984    }
985
986    #[test]
987    fn test_update_remote_inserts_item() {
988        let mut s = default_sync();
989        s.update_remote(&remote_id(), make_item("Qm1", 10, 0, remote_id()));
990        assert!(s.remote_states[&remote_id()].contains_key("Qm1"));
991    }
992
993    // ── 7. diff – upload ──────────────────────────────────────────────────────
994
995    #[test]
996    fn test_diff_local_only_generates_upload() {
997        let mut s = default_sync();
998        s.update_local(make_item("QmA", 50, 0, local_id()));
999        let plan = s.diff(&remote_id());
1000        assert_eq!(plan.operations.len(), 1);
1001        assert!(matches!(
1002            &plan.operations[0],
1003            SyncOperation::Upload { cid, .. } if cid == "QmA"
1004        ));
1005        assert_eq!(plan.estimated_bytes, 50);
1006    }
1007
1008    // ── 8. diff – download ────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn test_diff_remote_only_generates_download() {
1012        let mut s = default_sync();
1013        s.update_remote(&remote_id(), make_item("QmB", 80, 0, remote_id()));
1014        let plan = s.diff(&remote_id());
1015        assert_eq!(plan.operations.len(), 1);
1016        assert!(matches!(
1017            &plan.operations[0],
1018            SyncOperation::Download { cid, .. } if cid == "QmB"
1019        ));
1020        assert_eq!(plan.estimated_bytes, 80);
1021    }
1022
1023    // ── 9. diff – in-sync produces no ops ────────────────────────────────────
1024
1025    #[test]
1026    fn test_diff_in_sync_produces_no_ops() {
1027        let mut s = default_sync();
1028        s.update_local(make_item("QmC", 10, 1, local_id()));
1029        s.update_remote(&remote_id(), make_item("QmC", 10, 1, remote_id()));
1030        let plan = s.diff(&remote_id());
1031        assert!(plan.is_empty());
1032    }
1033
1034    // ── 10. diff – conflict TakeNewest (local newer) ──────────────────────────
1035
1036    #[test]
1037    fn test_diff_conflict_take_newest_local_wins() {
1038        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeNewest);
1039        s.register_mirror(remote_id());
1040
1041        let local = SyncItem::with_checksum("Qm1".to_string(), 11, 100, 1, 200, local_id());
1042        let remote = SyncItem::with_checksum("Qm1".to_string(), 22, 80, 1, 100, remote_id());
1043        s.update_local(local);
1044        s.update_remote(&remote_id(), remote);
1045
1046        let plan = s.diff(&remote_id());
1047        assert_eq!(plan.operations.len(), 1);
1048        assert!(matches!(
1049            &plan.operations[0],
1050            SyncOperation::Resolve { resolution, .. }
1051            if *resolution == MsConflictResolution::TakeNewest
1052        ));
1053    }
1054
1055    // ── 11. diff – conflict Skip produces no op ───────────────────────────────
1056
1057    #[test]
1058    fn test_diff_conflict_skip() {
1059        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::Skip);
1060        s.register_mirror(remote_id());
1061
1062        let local = SyncItem::with_checksum("Qm2".to_string(), 11, 100, 1, 0, local_id());
1063        let remote = SyncItem::with_checksum("Qm2".to_string(), 22, 80, 1, 0, remote_id());
1064        s.update_local(local);
1065        s.update_remote(&remote_id(), remote);
1066
1067        let plan = s.diff(&remote_id());
1068        assert!(plan.operations.is_empty());
1069        assert_eq!(plan.conflicts.len(), 1);
1070    }
1071
1072    // ── 12. diff – unknown mirror returns empty plan ──────────────────────────
1073
1074    #[test]
1075    fn test_diff_unknown_mirror_returns_empty() {
1076        let s = default_sync();
1077        let plan = s.diff(&MirrorId::new("ghost"));
1078        assert!(plan.is_empty());
1079    }
1080
1081    // ── 13. apply_plan – upload ───────────────────────────────────────────────
1082
1083    #[test]
1084    fn test_apply_plan_upload_updates_remote() {
1085        let mut s = default_sync();
1086        s.update_local(make_item("QmU", 64, 0, local_id()));
1087        let plan = s.diff(&remote_id());
1088        let res = s.apply_plan(&plan, 0);
1089        assert_eq!(res.operations_executed, 1);
1090        assert_eq!(res.bytes_transferred, 64);
1091        assert!(s.remote_states[&remote_id()].contains_key("QmU"));
1092    }
1093
1094    // ── 14. apply_plan – download ─────────────────────────────────────────────
1095
1096    #[test]
1097    fn test_apply_plan_download_updates_local() {
1098        let mut s = default_sync();
1099        s.update_remote(&remote_id(), make_item("QmD", 32, 0, remote_id()));
1100        let plan = s.diff(&remote_id());
1101        let res = s.apply_plan(&plan, 0);
1102        assert_eq!(res.operations_executed, 1);
1103        assert_eq!(res.bytes_transferred, 32);
1104        assert!(s.local_state.contains_key("QmD"));
1105    }
1106
1107    // ── 15. apply_plan – in-sync after upload ─────────────────────────────────
1108
1109    #[test]
1110    fn test_in_sync_after_apply() {
1111        let mut s = default_sync();
1112        s.update_local(make_item("QmE", 16, 0, local_id()));
1113        let plan = s.diff(&remote_id());
1114        s.apply_plan(&plan, 0);
1115        assert!(s.in_sync_with(&remote_id()));
1116    }
1117
1118    // ── 16. apply_plan – audit log grows ─────────────────────────────────────
1119
1120    #[test]
1121    fn test_apply_plan_audit_log_grows() {
1122        let mut s = default_sync();
1123        s.update_local(make_item("Qm1", 10, 0, local_id()));
1124        s.update_local(make_item("Qm2", 10, 0, local_id()));
1125        let plan = s.diff(&remote_id());
1126        s.apply_plan(&plan, 0);
1127        assert_eq!(s.audit_log.len(), 2);
1128    }
1129
1130    // ── 17. audit_log_tail ────────────────────────────────────────────────────
1131
1132    #[test]
1133    fn test_audit_log_tail_n_zero() {
1134        let s = default_sync();
1135        assert!(s.audit_log_tail(0).is_empty());
1136    }
1137
1138    #[test]
1139    fn test_audit_log_tail_returns_last_n() {
1140        let mut s = default_sync();
1141        for i in 0..5u64 {
1142            let cid = format!("Qm{}", i);
1143            s.update_local(make_item(&cid, 10, i, local_id()));
1144        }
1145        let plan = s.diff(&remote_id());
1146        s.apply_plan(&plan, 0);
1147        let tail = s.audit_log_tail(3);
1148        assert_eq!(tail.len(), 3);
1149    }
1150
1151    // ── 18. audit log cap ─────────────────────────────────────────────────────
1152
1153    #[test]
1154    fn test_audit_log_capped_at_1000() {
1155        let mut s = default_sync();
1156        // Insert 1100 items and apply to fill beyond cap
1157        for i in 0..1100u64 {
1158            let cid = format!("QmCap{}", i);
1159            s.update_local(make_item(&cid, 1, i, local_id()));
1160            s.update_remote(&remote_id(), make_item(&cid, 999, i, remote_id()));
1161            // create conflicts
1162        }
1163        // Only test the cap: apply a fresh plan for items not in remote
1164        let mut s2 = default_sync();
1165        for i in 0..1100u64 {
1166            let cid = format!("QmFresh{}", i);
1167            s2.update_local(make_item(&cid, 1, i, local_id()));
1168        }
1169        let plan = s2.diff(&remote_id());
1170        s2.apply_plan(&plan, 0);
1171        assert!(s2.audit_log.len() <= 1000);
1172    }
1173
1174    // ── 19. detect_conflicts ──────────────────────────────────────────────────
1175
1176    #[test]
1177    fn test_detect_conflicts_finds_checksum_mismatch() {
1178        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::Skip);
1179        s.register_mirror(remote_id());
1180
1181        let local = SyncItem::with_checksum("QmX".to_string(), 1, 100, 1, 0, local_id());
1182        let remote = SyncItem::with_checksum("QmX".to_string(), 2, 80, 1, 0, remote_id());
1183        s.update_local(local);
1184        s.update_remote(&remote_id(), remote);
1185
1186        let conflicts = s.detect_conflicts(&remote_id());
1187        assert_eq!(conflicts.len(), 1);
1188        assert_eq!(conflicts[0].cid, "QmX");
1189        assert_eq!(conflicts[0].conflict_type, ConflictType::ChecksumMismatch);
1190    }
1191
1192    #[test]
1193    fn test_detect_conflicts_none_when_in_sync() {
1194        let mut s = default_sync();
1195        s.update_local(make_item("Qm1", 10, 0, local_id()));
1196        s.update_remote(&remote_id(), make_item("Qm1", 10, 0, remote_id()));
1197        assert!(s.detect_conflicts(&remote_id()).is_empty());
1198    }
1199
1200    #[test]
1201    fn test_detect_conflicts_sorted_by_cid() {
1202        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::Skip);
1203        s.register_mirror(remote_id());
1204
1205        for ch in ["zzz", "aaa", "mmm"] {
1206            let local = SyncItem::with_checksum(ch.to_string(), 1, 100, 1, 0, local_id());
1207            let remote = SyncItem::with_checksum(ch.to_string(), 2, 80, 1, 0, remote_id());
1208            s.update_local(local);
1209            s.update_remote(&remote_id(), remote);
1210        }
1211        let conflicts = s.detect_conflicts(&remote_id());
1212        assert_eq!(conflicts[0].cid, "aaa");
1213        assert_eq!(conflicts[1].cid, "mmm");
1214        assert_eq!(conflicts[2].cid, "zzz");
1215    }
1216
1217    // ── 20. local_only_cids ───────────────────────────────────────────────────
1218
1219    #[test]
1220    fn test_local_only_cids_sorted() {
1221        let mut s = default_sync();
1222        s.update_local(make_item("z", 1, 0, local_id()));
1223        s.update_local(make_item("a", 1, 0, local_id()));
1224        s.update_local(make_item("m", 1, 0, local_id()));
1225        let cids = s.local_only_cids(&remote_id());
1226        assert_eq!(cids, vec!["a", "m", "z"]);
1227    }
1228
1229    #[test]
1230    fn test_local_only_cids_excludes_shared() {
1231        let mut s = default_sync();
1232        s.update_local(make_item("shared", 1, 0, local_id()));
1233        s.update_local(make_item("local-only", 1, 0, local_id()));
1234        s.update_remote(&remote_id(), make_item("shared", 1, 0, remote_id()));
1235        let cids = s.local_only_cids(&remote_id());
1236        assert_eq!(cids, vec!["local-only"]);
1237    }
1238
1239    // ── 21. remote_only_cids ──────────────────────────────────────────────────
1240
1241    #[test]
1242    fn test_remote_only_cids_sorted() {
1243        let mut s = default_sync();
1244        s.update_remote(&remote_id(), make_item("z", 1, 0, remote_id()));
1245        s.update_remote(&remote_id(), make_item("a", 1, 0, remote_id()));
1246        let cids = s.remote_only_cids(&remote_id());
1247        assert_eq!(cids, vec!["a", "z"]);
1248    }
1249
1250    #[test]
1251    fn test_remote_only_cids_unknown_mirror() {
1252        let s = default_sync();
1253        assert!(s.remote_only_cids(&MirrorId::new("ghost")).is_empty());
1254    }
1255
1256    // ── 22. in_sync_with ──────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn test_in_sync_with_empty_states() {
1260        let s = default_sync();
1261        assert!(s.in_sync_with(&remote_id()));
1262    }
1263
1264    #[test]
1265    fn test_in_sync_with_false_different_size_sets() {
1266        let mut s = default_sync();
1267        s.update_local(make_item("Qm1", 10, 0, local_id()));
1268        assert!(!s.in_sync_with(&remote_id()));
1269    }
1270
1271    #[test]
1272    fn test_in_sync_with_false_for_unknown_mirror() {
1273        let s = default_sync();
1274        assert!(!s.in_sync_with(&MirrorId::new("ghost")));
1275    }
1276
1277    // ── 23. stats ─────────────────────────────────────────────────────────────
1278
1279    #[test]
1280    fn test_stats_initial() {
1281        let s = default_sync();
1282        let st = s.stats();
1283        assert_eq!(st.local_items, 0);
1284        assert_eq!(st.remote_mirrors, 1);
1285        assert_eq!(st.total_operations, 0);
1286        assert_eq!(st.bytes_transferred, 0);
1287    }
1288
1289    #[test]
1290    fn test_stats_after_apply() {
1291        let mut s = default_sync();
1292        s.update_local(make_item("Qm1", 50, 0, local_id()));
1293        let plan = s.diff(&remote_id());
1294        s.apply_plan(&plan, 0);
1295        let st = s.stats();
1296        assert_eq!(st.total_operations, 1);
1297        assert_eq!(st.bytes_transferred, 50);
1298        assert_eq!(st.local_items, 1);
1299    }
1300
1301    #[test]
1302    fn test_stats_conflicts_tracked() {
1303        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::Skip);
1304        s.register_mirror(remote_id());
1305
1306        let local = SyncItem::with_checksum("Qm1".to_string(), 1, 100, 1, 0, local_id());
1307        let remote = SyncItem::with_checksum("Qm1".to_string(), 2, 80, 1, 0, remote_id());
1308        s.update_local(local);
1309        s.update_remote(&remote_id(), remote);
1310
1311        let plan = s.diff(&remote_id());
1312        s.apply_plan(&plan, 0);
1313        let st = s.stats();
1314        assert_eq!(st.total_conflicts_detected, 1);
1315    }
1316
1317    // ── 24. conflict resolution – TakeLocal ───────────────────────────────────
1318
1319    #[test]
1320    fn test_resolve_take_local_pushes_to_remote() {
1321        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeLocal);
1322        s.register_mirror(remote_id());
1323
1324        let local = SyncItem::with_checksum("Qm1".to_string(), 1, 100, 1, 0, local_id());
1325        let remote = SyncItem::with_checksum("Qm1".to_string(), 2, 80, 1, 0, remote_id());
1326        s.update_local(local);
1327        s.update_remote(&remote_id(), remote);
1328
1329        let plan = s.diff(&remote_id());
1330        s.apply_plan(&plan, 0);
1331
1332        // After TakeLocal, remote should have local's checksum
1333        let remote_item = &s.remote_states[&remote_id()]["Qm1"];
1334        assert_eq!(remote_item.checksum, 1);
1335    }
1336
1337    // ── 25. conflict resolution – TakeRemote ──────────────────────────────────
1338
1339    #[test]
1340    fn test_resolve_take_remote_pulls_to_local() {
1341        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeRemote);
1342        s.register_mirror(remote_id());
1343
1344        let local = SyncItem::with_checksum("Qm1".to_string(), 1, 100, 1, 0, local_id());
1345        let remote = SyncItem::with_checksum("Qm1".to_string(), 2, 80, 1, 0, remote_id());
1346        s.update_local(local);
1347        s.update_remote(&remote_id(), remote);
1348
1349        let plan = s.diff(&remote_id());
1350        s.apply_plan(&plan, 0);
1351
1352        let local_item = &s.local_state["Qm1"];
1353        assert_eq!(local_item.checksum, 2);
1354    }
1355
1356    // ── 26. conflict resolution – TakeLargest ─────────────────────────────────
1357
1358    #[test]
1359    fn test_resolve_take_largest_selects_bigger() {
1360        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeLargest);
1361        s.register_mirror(remote_id());
1362
1363        // Local is larger
1364        let local = SyncItem::with_checksum("Qm1".to_string(), 1, 200, 1, 0, local_id());
1365        let remote = SyncItem::with_checksum("Qm1".to_string(), 2, 100, 1, 0, remote_id());
1366        s.update_local(local);
1367        s.update_remote(&remote_id(), remote);
1368
1369        let plan = s.diff(&remote_id());
1370        s.apply_plan(&plan, 0);
1371
1372        // Remote should be updated to local's (larger) item
1373        let remote_item = &s.remote_states[&remote_id()]["Qm1"];
1374        assert_eq!(remote_item.size_bytes, 200);
1375    }
1376
1377    // ── 27. Delete operation ──────────────────────────────────────────────────
1378
1379    #[test]
1380    fn test_apply_delete_from_remote() {
1381        let mut s = default_sync();
1382        s.update_remote(&remote_id(), make_item("QmDel", 10, 0, remote_id()));
1383
1384        let plan = SyncPlan {
1385            operations: vec![SyncOperation::Delete {
1386                cid: "QmDel".to_string(),
1387                from_mirror: remote_id(),
1388            }],
1389            conflicts: Vec::new(),
1390            estimated_bytes: 0,
1391        };
1392        let res = s.apply_plan(&plan, 0);
1393        assert_eq!(res.operations_executed, 1);
1394        assert!(!s.remote_states[&remote_id()].contains_key("QmDel"));
1395    }
1396
1397    #[test]
1398    fn test_apply_delete_from_local() {
1399        let mut s = default_sync();
1400        s.update_local(make_item("QmDelLocal", 10, 0, local_id()));
1401
1402        let plan = SyncPlan {
1403            operations: vec![SyncOperation::Delete {
1404                cid: "QmDelLocal".to_string(),
1405                from_mirror: local_id(),
1406            }],
1407            conflicts: Vec::new(),
1408            estimated_bytes: 0,
1409        };
1410        s.apply_plan(&plan, 0);
1411        assert!(!s.local_state.contains_key("QmDelLocal"));
1412    }
1413
1414    // ── 28. multiple mirrors ──────────────────────────────────────────────────
1415
1416    #[test]
1417    fn test_multiple_mirrors_independent() {
1418        let mut s = StorageMirrorSync::new(local_id(), MsConflictResolution::TakeLocal);
1419        let r2 = MirrorId::new("remote-2");
1420        s.register_mirror(remote_id());
1421        s.register_mirror(r2.clone());
1422
1423        s.update_local(make_item("QmM", 10, 0, local_id()));
1424        s.update_remote(&r2, make_item("QmM", 10, 0, r2.clone()));
1425
1426        let plan1 = s.diff(&remote_id());
1427        let plan2 = s.diff(&r2);
1428        assert_eq!(plan1.operations.len(), 1); // upload to remote-1
1429        assert!(plan2.is_empty()); // already in r2
1430    }
1431
1432    // ── 29. SyncConflict::classify ────────────────────────────────────────────
1433
1434    #[test]
1435    fn test_classify_checksum_mismatch() {
1436        let local = SyncItem::with_checksum("Qm".to_string(), 10, 50, 1, 0, local_id());
1437        let remote = SyncItem::with_checksum("Qm".to_string(), 20, 50, 1, 0, remote_id());
1438        let c = SyncConflict::classify(local, remote);
1439        assert_eq!(c.conflict_type, ConflictType::ChecksumMismatch);
1440    }
1441
1442    #[test]
1443    fn test_classify_version_conflict() {
1444        // Same checksum, different versions
1445        let local = SyncItem::with_checksum("Qm".to_string(), 10, 50, 1, 0, local_id());
1446        let mut remote = SyncItem::with_checksum("Qm".to_string(), 10, 50, 2, 0, remote_id());
1447        remote.checksum = local.checksum; // make checksums equal
1448        let c = SyncConflict::classify(local, remote);
1449        assert_eq!(c.conflict_type, ConflictType::VersionConflict);
1450    }
1451
1452    // ── 30. Plan is_empty ─────────────────────────────────────────────────────
1453
1454    #[test]
1455    fn test_plan_is_empty_true() {
1456        let plan = SyncPlan {
1457            operations: Vec::new(),
1458            conflicts: Vec::new(),
1459            estimated_bytes: 0,
1460        };
1461        assert!(plan.is_empty());
1462    }
1463
1464    #[test]
1465    fn test_plan_is_empty_false_when_ops() {
1466        let plan = SyncPlan {
1467            operations: vec![SyncOperation::Upload {
1468                cid: "x".to_string(),
1469                to_mirror: remote_id(),
1470            }],
1471            conflicts: Vec::new(),
1472            estimated_bytes: 0,
1473        };
1474        assert!(!plan.is_empty());
1475    }
1476
1477    // ── 31. MsSyncResult default ──────────────────────────────────────────────
1478
1479    #[test]
1480    fn test_ms_sync_result_default() {
1481        let r = MsSyncResult::default();
1482        assert_eq!(r.operations_executed, 0);
1483        assert_eq!(r.bytes_transferred, 0);
1484        assert!(r.errors.is_empty());
1485    }
1486
1487    // ── 32. MirrorSyncStats default ───────────────────────────────────────────
1488
1489    #[test]
1490    fn test_mirror_sync_stats_default() {
1491        let st = MirrorSyncStats::default();
1492        assert_eq!(st.local_items, 0);
1493        assert_eq!(st.remote_mirrors, 0);
1494    }
1495}