Skip to main content

common/mount/conflict/
conflict_file.rs

1//! Conflict-file resolver
2
3use std::path::PathBuf;
4
5use crate::crypto::PublicKey;
6
7use super::super::path_ops::OpType;
8use super::types::{Conflict, Resolution};
9use super::ConflictResolver;
10
11/// Conflict-file resolution (recommended for peer sync)
12///
13/// When a conflict is detected, renames the incoming file to include a version
14/// suffix based on the content hash: `<name>@<short-hash>.<ext>`.
15///
16/// This preserves both versions:
17/// - The base (local) operation wins and keeps the original path
18/// - The incoming operation is renamed to a conflict file with its content version
19///
20/// Users can then manually review and resolve the conflict files.
21///
22/// # Example
23///
24/// If `document.txt` conflicts and incoming has content hash `abc123...`:
25/// - Local version stays at `document.txt`
26/// - Incoming version becomes `document@abc123de.txt`
27#[derive(Debug, Clone, Default)]
28pub struct ConflictFile {
29    /// Number of hex characters to use from the hash (default: 8)
30    pub hash_length: usize,
31}
32
33impl ConflictFile {
34    /// Create a new ConflictFile resolver with default settings
35    pub fn new() -> Self {
36        Self { hash_length: 8 }
37    }
38
39    /// Create a new ConflictFile resolver with custom hash length
40    pub fn with_hash_length(hash_length: usize) -> Self {
41        Self { hash_length }
42    }
43
44    /// Generate a conflict filename using a version string
45    ///
46    /// Format: `<name>@<version>` (extension precedes the `@`)
47    ///
48    /// Examples: `config.toml@abc123de`, `README@abc123de`
49    pub fn conflict_path(path: &std::path::Path, version: &str) -> PathBuf {
50        let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("file");
51
52        let conflict_name = format!("{}@{}", file_name, version);
53
54        match path.parent() {
55            Some(parent) if parent != std::path::Path::new("") => parent.join(conflict_name),
56            _ => PathBuf::from(conflict_name),
57        }
58    }
59}
60
61impl ConflictResolver for ConflictFile {
62    fn resolve(&self, conflict: &Conflict, _local_peer: &PublicKey) -> Resolution {
63        // Only create conflict files for Add operations (file content conflicts)
64        // For Remove or Mv, use standard CRDT resolution
65        match (&conflict.base.op_type, &conflict.incoming.op_type) {
66            (OpType::Add, OpType::Add) => {
67                // Both are adds - create a conflict file for incoming
68                // Use the content link hash as the version identifier
69                let version = match &conflict.incoming.content_link {
70                    Some(link) => {
71                        let hash_str = link.hash().to_string();
72                        // Take first N characters of the hash
73                        hash_str.chars().take(self.hash_length).collect::<String>()
74                    }
75                    // Fallback to timestamp if no content link (shouldn't happen for Add)
76                    None => conflict.incoming.id.timestamp.to_string(),
77                };
78                let new_path = Self::conflict_path(&conflict.incoming.path, &version);
79                Resolution::RenameIncoming { new_path }
80            }
81            _ => {
82                // For other conflicts (Remove, Mv), fall back to last-write-wins
83                if conflict.incoming.id > conflict.base.id {
84                    Resolution::UseIncoming
85                } else {
86                    Resolution::UseBase
87                }
88            }
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::crypto::SecretKey;
97    use crate::linked_data::Link;
98    use crate::mount::path_ops::{OpId, OpType, PathOperation};
99
100    fn make_peer_id(seed: u8) -> PublicKey {
101        let mut seed_bytes = [0u8; 32];
102        seed_bytes[0] = seed;
103        let secret = SecretKey::from(seed_bytes);
104        secret.public()
105    }
106
107    fn make_op(peer_id: PublicKey, timestamp: u64, op_type: OpType, path: &str) -> PathOperation {
108        PathOperation {
109            id: OpId { timestamp, peer_id },
110            op_type,
111            path: PathBuf::from(path),
112            content_link: None,
113            is_dir: false,
114        }
115    }
116
117    fn make_op_with_link(
118        peer_id: PublicKey,
119        timestamp: u64,
120        op_type: OpType,
121        path: &str,
122        hash_seed: u8,
123    ) -> PathOperation {
124        // Create a deterministic hash from the seed
125        let mut hash_bytes = [0u8; 32];
126        hash_bytes[0] = hash_seed;
127        let hash = iroh_blobs::Hash::from_bytes(hash_bytes);
128        let link = Link::new(crate::linked_data::LD_RAW_CODEC, hash);
129
130        PathOperation {
131            id: OpId { timestamp, peer_id },
132            op_type,
133            path: PathBuf::from(path),
134            content_link: Some(link),
135            is_dir: false,
136        }
137    }
138
139    #[test]
140    fn test_conflict_file_path_with_extension() {
141        let path = PathBuf::from("document.txt");
142        let result = ConflictFile::conflict_path(&path, "abc12345");
143        assert_eq!(result, PathBuf::from("document.txt@abc12345"));
144    }
145
146    #[test]
147    fn test_conflict_file_path_without_extension() {
148        let path = PathBuf::from("README");
149        let result = ConflictFile::conflict_path(&path, "abc12345");
150        assert_eq!(result, PathBuf::from("README@abc12345"));
151    }
152
153    #[test]
154    fn test_conflict_file_path_nested() {
155        let path = PathBuf::from("docs/notes/file.md");
156        let result = ConflictFile::conflict_path(&path, "v42");
157        assert_eq!(result, PathBuf::from("docs/notes/file.md@v42"));
158    }
159
160    #[test]
161    fn test_conflict_file_resolver_add_vs_add() {
162        let peer1 = make_peer_id(1);
163        let peer2 = make_peer_id(2);
164
165        // Create ops with content links - hash_seed determines the hash
166        let base = make_op_with_link(peer1, 1, OpType::Add, "file.txt", 0xAA);
167        let incoming = make_op_with_link(peer2, 100, OpType::Add, "file.txt", 0xBB);
168
169        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming.clone());
170        let resolver = ConflictFile::new();
171
172        let resolution = resolver.resolve(&conflict, &peer1);
173
174        // Should rename incoming to conflict file using content hash
175        match resolution {
176            Resolution::RenameIncoming { new_path } => {
177                // The hash starts with 0xBB, so first 8 chars of hex representation
178                let expected_version: String = incoming
179                    .content_link
180                    .unwrap()
181                    .hash()
182                    .to_string()
183                    .chars()
184                    .take(8)
185                    .collect();
186                assert_eq!(
187                    new_path,
188                    PathBuf::from(format!("file.txt@{}", expected_version))
189                );
190            }
191            _ => panic!("Expected RenameIncoming, got {:?}", resolution),
192        }
193    }
194
195    #[test]
196    fn test_conflict_file_resolver_add_vs_remove() {
197        let peer1 = make_peer_id(1);
198        let peer2 = make_peer_id(2);
199
200        let base = make_op(peer1, 1, OpType::Add, "file.txt");
201        let incoming = make_op(peer2, 100, OpType::Remove, "file.txt");
202
203        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
204        let resolver = ConflictFile::new();
205
206        // Non-Add conflicts fall back to last-write-wins
207        // incoming (ts=100) > base (ts=1)
208        assert_eq!(resolver.resolve(&conflict, &peer1), Resolution::UseIncoming);
209    }
210
211    #[test]
212    fn test_conflict_file_resolver_remove_vs_add() {
213        let peer1 = make_peer_id(1);
214        let peer2 = make_peer_id(2);
215
216        let base = make_op(peer1, 100, OpType::Remove, "file.txt");
217        let incoming = make_op(peer2, 1, OpType::Add, "file.txt");
218
219        let conflict = Conflict::new(PathBuf::from("file.txt"), base, incoming);
220        let resolver = ConflictFile::new();
221
222        // Non-Add conflicts fall back to last-write-wins
223        // base (ts=100) > incoming (ts=1)
224        assert_eq!(resolver.resolve(&conflict, &peer1), Resolution::UseBase);
225    }
226
227    // =========================================================================
228    // READABLE SCENARIO TESTS
229    // These tests demonstrate real-world conflict scenarios in plain English.
230    // =========================================================================
231
232    /// Scenario: Alice and Bob both create a file called "notes.txt" offline.
233    /// When they sync, we keep Alice's version at the original path and
234    /// rename Bob's to include his content's hash, like "notes@a1b2c3d4.txt".
235    #[test]
236    fn scenario_two_peers_create_same_file_offline() {
237        // Setup: Alice and Bob are peers
238        let alice = make_peer_id(1);
239        let bob = make_peer_id(2);
240
241        // Alice creates "notes.txt" with some content (hash seed 0x11)
242        let alice_creates_notes = make_op_with_link(
243            alice,
244            1000, // timestamp
245            OpType::Add,
246            "notes.txt",
247            0x11, // produces hash starting with "11000000..."
248        );
249
250        // Bob also creates "notes.txt" with different content (hash seed 0x22)
251        let bob_creates_notes = make_op_with_link(
252            bob,
253            1001, // slightly later timestamp
254            OpType::Add,
255            "notes.txt",
256            0x22, // produces hash starting with "22000000..."
257        );
258
259        // When we merge Bob's changes into Alice's log, there's a conflict
260        let conflict = Conflict::new(
261            PathBuf::from("notes.txt"),
262            alice_creates_notes,       // base: Alice's local version
263            bob_creates_notes.clone(), // incoming: Bob's version to merge
264        );
265
266        // Use ConflictFile resolver - it creates conflict files for Add vs Add
267        let resolver = ConflictFile::new();
268        let resolution = resolver.resolve(&conflict, &alice);
269
270        // Result: Bob's file gets renamed to include his content hash
271        match resolution {
272            Resolution::RenameIncoming { new_path } => {
273                // The new path should be "notes.txt@<first-8-chars-of-hash>"
274                let path_str = new_path.to_string_lossy();
275                assert!(
276                    path_str.starts_with("notes.txt@"),
277                    "Should be notes.txt@<hash>"
278                );
279                assert!(path_str.contains("22"), "Should contain Bob's hash prefix");
280            }
281            other => panic!("Expected RenameIncoming, got {:?}", other),
282        }
283    }
284
285    /// Scenario: File naming format examples.
286    /// Shows exactly what conflict filenames look like.
287    #[test]
288    fn scenario_conflict_file_naming_examples() {
289        // Example 1: Simple text file
290        // "report.txt" with hash "abc123def456..." becomes "report.txt@abc123de"
291        let path1 = ConflictFile::conflict_path(&PathBuf::from("report.txt"), "abc123de");
292        assert_eq!(path1.to_string_lossy(), "report.txt@abc123de");
293
294        // Example 2: File without extension
295        // "Makefile" with hash "deadbeef..." becomes "Makefile@deadbeef"
296        let path2 = ConflictFile::conflict_path(&PathBuf::from("Makefile"), "deadbeef");
297        assert_eq!(path2.to_string_lossy(), "Makefile@deadbeef");
298
299        // Example 3: Nested path
300        // "src/lib/utils.rs" with hash "cafebabe..." becomes "src/lib/utils.rs@cafebabe"
301        let path3 = ConflictFile::conflict_path(&PathBuf::from("src/lib/utils.rs"), "cafebabe");
302        assert_eq!(path3.to_string_lossy(), "src/lib/utils.rs@cafebabe");
303    }
304
305    /// Scenario: Different conflict types get different resolutions.
306    /// Only Add-vs-Add creates conflict files. Other conflicts use last-write-wins.
307    #[test]
308    fn scenario_different_conflict_types() {
309        let alice = make_peer_id(1);
310        let bob = make_peer_id(2);
311        let resolver = ConflictFile::new();
312
313        // Case 1: Add vs Add -> Creates conflict file (both users want the file)
314        let alice_adds = make_op_with_link(alice, 1, OpType::Add, "file.txt", 0xAA);
315        let bob_adds = make_op_with_link(bob, 2, OpType::Add, "file.txt", 0xBB);
316        let add_conflict = Conflict::new(PathBuf::from("file.txt"), alice_adds, bob_adds);
317        assert!(matches!(
318            resolver.resolve(&add_conflict, &alice),
319            Resolution::RenameIncoming { .. }
320        ));
321
322        // Case 2: Add vs Remove -> Last-write-wins (one wants it, one doesn't)
323        let alice_adds = make_op(alice, 1, OpType::Add, "file.txt");
324        let bob_removes = make_op(bob, 2, OpType::Remove, "file.txt");
325        let mixed_conflict = Conflict::new(PathBuf::from("file.txt"), alice_adds, bob_removes);
326        // Bob's remove (ts=2) wins over Alice's add (ts=1)
327        assert_eq!(
328            resolver.resolve(&mixed_conflict, &alice),
329            Resolution::UseIncoming
330        );
331
332        // Case 3: Remove vs Remove -> Last-write-wins (both want it gone, no conflict file needed)
333        let alice_removes = make_op(alice, 1, OpType::Remove, "file.txt");
334        let bob_removes = make_op(bob, 2, OpType::Remove, "file.txt");
335        let both_remove = Conflict::new(PathBuf::from("file.txt"), alice_removes, bob_removes);
336        // Bob's remove (ts=2) wins
337        assert_eq!(
338            resolver.resolve(&both_remove, &alice),
339            Resolution::UseIncoming
340        );
341    }
342
343    /// Scenario: Custom hash length.
344    /// You can configure how many characters of the hash to use.
345    #[test]
346    fn scenario_custom_hash_length() {
347        let alice = make_peer_id(1);
348        let bob = make_peer_id(2);
349
350        let alice_adds = make_op_with_link(alice, 1, OpType::Add, "doc.md", 0xAA);
351        let bob_adds = make_op_with_link(bob, 2, OpType::Add, "doc.md", 0xBB);
352        let conflict = Conflict::new(PathBuf::from("doc.md"), alice_adds, bob_adds);
353
354        // Default: 8 characters
355        let resolver_8 = ConflictFile::new();
356        if let Resolution::RenameIncoming { new_path } = resolver_8.resolve(&conflict, &alice) {
357            let name = new_path.file_name().unwrap().to_string_lossy();
358            let hash_part = name.split('@').nth(1).unwrap();
359            assert_eq!(hash_part.len(), 8, "Default should use 8 hash chars");
360        }
361
362        // Custom: 4 characters (shorter, but more collision risk)
363        let resolver_4 = ConflictFile::with_hash_length(4);
364        if let Resolution::RenameIncoming { new_path } = resolver_4.resolve(&conflict, &alice) {
365            let name = new_path.file_name().unwrap().to_string_lossy();
366            let hash_part = name.split('@').nth(1).unwrap();
367            assert_eq!(hash_part.len(), 4, "Custom should use 4 hash chars");
368        }
369
370        // Custom: 16 characters (longer, less collision risk)
371        let resolver_16 = ConflictFile::with_hash_length(16);
372        if let Resolution::RenameIncoming { new_path } = resolver_16.resolve(&conflict, &alice) {
373            let name = new_path.file_name().unwrap().to_string_lossy();
374            let hash_part = name.split('@').nth(1).unwrap();
375            assert_eq!(hash_part.len(), 16, "Custom should use 16 hash chars");
376        }
377    }
378}