Skip to main content

common/mount/
path_ops.rs

1//! Path Operation CRDT for tracking filesystem changes
2//!
3//! This module provides a Conflict-free Replicated Data Type (CRDT) for tracking
4//! path operations (add, remove, mkdir, mv) across peers. The operation log enables:
5//! - Filesystem history reconstruction
6//! - Conflict resolution during peer sync
7//!
8//! The log is stored as an encrypted blob separate from the manifest to avoid
9//! leaking directory structure information.
10
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14use serde::{Deserialize, Serialize};
15
16use crate::crypto::PublicKey;
17use crate::linked_data::{BlockEncoded, DagCborCodec, Link};
18
19use super::conflict::{
20    operations_conflict, Conflict, ConflictResolver, MergeResult, Resolution, ResolvedConflict,
21};
22
23/// Type of path operation
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum OpType {
26    /// Add a new file
27    Add,
28    /// Remove a file or directory
29    Remove,
30    /// Create a directory
31    Mkdir,
32    /// Move/rename a file or directory
33    Mv {
34        /// Source path (the path being moved from)
35        from: PathBuf,
36    },
37}
38
39/// Operation identifier for causal ordering
40///
41/// Provides a total ordering across all operations from all peers:
42/// - Primary ordering by Lamport timestamp
43/// - Secondary ordering by peer_id (lexicographic) for tie-breaking
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct OpId {
46    /// Lamport timestamp (logical clock)
47    pub timestamp: u64,
48    /// Peer that created this operation (for tie-breaking)
49    pub peer_id: PublicKey,
50}
51
52impl PartialOrd for OpId {
53    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
54        Some(self.cmp(other))
55    }
56}
57
58impl Ord for OpId {
59    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
60        match self.timestamp.cmp(&other.timestamp) {
61            std::cmp::Ordering::Equal => self.peer_id.cmp(&other.peer_id),
62            ord => ord,
63        }
64    }
65}
66
67/// A single path operation in the CRDT log
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PathOperation {
70    /// Unique operation ID (timestamp + peer_id)
71    pub id: OpId,
72    /// Type of operation
73    pub op_type: OpType,
74    /// Target path (destination for Mv, affected path for others)
75    pub path: PathBuf,
76    /// For Add: link to the content (None for Remove/Mkdir/Mv)
77    pub content_link: Option<Link>,
78    /// Whether this operation affects a directory
79    pub is_dir: bool,
80}
81
82/// The path operation log - an operation-based CRDT
83///
84/// This is a grow-only set of operations with causal ordering.
85/// Conflict resolution is deterministic based on:
86/// 1. Lamport timestamp (higher wins for concurrent ops on same path)
87/// 2. Peer ID (lexicographic tie-breaker)
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
89pub struct PathOpLog {
90    /// All operations, keyed by OpId for efficient lookup and ordering
91    operations: BTreeMap<OpId, PathOperation>,
92
93    /// Current Lamport clock (not serialized, rebuilt from operations)
94    #[serde(skip)]
95    local_clock: u64,
96}
97
98impl BlockEncoded<DagCborCodec> for PathOpLog {}
99
100impl PathOpLog {
101    /// Create a new empty log
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Create a log containing a single operation
107    pub fn from_operation(op: &PathOperation) -> Self {
108        let mut log = Self::new();
109        log.operations.insert(op.id.clone(), op.clone());
110        log.local_clock = op.id.timestamp;
111        log
112    }
113
114    /// Rebuild local clock from operations (call after deserialization)
115    pub fn rebuild_clock(&mut self) {
116        self.local_clock = self
117            .operations
118            .keys()
119            .map(|id| id.timestamp)
120            .max()
121            .unwrap_or(0);
122    }
123
124    /// Record a new local operation
125    ///
126    /// The peer_id identifies who is recording this operation.
127    /// Returns the OpId of the recorded operation.
128    pub fn record(
129        &mut self,
130        peer_id: PublicKey,
131        op_type: OpType,
132        path: impl Into<PathBuf>,
133        content_link: Option<Link>,
134        is_dir: bool,
135    ) -> OpId {
136        self.local_clock += 1;
137
138        let id = OpId {
139            timestamp: self.local_clock,
140            peer_id,
141        };
142
143        let op = PathOperation {
144            id: id.clone(),
145            op_type,
146            path: path.into(),
147            content_link,
148            is_dir,
149        };
150
151        self.operations.insert(id.clone(), op);
152        id
153    }
154
155    /// Merge operations from another log (CRDT merge)
156    ///
157    /// Returns the number of new operations added
158    pub fn merge(&mut self, other: &PathOpLog) -> usize {
159        let mut added = 0;
160        for (id, op) in &other.operations {
161            if !self.operations.contains_key(id) {
162                self.operations.insert(id.clone(), op.clone());
163                added += 1;
164                // Update local clock to stay ahead of all seen operations
165                if id.timestamp >= self.local_clock {
166                    self.local_clock = id.timestamp + 1;
167                }
168            }
169        }
170        added
171    }
172
173    /// Merge operations from another log with conflict resolution
174    ///
175    /// Unlike the basic `merge()`, this method:
176    /// 1. Detects conflicts between local and incoming operations
177    /// 2. Uses the provided resolver to decide how to handle each conflict
178    /// 3. Returns detailed information about what conflicts were found and resolved
179    ///
180    /// # Arguments
181    ///
182    /// * `other` - The incoming log to merge
183    /// * `resolver` - The conflict resolution strategy to use
184    /// * `local_peer` - The local peer's identity (for tie-breaking)
185    ///
186    /// # Returns
187    ///
188    /// A `MergeResult` containing:
189    /// - Number of operations added
190    /// - List of resolved conflicts
191    /// - List of unresolved conflicts (when using ForkOnConflict)
192    pub fn merge_with_resolver(
193        &mut self,
194        other: &PathOpLog,
195        resolver: &dyn ConflictResolver,
196        local_peer: &PublicKey,
197    ) -> MergeResult {
198        let mut result = MergeResult::new();
199
200        // First, identify all incoming operations that would conflict
201        let mut conflicts_by_path: BTreeMap<PathBuf, Vec<(&OpId, &PathOperation)>> =
202            BTreeMap::new();
203
204        for (id, op) in &other.operations {
205            // Skip if we already have this exact operation
206            if self.operations.contains_key(id) {
207                continue;
208            }
209
210            // Check for conflicts with existing operations on the same path
211            let has_conflict = self
212                .operations
213                .values()
214                .any(|existing| operations_conflict(existing, op));
215
216            if has_conflict {
217                conflicts_by_path
218                    .entry(op.path.clone())
219                    .or_default()
220                    .push((id, op));
221            }
222        }
223
224        // Process each incoming operation
225        for (id, op) in &other.operations {
226            // Skip if we already have this exact operation
227            if self.operations.contains_key(id) {
228                continue;
229            }
230
231            // Update local clock to stay ahead of all seen operations
232            if id.timestamp >= self.local_clock {
233                self.local_clock = id.timestamp + 1;
234            }
235
236            // Find conflicting base operation (if any)
237            let conflicting_base = self
238                .operations
239                .values()
240                .find(|existing| operations_conflict(existing, op));
241
242            match conflicting_base {
243                Some(base) => {
244                    // We have a conflict
245                    let conflict = Conflict::new(op.path.clone(), base.clone(), op.clone());
246                    let resolution = resolver.resolve(&conflict, local_peer);
247
248                    match resolution {
249                        Resolution::UseBase => {
250                            // Don't add the incoming operation
251                            result.conflicts_resolved.push(ResolvedConflict {
252                                conflict,
253                                resolution,
254                            });
255                        }
256                        Resolution::UseIncoming => {
257                            // Add the incoming operation (it will win in resolve_path)
258                            self.operations.insert(id.clone(), op.clone());
259                            result.operations_added += 1;
260                            result.conflicts_resolved.push(ResolvedConflict {
261                                conflict,
262                                resolution,
263                            });
264                        }
265                        Resolution::KeepBoth => {
266                            // Add the incoming operation and track as unresolved
267                            self.operations.insert(id.clone(), op.clone());
268                            result.operations_added += 1;
269                            result.unresolved_conflicts.push(conflict);
270                        }
271                        Resolution::SkipBoth => {
272                            // Don't add incoming, and remove base
273                            // Note: We don't actually remove from operations to preserve history
274                            // Instead, this could be used by higher-level logic
275                            result.conflicts_resolved.push(ResolvedConflict {
276                                conflict,
277                                resolution,
278                            });
279                        }
280                        Resolution::RenameIncoming { new_path } => {
281                            // Create a modified copy of the incoming operation with the new path
282                            let mut renamed_op = op.clone();
283                            renamed_op.path = new_path.clone();
284                            self.operations.insert(id.clone(), renamed_op);
285                            result.operations_added += 1;
286                            result.conflicts_resolved.push(ResolvedConflict {
287                                conflict,
288                                resolution: Resolution::RenameIncoming { new_path },
289                            });
290                        }
291                    }
292                }
293                None => {
294                    // No conflict, just add the operation
295                    self.operations.insert(id.clone(), op.clone());
296                    result.operations_added += 1;
297                }
298            }
299        }
300
301        result
302    }
303
304    /// Get all operations
305    pub fn operations(&self) -> &BTreeMap<OpId, PathOperation> {
306        &self.operations
307    }
308
309    /// Resolve the current state of a path
310    ///
311    /// Returns the winning operation for the path (if any).
312    /// The operation with the highest OpId wins.
313    pub fn resolve_path(&self, path: impl AsRef<std::path::Path>) -> Option<&PathOperation> {
314        let path = path.as_ref();
315        // Find all operations affecting this path
316        let path_ops: Vec<&PathOperation> = self
317            .operations
318            .values()
319            .filter(|op| op.path == path)
320            .collect();
321
322        if path_ops.is_empty() {
323            return None;
324        }
325
326        // The operation with the highest OpId wins (total order)
327        path_ops.into_iter().max_by_key(|op| &op.id)
328    }
329
330    /// Resolve the entire filesystem state from operations
331    ///
332    /// Returns a map of path -> winning operation for all paths
333    /// that currently exist (excludes paths where Remove was the winning op)
334    pub fn resolve_all(&self) -> BTreeMap<PathBuf, &PathOperation> {
335        let mut result: BTreeMap<PathBuf, &PathOperation> = BTreeMap::new();
336
337        // Group operations by path
338        let mut by_path: BTreeMap<&PathBuf, Vec<&PathOperation>> = BTreeMap::new();
339        for op in self.operations.values() {
340            by_path.entry(&op.path).or_default().push(op);
341        }
342
343        // For each path, pick the winning operation
344        for (path, ops) in by_path {
345            if let Some(winner) = ops.into_iter().max_by_key(|op| &op.id) {
346                // Only include if the winning op is not a Remove
347                if !matches!(winner.op_type, OpType::Remove) {
348                    result.insert(path.clone(), winner);
349                }
350            }
351        }
352
353        result
354    }
355
356    /// Get the number of operations
357    pub fn len(&self) -> usize {
358        self.operations.len()
359    }
360
361    /// Check if empty
362    pub fn is_empty(&self) -> bool {
363        self.operations.is_empty()
364    }
365
366    /// Clear operations but preserve the local clock value.
367    ///
368    /// This is used after persisting operations to a manifest. We clear the
369    /// operations (since they're now saved) but keep the clock so future
370    /// operations have unique timestamps across save cycles.
371    pub fn clear_preserving_clock(&mut self) {
372        self.operations.clear();
373        // local_clock is preserved
374    }
375
376    /// Get operations affecting a specific path, in order
377    pub fn ops_for_path(&self, path: impl AsRef<std::path::Path>) -> Vec<&PathOperation> {
378        let path = path.as_ref();
379        self.operations
380            .values()
381            .filter(|op| op.path == path)
382            .collect()
383    }
384
385    /// Get all operations in order (oldest to newest)
386    pub fn ops_in_order(&self) -> impl Iterator<Item = &PathOperation> {
387        self.operations.values()
388    }
389}
390
391/// Merge multiple PathOpLogs from divergent chains into a single log
392///
393/// This is useful when synchronizing with multiple peers who have diverged
394/// from a common ancestor. Each log is merged in sequence, with conflicts
395/// resolved according to the provided resolver strategy.
396///
397/// # Arguments
398///
399/// * `logs` - Slice of PathOpLog references to merge (first one is used as base)
400/// * `resolver` - The conflict resolution strategy to use
401/// * `local_peer` - The local peer's identity (for tie-breaking)
402///
403/// # Returns
404///
405/// A tuple of:
406/// - The merged PathOpLog (cloned from first, with all others merged in)
407/// - Vector of MergeResults, one for each merge operation
408///
409/// # Example
410///
411/// ```ignore
412/// // Alice has log_alice, Bob has log_bob, Carol has log_carol
413/// // All diverged from a common ancestor
414/// let (merged, results) = merge_logs(
415///     &[&log_alice, &log_bob, &log_carol],
416///     &ConflictFile::new(),
417///     &alice_peer_id,
418/// );
419/// // merged now contains all operations from all logs
420/// // results[0] = merge of log_bob into log_alice
421/// // results[1] = merge of log_carol into (log_alice + log_bob)
422/// ```
423pub fn merge_logs<R: ConflictResolver>(
424    logs: &[&PathOpLog],
425    resolver: &R,
426    local_peer: &PublicKey,
427) -> (PathOpLog, Vec<MergeResult>) {
428    if logs.is_empty() {
429        return (PathOpLog::new(), Vec::new());
430    }
431
432    // Start with a clone of the first log
433    let mut merged = logs[0].clone();
434    let mut results = Vec::with_capacity(logs.len().saturating_sub(1));
435
436    // Merge each subsequent log into the merged result
437    for log in logs.iter().skip(1) {
438        let result = merged.merge_with_resolver(log, resolver, local_peer);
439        results.push(result);
440    }
441
442    (merged, results)
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::crypto::SecretKey;
449
450    fn make_peer_id(seed: u8) -> PublicKey {
451        // Generate a deterministic keypair from the seed
452        // We use the seed to create a reproducible secret key
453        let mut seed_bytes = [0u8; 32];
454        seed_bytes[0] = seed;
455        let secret = SecretKey::from(seed_bytes);
456        secret.public()
457    }
458
459    #[test]
460    fn test_op_id_ordering() {
461        let peer1 = make_peer_id(1);
462        let peer2 = make_peer_id(2);
463
464        let id1 = OpId {
465            timestamp: 1,
466            peer_id: peer1,
467        };
468        let id2 = OpId {
469            timestamp: 2,
470            peer_id: peer1,
471        };
472        let id3 = OpId {
473            timestamp: 1,
474            peer_id: peer2,
475        };
476
477        // Higher timestamp wins
478        assert!(id2 > id1);
479        // Same timestamp, different peer_ids have deterministic ordering
480        assert!(id3 != id1);
481        // Order is determined by peer_id comparison
482        if peer2 > peer1 {
483            assert!(id3 > id1);
484        } else {
485            assert!(id3 < id1);
486        }
487    }
488
489    #[test]
490    fn test_record_operation() {
491        let peer1 = make_peer_id(1);
492        let mut log = PathOpLog::new();
493
494        let id = log.record(peer1, OpType::Add, "file.txt", None, false);
495
496        assert_eq!(id.timestamp, 1);
497        assert_eq!(log.len(), 1);
498
499        let op = log.operations.get(&id).unwrap();
500        assert_eq!(op.path, PathBuf::from("file.txt"));
501        assert!(matches!(op.op_type, OpType::Add));
502    }
503
504    #[test]
505    fn test_record_multiple_operations() {
506        let peer1 = make_peer_id(1);
507        let mut log = PathOpLog::new();
508
509        let id1 = log.record(peer1, OpType::Add, "file1.txt", None, false);
510        let id2 = log.record(peer1, OpType::Add, "file2.txt", None, false);
511        let id3 = log.record(peer1, OpType::Remove, "file1.txt", None, false);
512
513        assert_eq!(id1.timestamp, 1);
514        assert_eq!(id2.timestamp, 2);
515        assert_eq!(id3.timestamp, 3);
516        assert_eq!(log.len(), 3);
517    }
518
519    #[test]
520    fn test_merge_logs() {
521        let peer1 = make_peer_id(1);
522        let peer2 = make_peer_id(2);
523
524        let mut log1 = PathOpLog::new();
525        log1.record(peer1, OpType::Add, "file1.txt", None, false);
526
527        let mut log2 = PathOpLog::new();
528        log2.record(peer2, OpType::Add, "file2.txt", None, false);
529
530        let added = log1.merge(&log2);
531
532        assert_eq!(added, 1);
533        assert_eq!(log1.len(), 2);
534    }
535
536    #[test]
537    fn test_merge_idempotent() {
538        let peer1 = make_peer_id(1);
539        let mut log1 = PathOpLog::new();
540        log1.record(peer1, OpType::Add, "file.txt", None, false);
541
542        let log1_clone = log1.clone();
543        let added = log1.merge(&log1_clone);
544
545        assert_eq!(added, 0);
546        assert_eq!(log1.len(), 1);
547    }
548
549    #[test]
550    fn test_resolve_path_single_op() {
551        let peer1 = make_peer_id(1);
552        let mut log = PathOpLog::new();
553        log.record(peer1, OpType::Add, "file.txt", None, false);
554
555        let resolved = log.resolve_path("file.txt");
556        assert!(resolved.is_some());
557        assert!(matches!(resolved.unwrap().op_type, OpType::Add));
558    }
559
560    #[test]
561    fn test_resolve_path_latest_wins() {
562        let peer1 = make_peer_id(1);
563        let mut log = PathOpLog::new();
564
565        log.record(peer1, OpType::Add, "file.txt", None, false);
566        log.record(peer1, OpType::Remove, "file.txt", None, false);
567
568        let resolved = log.resolve_path("file.txt");
569        assert!(resolved.is_some());
570        assert!(matches!(resolved.unwrap().op_type, OpType::Remove));
571    }
572
573    #[test]
574    fn test_resolve_all_excludes_removed() {
575        let peer1 = make_peer_id(1);
576        let mut log = PathOpLog::new();
577
578        log.record(peer1, OpType::Add, "file1.txt", None, false);
579        log.record(peer1, OpType::Add, "file2.txt", None, false);
580        log.record(peer1, OpType::Remove, "file1.txt", None, false);
581
582        let resolved = log.resolve_all();
583
584        assert_eq!(resolved.len(), 1);
585        assert!(resolved.contains_key(&PathBuf::from("file2.txt")));
586        assert!(!resolved.contains_key(&PathBuf::from("file1.txt")));
587    }
588
589    #[test]
590    fn test_concurrent_ops_different_peers() {
591        let peer1 = make_peer_id(1);
592        let peer2 = make_peer_id(2);
593
594        let mut log1 = PathOpLog::new();
595        log1.record(peer1, OpType::Add, "file.txt", None, false);
596
597        let mut log2 = PathOpLog::new();
598        log2.record(peer2, OpType::Remove, "file.txt", None, false);
599
600        // Merge log2 into log1
601        log1.merge(&log2);
602
603        // Both have timestamp=1, winner is determined by peer_id ordering
604        let resolved = log1.resolve_path("file.txt");
605        assert!(resolved.is_some());
606        let winning_op = resolved.unwrap();
607
608        // The peer with higher ID wins (deterministic tie-breaking)
609        if peer2 > peer1 {
610            assert!(matches!(winning_op.op_type, OpType::Remove));
611        } else {
612            assert!(matches!(winning_op.op_type, OpType::Add));
613        }
614    }
615
616    #[test]
617    fn test_mv_operation() {
618        let peer1 = make_peer_id(1);
619        let mut log = PathOpLog::new();
620
621        log.record(peer1, OpType::Add, "old.txt", None, false);
622        log.record(
623            peer1,
624            OpType::Mv {
625                from: PathBuf::from("old.txt"),
626            },
627            "new.txt",
628            None,
629            false,
630        );
631
632        assert_eq!(log.len(), 2);
633
634        // The destination path should resolve to Mv
635        let resolved = log.resolve_path("new.txt");
636        assert!(resolved.is_some());
637        assert!(matches!(resolved.unwrap().op_type, OpType::Mv { .. }));
638    }
639
640    #[test]
641    fn test_serialization_roundtrip() {
642        use crate::linked_data::BlockEncoded;
643
644        let peer1 = make_peer_id(1);
645        let mut log = PathOpLog::new();
646
647        log.record(peer1, OpType::Add, "file1.txt", None, false);
648        log.record(peer1, OpType::Mkdir, "dir", None, true);
649        log.record(
650            peer1,
651            OpType::Mv {
652                from: PathBuf::from("file1.txt"),
653            },
654            "dir/file1.txt",
655            None,
656            false,
657        );
658
659        let encoded = log.encode().unwrap();
660        let decoded = PathOpLog::decode(&encoded).unwrap();
661
662        // Operations should match (local_clock is not serialized)
663        assert_eq!(log.operations, decoded.operations);
664    }
665
666    #[test]
667    fn test_merge_with_resolver_no_conflicts() {
668        use super::super::conflict::LastWriteWins;
669
670        let peer1 = make_peer_id(1);
671        let peer2 = make_peer_id(2);
672
673        let mut log1 = PathOpLog::new();
674        log1.record(peer1, OpType::Add, "file1.txt", None, false);
675
676        let mut log2 = PathOpLog::new();
677        log2.record(peer2, OpType::Add, "file2.txt", None, false);
678
679        let resolver = LastWriteWins::new();
680        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
681
682        assert_eq!(result.operations_added, 1);
683        assert_eq!(result.conflicts_resolved.len(), 0);
684        assert!(!result.has_unresolved());
685        assert_eq!(log1.len(), 2);
686    }
687
688    #[test]
689    fn test_merge_with_resolver_last_write_wins() {
690        use super::super::conflict::{LastWriteWins, Resolution};
691
692        let peer1 = make_peer_id(1);
693        let peer2 = make_peer_id(2);
694
695        let mut log1 = PathOpLog::new();
696        log1.record(peer1, OpType::Add, "file.txt", None, false);
697
698        let mut log2 = PathOpLog::new();
699        // Simulate peer2 being "ahead" by using a higher timestamp
700        log2.record(peer2, OpType::Add, "dummy", None, false); // ts=1
701        log2.record(peer2, OpType::Remove, "file.txt", None, false); // ts=2
702
703        let resolver = LastWriteWins::new();
704        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
705
706        // Should have 2 ops added (dummy and remove)
707        // The remove conflicts with add, incoming (ts=2) > base (ts=1)
708        assert_eq!(result.operations_added, 2);
709        assert_eq!(result.conflicts_resolved.len(), 1);
710
711        let resolved = &result.conflicts_resolved[0];
712        assert_eq!(resolved.resolution, Resolution::UseIncoming);
713    }
714
715    #[test]
716    fn test_merge_with_resolver_base_wins() {
717        use super::super::conflict::{BaseWins, Resolution};
718
719        let peer1 = make_peer_id(1);
720        let peer2 = make_peer_id(2);
721
722        let mut log1 = PathOpLog::new();
723        log1.record(peer1, OpType::Add, "file.txt", None, false);
724
725        let mut log2 = PathOpLog::new();
726        log2.record(peer2, OpType::Remove, "file.txt", None, false);
727
728        let resolver = BaseWins::new();
729        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
730
731        // With BaseWins, the incoming Remove should not be added
732        assert_eq!(result.operations_added, 0);
733        assert_eq!(result.conflicts_resolved.len(), 1);
734
735        let resolved = &result.conflicts_resolved[0];
736        assert_eq!(resolved.resolution, Resolution::UseBase);
737
738        // Original operation should still be the only one for this path
739        let resolved_path = log1.resolve_path("file.txt");
740        assert!(matches!(resolved_path.unwrap().op_type, OpType::Add));
741    }
742
743    #[test]
744    fn test_merge_with_resolver_fork_on_conflict() {
745        use super::super::conflict::ForkOnConflict;
746
747        let peer1 = make_peer_id(1);
748        let peer2 = make_peer_id(2);
749
750        let mut log1 = PathOpLog::new();
751        log1.record(peer1, OpType::Add, "file.txt", None, false);
752
753        let mut log2 = PathOpLog::new();
754        log2.record(peer2, OpType::Add, "file.txt", None, false);
755
756        let resolver = ForkOnConflict::new();
757        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
758
759        // With ForkOnConflict, the incoming operation should be added
760        // but tracked as unresolved
761        assert_eq!(result.operations_added, 1);
762        assert_eq!(result.conflicts_resolved.len(), 0);
763        assert!(result.has_unresolved());
764        assert_eq!(result.unresolved_conflicts.len(), 1);
765
766        // Both operations should be in the log
767        assert_eq!(log1.len(), 2);
768
769        // resolve_path should return the winner by OpId
770        let resolved = log1.resolve_path("file.txt").unwrap();
771        // The winner is the one with higher OpId
772        if peer2 > peer1 {
773            assert_eq!(resolved.id.peer_id, peer2);
774        } else {
775            assert_eq!(resolved.id.peer_id, peer1);
776        }
777    }
778
779    #[test]
780    fn test_merge_with_resolver_concurrent_ops() {
781        use super::super::conflict::LastWriteWins;
782
783        let peer1 = make_peer_id(1);
784        let peer2 = make_peer_id(2);
785
786        // Both peers start with the same clock (concurrent operations)
787        let mut log1 = PathOpLog::new();
788        log1.record(peer1, OpType::Add, "file.txt", None, false);
789
790        let mut log2 = PathOpLog::new();
791        log2.record(peer2, OpType::Add, "file.txt", None, false);
792
793        // Both have timestamp=1, so this is a true concurrent edit
794        let resolver = LastWriteWins::new();
795        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
796
797        // There's a conflict
798        assert_eq!(result.total_conflicts(), 1);
799
800        // The result depends on peer_id ordering
801        // LastWriteWins uses OpId comparison (timestamp then peer_id)
802        if peer2 > peer1 {
803            // peer2 wins, incoming operation added
804            assert_eq!(result.operations_added, 1);
805        } else {
806            // peer1 wins, incoming operation not added
807            assert_eq!(result.operations_added, 0);
808        }
809    }
810
811    #[test]
812    fn test_merge_with_resolver_idempotent() {
813        use super::super::conflict::LastWriteWins;
814
815        let peer1 = make_peer_id(1);
816        let mut log1 = PathOpLog::new();
817        log1.record(peer1, OpType::Add, "file.txt", None, false);
818
819        let log1_clone = log1.clone();
820        let resolver = LastWriteWins::new();
821        let result = log1.merge_with_resolver(&log1_clone, &resolver, &peer1);
822
823        // Merging with self should add nothing and have no conflicts
824        assert_eq!(result.operations_added, 0);
825        assert_eq!(result.total_conflicts(), 0);
826        assert_eq!(log1.len(), 1);
827    }
828
829    #[test]
830    fn test_merge_with_resolver_mixed_conflicts() {
831        use super::super::conflict::LastWriteWins;
832
833        let peer1 = make_peer_id(1);
834        let peer2 = make_peer_id(2);
835
836        let mut log1 = PathOpLog::new();
837        log1.record(peer1, OpType::Add, "file1.txt", None, false);
838        log1.record(peer1, OpType::Add, "file2.txt", None, false);
839
840        let mut log2 = PathOpLog::new();
841        log2.record(peer2, OpType::Remove, "file1.txt", None, false); // conflicts
842        log2.record(peer2, OpType::Add, "file3.txt", None, false); // no conflict
843
844        let resolver = LastWriteWins::new();
845        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
846
847        // file3.txt should be added (no conflict)
848        // file1.txt has a conflict
849        assert_eq!(result.total_conflicts(), 1);
850
851        // file3.txt is always added
852        assert!(log1.resolve_path("file3.txt").is_some());
853    }
854
855    #[test]
856    fn test_merge_with_resolver_conflict_file() {
857        use super::super::conflict::{ConflictFile, Resolution};
858        use crate::linked_data::Link;
859
860        let peer1 = make_peer_id(1);
861        let peer2 = make_peer_id(2);
862
863        // Create deterministic content links for testing
864        let make_link = |seed: u8| {
865            let mut hash_bytes = [0u8; 32];
866            hash_bytes[0] = seed;
867            let hash = iroh_blobs::Hash::from_bytes(hash_bytes);
868            Link::new(crate::linked_data::LD_RAW_CODEC, hash)
869        };
870
871        let link_local = make_link(0xAA);
872        let link_other = make_link(0xBB);
873        let link_incoming = make_link(0xCC);
874
875        let mut log1 = PathOpLog::new();
876        log1.record(peer1, OpType::Add, "document.txt", Some(link_local), false);
877
878        let mut log2 = PathOpLog::new();
879        // Simulate peer2 being "ahead" with a higher timestamp
880        log2.record(peer2, OpType::Add, "other.txt", Some(link_other), false); // ts=1
881        log2.record(
882            peer2,
883            OpType::Add,
884            "document.txt",
885            Some(link_incoming.clone()),
886            false,
887        ); // ts=2, conflicts
888
889        let resolver = ConflictFile::new();
890        let result = log1.merge_with_resolver(&log2, &resolver, &peer1);
891
892        // Both files should be added (other.txt normally, document.txt as conflict file)
893        assert_eq!(result.operations_added, 2);
894        assert_eq!(result.conflicts_resolved.len(), 1);
895
896        // Check the resolution was RenameIncoming with content hash
897        let resolved = &result.conflicts_resolved[0];
898        match &resolved.resolution {
899            Resolution::RenameIncoming { new_path } => {
900                // Conflict file should use first 8 chars of incoming's content hash
901                let expected_version: String =
902                    link_incoming.hash().to_string().chars().take(8).collect();
903                assert_eq!(
904                    new_path,
905                    &std::path::PathBuf::from(format!("document.txt@{}", expected_version))
906                );
907            }
908            _ => panic!("Expected RenameIncoming, got {:?}", resolved.resolution),
909        }
910
911        // Original document.txt should resolve to local version (ts=1)
912        let original = log1.resolve_path("document.txt").unwrap();
913        assert_eq!(original.id.peer_id, peer1);
914
915        // Conflict file should exist with peer2's content
916        let resolved_conflict = &result.conflicts_resolved[0];
917        if let Resolution::RenameIncoming { new_path } = &resolved_conflict.resolution {
918            let conflict = log1.resolve_path(new_path).unwrap();
919            assert_eq!(conflict.id.peer_id, peer2);
920        }
921    }
922
923    #[test]
924    fn test_merge_logs_empty() {
925        use super::super::conflict::LastWriteWins;
926        use super::merge_logs;
927
928        let peer1 = make_peer_id(1);
929        let resolver = LastWriteWins::new();
930
931        // Empty slice
932        let (merged, results) = merge_logs::<LastWriteWins>(&[], &resolver, &peer1);
933        assert!(merged.is_empty());
934        assert!(results.is_empty());
935    }
936
937    #[test]
938    fn test_merge_logs_single() {
939        use super::super::conflict::LastWriteWins;
940        use super::merge_logs;
941
942        let peer1 = make_peer_id(1);
943        let mut log1 = PathOpLog::new();
944        log1.record(peer1, OpType::Add, "file.txt", None, false);
945
946        let resolver = LastWriteWins::new();
947        let (merged, results) = merge_logs(&[&log1], &resolver, &peer1);
948
949        // Single log just clones it
950        assert_eq!(merged.len(), 1);
951        assert!(results.is_empty());
952    }
953
954    #[test]
955    fn test_merge_logs_two_no_conflict() {
956        use super::super::conflict::LastWriteWins;
957        use super::merge_logs;
958
959        let peer1 = make_peer_id(1);
960        let peer2 = make_peer_id(2);
961
962        let mut log1 = PathOpLog::new();
963        log1.record(peer1, OpType::Add, "file1.txt", None, false);
964
965        let mut log2 = PathOpLog::new();
966        log2.record(peer2, OpType::Add, "file2.txt", None, false);
967
968        let resolver = LastWriteWins::new();
969        let (merged, results) = merge_logs(&[&log1, &log2], &resolver, &peer1);
970
971        // Both files should be present
972        assert_eq!(merged.len(), 2);
973        assert!(merged.resolve_path("file1.txt").is_some());
974        assert!(merged.resolve_path("file2.txt").is_some());
975
976        // One merge result
977        assert_eq!(results.len(), 1);
978        assert_eq!(results[0].operations_added, 1);
979        assert_eq!(results[0].total_conflicts(), 0);
980    }
981
982    #[test]
983    fn test_merge_logs_three_way() {
984        use super::super::conflict::ConflictFile;
985        use super::merge_logs;
986        use crate::linked_data::Link;
987
988        let peer1 = make_peer_id(1);
989        let peer2 = make_peer_id(2);
990        let peer3 = make_peer_id(3);
991
992        let make_link = |seed: u8| {
993            let mut hash_bytes = [0u8; 32];
994            hash_bytes[0] = seed;
995            let hash = iroh_blobs::Hash::from_bytes(hash_bytes);
996            Link::new(crate::linked_data::LD_RAW_CODEC, hash)
997        };
998
999        // Alice has file.txt
1000        let mut log_alice = PathOpLog::new();
1001        log_alice.record(peer1, OpType::Add, "file.txt", Some(make_link(0xAA)), false);
1002
1003        // Bob also has file.txt (different content)
1004        let mut log_bob = PathOpLog::new();
1005        log_bob.record(peer2, OpType::Add, "file.txt", Some(make_link(0xBB)), false);
1006
1007        // Carol also has file.txt (different content)
1008        let mut log_carol = PathOpLog::new();
1009        log_carol.record(peer3, OpType::Add, "file.txt", Some(make_link(0xCC)), false);
1010
1011        let resolver = ConflictFile::new();
1012        let (merged, results) = merge_logs(&[&log_alice, &log_bob, &log_carol], &resolver, &peer1);
1013
1014        // Two merge operations
1015        assert_eq!(results.len(), 2);
1016
1017        // Both merges should have conflicts
1018        assert_eq!(results[0].total_conflicts(), 1);
1019        assert_eq!(results[1].total_conflicts(), 1);
1020
1021        // All three versions should be present (original + 2 conflict files)
1022        assert_eq!(merged.len(), 3);
1023
1024        // Original file should exist
1025        assert!(merged.resolve_path("file.txt").is_some());
1026    }
1027
1028    #[test]
1029    fn test_merge_logs_accumulates_operations() {
1030        use super::super::conflict::LastWriteWins;
1031        use super::merge_logs;
1032
1033        let peer1 = make_peer_id(1);
1034        let peer2 = make_peer_id(2);
1035        let peer3 = make_peer_id(3);
1036
1037        let mut log1 = PathOpLog::new();
1038        log1.record(peer1, OpType::Add, "a.txt", None, false);
1039        log1.record(peer1, OpType::Add, "b.txt", None, false);
1040
1041        let mut log2 = PathOpLog::new();
1042        log2.record(peer2, OpType::Add, "c.txt", None, false);
1043
1044        let mut log3 = PathOpLog::new();
1045        log3.record(peer3, OpType::Add, "d.txt", None, false);
1046        log3.record(peer3, OpType::Add, "e.txt", None, false);
1047
1048        let resolver = LastWriteWins::new();
1049        let (merged, results) = merge_logs(&[&log1, &log2, &log3], &resolver, &peer1);
1050
1051        // All files should be present
1052        assert_eq!(merged.len(), 5);
1053
1054        // Two merge results
1055        assert_eq!(results.len(), 2);
1056        assert_eq!(results[0].operations_added, 1); // c.txt from log2
1057        assert_eq!(results[1].operations_added, 2); // d.txt, e.txt from log3
1058    }
1059}