1use std::collections::{HashMap, HashSet};
7
8use serde::{Deserialize as SerdeDeserialize, Serialize};
9
10use super::collect::{collect_commit_files, load_commit_and_reader};
11use crate::cid::VoidCid;
12use crate::crypto::KeyVault;
13
14use crate::ContentHash;
15use crate::store::ObjectStoreExt;
16use crate::Result;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, SerdeDeserialize)]
20pub enum Side {
21 Ours,
23 Theirs,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, SerdeDeserialize)]
29pub enum ThreeWayStatus {
30 Unchanged,
32 OursOnly,
34 TheirsOnly,
36 BothSame,
38 Conflict,
40 AddedBothSame,
42 AddedBothDifferent,
44 DeleteModify {
46 deleted_by: Side,
48 },
49 BothDeleted,
51}
52
53impl ThreeWayStatus {
54 pub fn is_conflict(&self) -> bool {
56 matches!(
57 self,
58 ThreeWayStatus::Conflict
59 | ThreeWayStatus::AddedBothDifferent
60 | ThreeWayStatus::DeleteModify { .. }
61 )
62 }
63
64 pub fn is_auto_mergeable(&self) -> bool {
66 matches!(
67 self,
68 ThreeWayStatus::OursOnly
69 | ThreeWayStatus::TheirsOnly
70 | ThreeWayStatus::BothSame
71 | ThreeWayStatus::AddedBothSame
72 | ThreeWayStatus::BothDeleted
73 )
74 }
75}
76
77#[derive(Debug, Clone, Serialize, SerdeDeserialize)]
79pub struct ThreeWayDiff {
80 pub files: HashMap<String, ThreeWayStatus>,
82 pub conflicts: Vec<String>,
84 pub auto_mergeable: Vec<String>,
86}
87
88impl ThreeWayDiff {
89 pub fn empty() -> Self {
91 Self {
92 files: HashMap::new(),
93 conflicts: Vec::new(),
94 auto_mergeable: Vec::new(),
95 }
96 }
97
98 pub fn is_clean(&self) -> bool {
100 self.conflicts.is_empty()
101 }
102
103 pub fn len(&self) -> usize {
105 self.files.len()
106 }
107
108 pub fn is_empty(&self) -> bool {
110 self.files.is_empty()
111 }
112}
113
114fn collect_commit_file_map<S: ObjectStoreExt>(
116 store: &S,
117 vault: &KeyVault,
118 commit_cid: &VoidCid,
119) -> Result<HashMap<String, ContentHash>> {
120 let (commit, reader) = load_commit_and_reader(store, vault, commit_cid)?;
121 let files = collect_commit_files(store, &commit, &reader)?;
122 Ok(files.into_iter().map(|f| (f.path, f.content_hash)).collect())
123}
124
125fn classify_file(
143 base: Option<&ContentHash>,
144 ours: Option<&ContentHash>,
145 theirs: Option<&ContentHash>,
146) -> Option<ThreeWayStatus> {
147 match (base, ours, theirs) {
148 (Some(b), Some(o), Some(t)) => {
150 if o == b && t == b {
151 Some(ThreeWayStatus::Unchanged)
152 } else if o == b && t != b {
153 Some(ThreeWayStatus::TheirsOnly)
154 } else if o != b && t == b {
155 Some(ThreeWayStatus::OursOnly)
156 } else if o == t {
157 Some(ThreeWayStatus::BothSame)
158 } else {
159 Some(ThreeWayStatus::Conflict)
160 }
161 }
162
163 (None, Some(o), Some(t)) => {
165 if o == t {
166 Some(ThreeWayStatus::AddedBothSame)
167 } else {
168 Some(ThreeWayStatus::AddedBothDifferent)
169 }
170 }
171
172 (Some(b), None, Some(t)) => {
174 if t == b {
175 Some(ThreeWayStatus::OursOnly)
176 } else {
177 Some(ThreeWayStatus::DeleteModify {
178 deleted_by: Side::Ours,
179 })
180 }
181 }
182
183 (Some(b), Some(o), None) => {
185 if o == b {
186 Some(ThreeWayStatus::TheirsOnly)
187 } else {
188 Some(ThreeWayStatus::DeleteModify {
189 deleted_by: Side::Theirs,
190 })
191 }
192 }
193
194 (Some(_), None, None) => Some(ThreeWayStatus::BothDeleted),
196
197 (None, Some(_), None) => Some(ThreeWayStatus::OursOnly),
199
200 (None, None, Some(_)) => Some(ThreeWayStatus::TheirsOnly),
202
203 (None, None, None) => None,
205 }
206}
207
208pub fn diff_three_way<S: ObjectStoreExt>(
210 store: &S,
211 vault: &KeyVault,
212 base: Option<&VoidCid>,
213 ours: &VoidCid,
214 theirs: &VoidCid,
215) -> Result<ThreeWayDiff> {
216 let base_files: HashMap<String, ContentHash> = match base {
218 Some(cid) => collect_commit_file_map(store, vault, cid)?,
219 None => HashMap::new(),
220 };
221
222 let ours_files = collect_commit_file_map(store, vault, ours)?;
223 let theirs_files = collect_commit_file_map(store, vault, theirs)?;
224
225 let mut all_paths: HashSet<&str> = HashSet::new();
227 all_paths.extend(base_files.keys().map(|s| s.as_str()));
228 all_paths.extend(ours_files.keys().map(|s| s.as_str()));
229 all_paths.extend(theirs_files.keys().map(|s| s.as_str()));
230
231 let mut files = HashMap::new();
232 let mut conflicts = Vec::new();
233 let mut auto_mergeable = Vec::new();
234
235 for path in all_paths {
237 let base_hash = base_files.get(path);
238 let ours_hash = ours_files.get(path);
239 let theirs_hash = theirs_files.get(path);
240
241 if let Some(status) = classify_file(base_hash, ours_hash, theirs_hash) {
242 if matches!(status, ThreeWayStatus::Unchanged) {
244 continue;
245 }
246
247 let path_owned = path.to_string();
248
249 if status.is_conflict() {
250 conflicts.push(path_owned.clone());
251 } else if status.is_auto_mergeable() {
252 auto_mergeable.push(path_owned.clone());
253 }
254
255 files.insert(path_owned, status);
256 }
257 }
258
259 conflicts.sort();
261 auto_mergeable.sort();
262
263 Ok(ThreeWayDiff {
264 files,
265 conflicts,
266 auto_mergeable,
267 })
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn hash(val: u8) -> ContentHash {
276 let mut h = [0u8; 32];
277 h[0] = val;
278 ContentHash(h)
279 }
280
281 #[test]
282 fn test_classify_unchanged() {
283 let a = hash(1);
284 let result = classify_file(Some(&a), Some(&a), Some(&a));
285 assert_eq!(result, Some(ThreeWayStatus::Unchanged));
286 }
287
288 #[test]
289 fn test_classify_theirs_only() {
290 let a = hash(1);
291 let b = hash(2);
292 let result = classify_file(Some(&a), Some(&a), Some(&b));
293 assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
294 }
295
296 #[test]
297 fn test_classify_ours_only() {
298 let a = hash(1);
299 let b = hash(2);
300 let result = classify_file(Some(&a), Some(&b), Some(&a));
301 assert_eq!(result, Some(ThreeWayStatus::OursOnly));
302 }
303
304 #[test]
305 fn test_classify_both_same() {
306 let a = hash(1);
307 let b = hash(2);
308 let result = classify_file(Some(&a), Some(&b), Some(&b));
309 assert_eq!(result, Some(ThreeWayStatus::BothSame));
310 }
311
312 #[test]
313 fn test_classify_conflict() {
314 let a = hash(1);
315 let b = hash(2);
316 let c = hash(3);
317 let result = classify_file(Some(&a), Some(&b), Some(&c));
318 assert_eq!(result, Some(ThreeWayStatus::Conflict));
319 }
320
321 #[test]
322 fn test_classify_added_both_same() {
323 let a = hash(1);
324 let result = classify_file(None, Some(&a), Some(&a));
325 assert_eq!(result, Some(ThreeWayStatus::AddedBothSame));
326 }
327
328 #[test]
329 fn test_classify_added_both_different() {
330 let a = hash(1);
331 let b = hash(2);
332 let result = classify_file(None, Some(&a), Some(&b));
333 assert_eq!(result, Some(ThreeWayStatus::AddedBothDifferent));
334 }
335
336 #[test]
337 fn test_classify_ours_deleted() {
338 let a = hash(1);
339 let result = classify_file(Some(&a), None, Some(&a));
340 assert_eq!(result, Some(ThreeWayStatus::OursOnly));
341 }
342
343 #[test]
344 fn test_classify_theirs_deleted() {
345 let a = hash(1);
346 let result = classify_file(Some(&a), Some(&a), None);
347 assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
348 }
349
350 #[test]
351 fn test_classify_both_deleted() {
352 let a = hash(1);
353 let result = classify_file(Some(&a), None, None);
354 assert_eq!(result, Some(ThreeWayStatus::BothDeleted));
355 }
356
357 #[test]
358 fn test_classify_delete_modify_ours_deleted() {
359 let a = hash(1);
360 let b = hash(2);
361 let result = classify_file(Some(&a), None, Some(&b));
362 assert_eq!(
363 result,
364 Some(ThreeWayStatus::DeleteModify {
365 deleted_by: Side::Ours
366 })
367 );
368 }
369
370 #[test]
371 fn test_classify_delete_modify_theirs_deleted() {
372 let a = hash(1);
373 let b = hash(2);
374 let result = classify_file(Some(&a), Some(&b), None);
375 assert_eq!(
376 result,
377 Some(ThreeWayStatus::DeleteModify {
378 deleted_by: Side::Theirs
379 })
380 );
381 }
382
383 #[test]
384 fn test_classify_ours_added_only() {
385 let a = hash(1);
386 let result = classify_file(None, Some(&a), None);
387 assert_eq!(result, Some(ThreeWayStatus::OursOnly));
388 }
389
390 #[test]
391 fn test_classify_theirs_added_only() {
392 let a = hash(1);
393 let result = classify_file(None, None, Some(&a));
394 assert_eq!(result, Some(ThreeWayStatus::TheirsOnly));
395 }
396
397 #[test]
398 fn test_classify_none() {
399 let result = classify_file(None, None, None);
400 assert_eq!(result, None);
401 }
402
403 #[test]
404 fn test_status_is_conflict() {
405 assert!(ThreeWayStatus::Conflict.is_conflict());
406 assert!(ThreeWayStatus::AddedBothDifferent.is_conflict());
407 assert!(ThreeWayStatus::DeleteModify {
408 deleted_by: Side::Ours
409 }
410 .is_conflict());
411 assert!(ThreeWayStatus::DeleteModify {
412 deleted_by: Side::Theirs
413 }
414 .is_conflict());
415
416 assert!(!ThreeWayStatus::Unchanged.is_conflict());
417 assert!(!ThreeWayStatus::OursOnly.is_conflict());
418 assert!(!ThreeWayStatus::TheirsOnly.is_conflict());
419 assert!(!ThreeWayStatus::BothSame.is_conflict());
420 assert!(!ThreeWayStatus::AddedBothSame.is_conflict());
421 assert!(!ThreeWayStatus::BothDeleted.is_conflict());
422 }
423
424 #[test]
425 fn test_status_is_auto_mergeable() {
426 assert!(ThreeWayStatus::OursOnly.is_auto_mergeable());
427 assert!(ThreeWayStatus::TheirsOnly.is_auto_mergeable());
428 assert!(ThreeWayStatus::BothSame.is_auto_mergeable());
429 assert!(ThreeWayStatus::AddedBothSame.is_auto_mergeable());
430 assert!(ThreeWayStatus::BothDeleted.is_auto_mergeable());
431
432 assert!(!ThreeWayStatus::Unchanged.is_auto_mergeable());
433 assert!(!ThreeWayStatus::Conflict.is_auto_mergeable());
434 assert!(!ThreeWayStatus::AddedBothDifferent.is_auto_mergeable());
435 assert!(!ThreeWayStatus::DeleteModify {
436 deleted_by: Side::Ours
437 }
438 .is_auto_mergeable());
439 }
440
441 #[test]
442 fn test_three_way_diff_empty() {
443 let diff = ThreeWayDiff::empty();
444 assert!(diff.is_empty());
445 assert!(diff.is_clean());
446 assert_eq!(diff.len(), 0);
447 }
448}