1use 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#[derive(Debug, Clone, Default)]
28pub struct ConflictFile {
29 pub hash_length: usize,
31}
32
33impl ConflictFile {
34 pub fn new() -> Self {
36 Self { hash_length: 8 }
37 }
38
39 pub fn with_hash_length(hash_length: usize) -> Self {
41 Self { hash_length }
42 }
43
44 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 match (&conflict.base.op_type, &conflict.incoming.op_type) {
66 (OpType::Add, OpType::Add) => {
67 let version = match &conflict.incoming.content_link {
70 Some(link) => {
71 let hash_str = link.hash().to_string();
72 hash_str.chars().take(self.hash_length).collect::<String>()
74 }
75 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 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 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 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 match resolution {
176 Resolution::RenameIncoming { new_path } => {
177 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 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 assert_eq!(resolver.resolve(&conflict, &peer1), Resolution::UseBase);
225 }
226
227 #[test]
236 fn scenario_two_peers_create_same_file_offline() {
237 let alice = make_peer_id(1);
239 let bob = make_peer_id(2);
240
241 let alice_creates_notes = make_op_with_link(
243 alice,
244 1000, OpType::Add,
246 "notes.txt",
247 0x11, );
249
250 let bob_creates_notes = make_op_with_link(
252 bob,
253 1001, OpType::Add,
255 "notes.txt",
256 0x22, );
258
259 let conflict = Conflict::new(
261 PathBuf::from("notes.txt"),
262 alice_creates_notes, bob_creates_notes.clone(), );
265
266 let resolver = ConflictFile::new();
268 let resolution = resolver.resolve(&conflict, &alice);
269
270 match resolution {
272 Resolution::RenameIncoming { new_path } => {
273 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 #[test]
288 fn scenario_conflict_file_naming_examples() {
289 let path1 = ConflictFile::conflict_path(&PathBuf::from("report.txt"), "abc123de");
292 assert_eq!(path1.to_string_lossy(), "report.txt@abc123de");
293
294 let path2 = ConflictFile::conflict_path(&PathBuf::from("Makefile"), "deadbeef");
297 assert_eq!(path2.to_string_lossy(), "Makefile@deadbeef");
298
299 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 #[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 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 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 assert_eq!(
328 resolver.resolve(&mixed_conflict, &alice),
329 Resolution::UseIncoming
330 );
331
332 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 assert_eq!(
338 resolver.resolve(&both_remove, &alice),
339 Resolution::UseIncoming
340 );
341 }
342
343 #[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 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 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 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}