1#![allow(clippy::missing_errors_doc)]
26
27use std::collections::BTreeMap;
28use std::fmt;
29use std::path::PathBuf;
30
31use serde::{Deserialize, Serialize};
32
33use super::patch::{PatchSet, PatchValue};
34use super::types::EpochId;
35
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct JoinResult {
43 pub merged: PatchSet,
45 pub conflicts: Vec<PathConflict>,
47}
48
49impl JoinResult {
50 #[must_use]
52 pub const fn is_clean(&self) -> bool {
53 self.conflicts.is_empty()
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PathConflict {
70 pub path: PathBuf,
72 pub sides: [PatchValue; 2],
75 pub reason: ConflictReason,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ConflictReason {
87 DivergentAdd,
89 DivergentModify,
91 ModifyDelete,
93 RenameConflict,
95 DivergentRename,
97 Incompatible,
99}
100
101impl fmt::Display for ConflictReason {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 Self::DivergentAdd => write!(f, "both sides add different content"),
105 Self::DivergentModify => write!(f, "both sides modify to different results"),
106 Self::ModifyDelete => write!(f, "one side modifies, the other deletes"),
107 Self::RenameConflict => write!(f, "rename conflicts with another operation"),
108 Self::DivergentRename => write!(f, "both sides rename to different destinations"),
109 Self::Incompatible => write!(f, "incompatible operations on the same path"),
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct EpochMismatch {
121 pub left: EpochId,
122 pub right: EpochId,
123}
124
125impl fmt::Display for EpochMismatch {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 write!(
128 f,
129 "cannot join patch-sets with different base epochs: {} vs {}",
130 self.left, self.right
131 )
132 }
133}
134
135impl std::error::Error for EpochMismatch {}
136
137pub fn join(a: &PatchSet, b: &PatchSet) -> Result<JoinResult, EpochMismatch> {
165 if a.base_epoch != b.base_epoch {
166 return Err(EpochMismatch {
167 left: a.base_epoch.clone(),
168 right: b.base_epoch.clone(),
169 });
170 }
171
172 let mut merged = BTreeMap::new();
173 let mut conflicts = Vec::new();
174
175 let all_paths: BTreeMap<&PathBuf, (Option<&PatchValue>, Option<&PatchValue>)> = {
177 let mut m: BTreeMap<&PathBuf, (Option<&PatchValue>, Option<&PatchValue>)> = BTreeMap::new();
178 for (path, val) in &a.patches {
179 m.entry(path).or_insert((None, None)).0 = Some(val);
180 }
181 for (path, val) in &b.patches {
182 m.entry(path).or_insert((None, None)).1 = Some(val);
183 }
184 m
185 };
186
187 for (path, (left, right)) in &all_paths {
188 match (left, right) {
189 (Some(l), None) => {
191 merged.insert((*path).clone(), (*l).clone());
192 }
193 (None, Some(r)) => {
195 merged.insert((*path).clone(), (*r).clone());
196 }
197 (Some(l), Some(r)) => {
199 if *l == *r {
200 merged.insert((*path).clone(), (*l).clone());
202 } else {
203 let reason = classify_conflict(l, r);
205 let sides = sorted_sides(l, r);
206 conflicts.push(PathConflict {
207 path: (*path).clone(),
208 sides,
209 reason,
210 });
211 }
212 }
213 (None, None) => {}
215 }
216 }
217
218 Ok(JoinResult {
219 merged: PatchSet {
220 base_epoch: a.base_epoch.clone(),
221 patches: merged,
222 },
223 conflicts,
224 })
225}
226
227fn classify_conflict(left: &PatchValue, right: &PatchValue) -> ConflictReason {
233 use PatchValue::{Add, Delete, Modify, Rename};
234 match (left, right) {
235 (Add { .. }, Add { .. }) => ConflictReason::DivergentAdd,
237
238 (Modify { .. }, Modify { .. }) => ConflictReason::DivergentModify,
240
241 (Modify { .. }, Delete { .. }) | (Delete { .. }, Modify { .. }) => {
243 ConflictReason::ModifyDelete
244 }
245
246 (Rename { from: from_l, .. }, Rename { from: from_r, .. }) => {
248 if from_l == from_r {
249 ConflictReason::DivergentRename
250 } else {
251 ConflictReason::Incompatible
252 }
253 }
254
255 (Rename { .. }, _) | (_, Rename { .. }) => ConflictReason::RenameConflict,
257
258 _ => ConflictReason::Incompatible,
260 }
261}
262
263fn sorted_sides(left: &PatchValue, right: &PatchValue) -> [PatchValue; 2] {
267 let l_json = serde_json::to_string(left).unwrap_or_default();
268 let r_json = serde_json::to_string(right).unwrap_or_default();
269 if l_json <= r_json {
270 [left.clone(), right.clone()]
271 } else {
272 [right.clone(), left.clone()]
273 }
274}
275
276#[cfg(test)]
281#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
282mod tests {
283 use super::*;
284 use crate::model::patch::{FileId, PatchSet, PatchValue};
285 use crate::model::types::{EpochId, GitOid};
286
287 fn oid(c: char) -> String {
292 c.to_string().repeat(40)
293 }
294
295 fn epoch(c: char) -> EpochId {
296 EpochId::new(&oid(c)).unwrap()
297 }
298
299 fn git_oid(c: char) -> GitOid {
300 GitOid::new(&oid(c)).unwrap()
301 }
302
303 fn fid(n: u128) -> FileId {
304 FileId::new(n)
305 }
306
307 fn empty_ps(e: char) -> PatchSet {
308 PatchSet::empty(epoch(e))
309 }
310
311 #[test]
316 fn join_epoch_mismatch() {
317 let a = empty_ps('a');
318 let b = empty_ps('b');
319 let err = join(&a, &b).unwrap_err();
320 assert_eq!(err.left, epoch('a'));
321 assert_eq!(err.right, epoch('b'));
322 }
323
324 #[test]
329 fn join_disjoint_paths() {
330 let mut a = empty_ps('a');
331 a.patches.insert(
332 "src/foo.rs".into(),
333 PatchValue::Add {
334 blob: git_oid('1'),
335 file_id: fid(1),
336 },
337 );
338
339 let mut b = empty_ps('a');
340 b.patches.insert(
341 "src/bar.rs".into(),
342 PatchValue::Add {
343 blob: git_oid('2'),
344 file_id: fid(2),
345 },
346 );
347
348 let result = join(&a, &b).unwrap();
349 assert!(result.is_clean());
350 assert_eq!(result.merged.len(), 2);
351 assert!(
352 result
353 .merged
354 .patches
355 .contains_key(&PathBuf::from("src/foo.rs"))
356 );
357 assert!(
358 result
359 .merged
360 .patches
361 .contains_key(&PathBuf::from("src/bar.rs"))
362 );
363 }
364
365 #[test]
366 fn join_empty_with_non_empty() {
367 let a = empty_ps('a');
368 let mut b = empty_ps('a');
369 b.patches.insert(
370 "file.txt".into(),
371 PatchValue::Add {
372 blob: git_oid('1'),
373 file_id: fid(1),
374 },
375 );
376
377 let result = join(&a, &b).unwrap();
378 assert!(result.is_clean());
379 assert_eq!(result.merged.len(), 1);
380 }
381
382 #[test]
383 fn join_two_empties() {
384 let a = empty_ps('a');
385 let b = empty_ps('a');
386 let result = join(&a, &b).unwrap();
387 assert!(result.is_clean());
388 assert!(result.merged.is_empty());
389 }
390
391 #[test]
396 fn join_identical_add() {
397 let pv = PatchValue::Add {
398 blob: git_oid('1'),
399 file_id: fid(1),
400 };
401 let mut a = empty_ps('a');
402 a.patches.insert("file.rs".into(), pv.clone());
403
404 let mut b = empty_ps('a');
405 b.patches.insert("file.rs".into(), pv.clone());
406
407 let result = join(&a, &b).unwrap();
408 assert!(result.is_clean());
409 assert_eq!(result.merged.len(), 1);
410 assert_eq!(result.merged.patches[&PathBuf::from("file.rs")], pv);
411 }
412
413 #[test]
414 fn join_identical_modify() {
415 let pv = PatchValue::Modify {
416 base_blob: git_oid('1'),
417 new_blob: git_oid('2'),
418 file_id: fid(1),
419 };
420 let mut a = empty_ps('a');
421 a.patches.insert("file.rs".into(), pv.clone());
422
423 let mut b = empty_ps('a');
424 b.patches.insert("file.rs".into(), pv);
425
426 let result = join(&a, &b).unwrap();
427 assert!(result.is_clean());
428 assert_eq!(result.merged.len(), 1);
429 }
430
431 #[test]
432 fn join_identical_delete() {
433 let pv = PatchValue::Delete {
434 previous_blob: git_oid('1'),
435 file_id: fid(1),
436 };
437 let mut a = empty_ps('a');
438 a.patches.insert("file.rs".into(), pv.clone());
439
440 let mut b = empty_ps('a');
441 b.patches.insert("file.rs".into(), pv);
442
443 let result = join(&a, &b).unwrap();
444 assert!(result.is_clean());
445 assert_eq!(result.merged.len(), 1);
446 }
447
448 #[test]
449 fn join_identical_rename() {
450 let pv = PatchValue::Rename {
451 from: "old.rs".into(),
452 file_id: fid(1),
453 new_blob: None,
454 };
455 let mut a = empty_ps('a');
456 a.patches.insert("new.rs".into(), pv.clone());
457
458 let mut b = empty_ps('a');
459 b.patches.insert("new.rs".into(), pv);
460
461 let result = join(&a, &b).unwrap();
462 assert!(result.is_clean());
463 assert_eq!(result.merged.len(), 1);
464 }
465
466 #[test]
471 fn join_divergent_add() {
472 let mut a = empty_ps('a');
473 a.patches.insert(
474 "file.rs".into(),
475 PatchValue::Add {
476 blob: git_oid('1'),
477 file_id: fid(1),
478 },
479 );
480
481 let mut b = empty_ps('a');
482 b.patches.insert(
483 "file.rs".into(),
484 PatchValue::Add {
485 blob: git_oid('2'),
486 file_id: fid(2),
487 },
488 );
489
490 let result = join(&a, &b).unwrap();
491 assert!(!result.is_clean());
492 assert_eq!(result.conflicts.len(), 1);
493 assert_eq!(result.conflicts[0].path, PathBuf::from("file.rs"));
494 assert_eq!(result.conflicts[0].reason, ConflictReason::DivergentAdd);
495 assert!(
497 !result
498 .merged
499 .patches
500 .contains_key(&PathBuf::from("file.rs"))
501 );
502 }
503
504 #[test]
505 fn join_divergent_modify() {
506 let mut a = empty_ps('a');
507 a.patches.insert(
508 "file.rs".into(),
509 PatchValue::Modify {
510 base_blob: git_oid('1'),
511 new_blob: git_oid('2'),
512 file_id: fid(1),
513 },
514 );
515
516 let mut b = empty_ps('a');
517 b.patches.insert(
518 "file.rs".into(),
519 PatchValue::Modify {
520 base_blob: git_oid('1'),
521 new_blob: git_oid('3'),
522 file_id: fid(1),
523 },
524 );
525
526 let result = join(&a, &b).unwrap();
527 assert!(!result.is_clean());
528 assert_eq!(result.conflicts.len(), 1);
529 assert_eq!(result.conflicts[0].reason, ConflictReason::DivergentModify);
530 }
531
532 #[test]
533 fn join_modify_delete() {
534 let mut a = empty_ps('a');
535 a.patches.insert(
536 "file.rs".into(),
537 PatchValue::Modify {
538 base_blob: git_oid('1'),
539 new_blob: git_oid('2'),
540 file_id: fid(1),
541 },
542 );
543
544 let mut b = empty_ps('a');
545 b.patches.insert(
546 "file.rs".into(),
547 PatchValue::Delete {
548 previous_blob: git_oid('1'),
549 file_id: fid(1),
550 },
551 );
552
553 let result = join(&a, &b).unwrap();
554 assert!(!result.is_clean());
555 assert_eq!(result.conflicts.len(), 1);
556 assert_eq!(result.conflicts[0].reason, ConflictReason::ModifyDelete);
557 }
558
559 #[test]
560 fn join_divergent_rename() {
561 let mut a = empty_ps('a');
575 a.patches.insert(
576 "dest.rs".into(),
577 PatchValue::Rename {
578 from: "src.rs".into(),
579 file_id: fid(1),
580 new_blob: None,
581 },
582 );
583
584 let mut b = empty_ps('a');
585 b.patches.insert(
586 "dest.rs".into(),
587 PatchValue::Rename {
588 from: "src.rs".into(),
589 file_id: fid(1),
590 new_blob: Some(git_oid('2')),
591 },
592 );
593
594 let result = join(&a, &b).unwrap();
595 assert!(!result.is_clean());
596 assert_eq!(result.conflicts.len(), 1);
597 assert_eq!(result.conflicts[0].reason, ConflictReason::DivergentRename);
598 }
599
600 #[test]
601 fn join_rename_vs_modify() {
602 let mut a = empty_ps('a');
603 a.patches.insert(
604 "file.rs".into(),
605 PatchValue::Rename {
606 from: "old.rs".into(),
607 file_id: fid(1),
608 new_blob: None,
609 },
610 );
611
612 let mut b = empty_ps('a');
613 b.patches.insert(
614 "file.rs".into(),
615 PatchValue::Modify {
616 base_blob: git_oid('1'),
617 new_blob: git_oid('2'),
618 file_id: fid(1),
619 },
620 );
621
622 let result = join(&a, &b).unwrap();
623 assert!(!result.is_clean());
624 assert_eq!(result.conflicts[0].reason, ConflictReason::RenameConflict);
625 }
626
627 #[test]
628 fn join_add_vs_delete() {
629 let mut a = empty_ps('a');
630 a.patches.insert(
631 "file.rs".into(),
632 PatchValue::Add {
633 blob: git_oid('1'),
634 file_id: fid(1),
635 },
636 );
637
638 let mut b = empty_ps('a');
639 b.patches.insert(
640 "file.rs".into(),
641 PatchValue::Delete {
642 previous_blob: git_oid('2'),
643 file_id: fid(2),
644 },
645 );
646
647 let result = join(&a, &b).unwrap();
648 assert!(!result.is_clean());
649 assert_eq!(result.conflicts[0].reason, ConflictReason::Incompatible);
650 }
651
652 #[test]
657 fn join_mixed_scenario() {
658 let mut a = empty_ps('a');
659 a.patches.insert(
661 "only_a.rs".into(),
662 PatchValue::Add {
663 blob: git_oid('1'),
664 file_id: fid(1),
665 },
666 );
667 a.patches.insert(
669 "shared.rs".into(),
670 PatchValue::Modify {
671 base_blob: git_oid('2'),
672 new_blob: git_oid('3'),
673 file_id: fid(2),
674 },
675 );
676 a.patches.insert(
678 "conflict.rs".into(),
679 PatchValue::Add {
680 blob: git_oid('4'),
681 file_id: fid(3),
682 },
683 );
684
685 let mut b = empty_ps('a');
686 b.patches.insert(
688 "only_b.rs".into(),
689 PatchValue::Delete {
690 previous_blob: git_oid('5'),
691 file_id: fid(4),
692 },
693 );
694 b.patches.insert(
696 "shared.rs".into(),
697 PatchValue::Modify {
698 base_blob: git_oid('2'),
699 new_blob: git_oid('3'),
700 file_id: fid(2),
701 },
702 );
703 b.patches.insert(
705 "conflict.rs".into(),
706 PatchValue::Add {
707 blob: git_oid('6'),
708 file_id: fid(5),
709 },
710 );
711
712 let result = join(&a, &b).unwrap();
713 assert_eq!(result.merged.len(), 3);
715 assert!(
716 result
717 .merged
718 .patches
719 .contains_key(&PathBuf::from("only_a.rs"))
720 );
721 assert!(
722 result
723 .merged
724 .patches
725 .contains_key(&PathBuf::from("only_b.rs"))
726 );
727 assert!(
728 result
729 .merged
730 .patches
731 .contains_key(&PathBuf::from("shared.rs"))
732 );
733 assert!(
734 !result
735 .merged
736 .patches
737 .contains_key(&PathBuf::from("conflict.rs"))
738 );
739 assert_eq!(result.conflicts.len(), 1);
740 assert_eq!(result.conflicts[0].path, PathBuf::from("conflict.rs"));
741 }
742
743 #[test]
748 fn join_is_commutative_disjoint() {
749 let mut a = empty_ps('a');
750 a.patches.insert(
751 "a.rs".into(),
752 PatchValue::Add {
753 blob: git_oid('1'),
754 file_id: fid(1),
755 },
756 );
757
758 let mut b = empty_ps('a');
759 b.patches.insert(
760 "b.rs".into(),
761 PatchValue::Add {
762 blob: git_oid('2'),
763 file_id: fid(2),
764 },
765 );
766
767 let ab = join(&a, &b).unwrap();
768 let ba = join(&b, &a).unwrap();
769 assert_eq!(ab, ba, "join must be commutative");
770 }
771
772 #[test]
773 fn join_is_commutative_conflicting() {
774 let mut a = empty_ps('a');
775 a.patches.insert(
776 "file.rs".into(),
777 PatchValue::Add {
778 blob: git_oid('1'),
779 file_id: fid(1),
780 },
781 );
782
783 let mut b = empty_ps('a');
784 b.patches.insert(
785 "file.rs".into(),
786 PatchValue::Add {
787 blob: git_oid('2'),
788 file_id: fid(2),
789 },
790 );
791
792 let ab = join(&a, &b).unwrap();
793 let ba = join(&b, &a).unwrap();
794 assert_eq!(ab, ba, "join must be commutative even with conflicts");
795 }
796
797 #[test]
802 fn join_is_idempotent() {
803 let mut a = empty_ps('a');
804 a.patches.insert(
805 "file.rs".into(),
806 PatchValue::Modify {
807 base_blob: git_oid('1'),
808 new_blob: git_oid('2'),
809 file_id: fid(1),
810 },
811 );
812 a.patches.insert(
813 "other.rs".into(),
814 PatchValue::Delete {
815 previous_blob: git_oid('3'),
816 file_id: fid(2),
817 },
818 );
819
820 let result = join(&a, &a).unwrap();
821 assert!(result.is_clean(), "join(a, a) must have no conflicts");
822 assert_eq!(result.merged, a, "join(a, a) must equal a");
823 }
824
825 #[test]
830 fn join_is_associative_disjoint() {
831 let mut a = empty_ps('a');
832 a.patches.insert(
833 "a.rs".into(),
834 PatchValue::Add {
835 blob: git_oid('1'),
836 file_id: fid(1),
837 },
838 );
839
840 let mut b = empty_ps('a');
841 b.patches.insert(
842 "b.rs".into(),
843 PatchValue::Add {
844 blob: git_oid('2'),
845 file_id: fid(2),
846 },
847 );
848
849 let mut c = empty_ps('a');
850 c.patches.insert(
851 "c.rs".into(),
852 PatchValue::Add {
853 blob: git_oid('3'),
854 file_id: fid(3),
855 },
856 );
857
858 let ab = join(&a, &b).unwrap();
860 assert!(ab.is_clean());
861 let abc_left = join(&ab.merged, &c).unwrap();
862
863 let bc = join(&b, &c).unwrap();
865 assert!(bc.is_clean());
866 let abc_right = join(&a, &bc.merged).unwrap();
867
868 assert_eq!(abc_left, abc_right, "join must be associative");
869 }
870
871 #[test]
876 fn join_many_disjoint_paths() {
877 let mut a = empty_ps('a');
878 let mut b = empty_ps('a');
879 for i in 0..50 {
880 a.patches.insert(
881 format!("a_{i:03}.rs").into(),
882 PatchValue::Add {
883 blob: git_oid('a'),
884 file_id: fid(i as u128),
885 },
886 );
887 b.patches.insert(
888 format!("b_{i:03}.rs").into(),
889 PatchValue::Add {
890 blob: git_oid('b'),
891 file_id: fid(100 + i as u128),
892 },
893 );
894 }
895
896 let result = join(&a, &b).unwrap();
897 assert!(result.is_clean());
898 assert_eq!(result.merged.len(), 100);
899 }
900
901 #[test]
902 fn join_result_serde_round_trip() {
903 let mut a = empty_ps('a');
904 a.patches.insert(
905 "file.rs".into(),
906 PatchValue::Add {
907 blob: git_oid('1'),
908 file_id: fid(1),
909 },
910 );
911
912 let mut b = empty_ps('a');
913 b.patches.insert(
914 "file.rs".into(),
915 PatchValue::Add {
916 blob: git_oid('2'),
917 file_id: fid(2),
918 },
919 );
920
921 let result = join(&a, &b).unwrap();
922 let json = serde_json::to_string(&result).unwrap();
923 let decoded: JoinResult = serde_json::from_str(&json).unwrap();
924 assert_eq!(decoded, result);
925 }
926
927 #[test]
928 fn conflict_reason_display() {
929 assert!(!ConflictReason::DivergentAdd.to_string().is_empty());
930 assert!(!ConflictReason::DivergentModify.to_string().is_empty());
931 assert!(!ConflictReason::ModifyDelete.to_string().is_empty());
932 assert!(!ConflictReason::RenameConflict.to_string().is_empty());
933 assert!(!ConflictReason::DivergentRename.to_string().is_empty());
934 assert!(!ConflictReason::Incompatible.to_string().is_empty());
935 }
936
937 #[test]
938 fn epoch_mismatch_display() {
939 let err = EpochMismatch {
940 left: epoch('a'),
941 right: epoch('b'),
942 };
943 let msg = format!("{err}");
944 assert!(msg.contains("different base epochs"));
945 }
946}
947
948#[cfg(test)]
953#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
954mod proptests {
955 use super::*;
956 use crate::model::patch::{FileId, PatchSet, PatchValue};
957 use crate::model::types::{EpochId, GitOid};
958 use proptest::prelude::*;
959
960 fn arb_git_oid() -> impl Strategy<Value = GitOid> {
962 "[0-9a-f]{40}".prop_map(|s| GitOid::new(&s).unwrap())
963 }
964
965 fn arb_file_id() -> impl Strategy<Value = FileId> {
966 any::<u128>().prop_map(FileId::new)
967 }
968
969 fn arb_patch_value() -> impl Strategy<Value = PatchValue> {
970 prop_oneof![
971 (arb_git_oid(), arb_file_id())
972 .prop_map(|(blob, file_id)| PatchValue::Add { blob, file_id }),
973 (arb_git_oid(), arb_file_id()).prop_map(|(previous_blob, file_id)| {
974 PatchValue::Delete {
975 previous_blob,
976 file_id,
977 }
978 }),
979 (arb_git_oid(), arb_git_oid(), arb_file_id()).prop_map(
980 |(base_blob, new_blob, file_id)| PatchValue::Modify {
981 base_blob,
982 new_blob,
983 file_id
984 }
985 ),
986 ]
987 }
988
989 fn arb_path() -> impl Strategy<Value = PathBuf> {
990 prop_oneof![
991 Just(PathBuf::from("src/main.rs")),
992 Just(PathBuf::from("src/lib.rs")),
993 Just(PathBuf::from("src/model.rs")),
994 Just(PathBuf::from("README.md")),
995 Just(PathBuf::from("Cargo.toml")),
996 Just(PathBuf::from("tests/test.rs")),
997 Just(PathBuf::from("src/a.rs")),
998 Just(PathBuf::from("src/b.rs")),
999 ]
1000 }
1001
1002 fn arb_patchset() -> impl Strategy<Value = PatchSet> {
1003 let epoch = EpochId::new(&"a".repeat(40)).unwrap();
1005 prop::collection::btree_map(arb_path(), arb_patch_value(), 0..5).prop_map(move |patches| {
1006 PatchSet {
1007 base_epoch: epoch.clone(),
1008 patches,
1009 }
1010 })
1011 }
1012
1013 proptest! {
1014 #[test]
1015 fn prop_commutativity(a in arb_patchset(), b in arb_patchset()) {
1016 let ab = join(&a, &b).unwrap();
1017 let ba = join(&b, &a).unwrap();
1018 prop_assert_eq!(ab, ba, "join must be commutative");
1019 }
1020
1021 #[test]
1022 fn prop_idempotency(a in arb_patchset()) {
1023 let aa = join(&a, &a).unwrap();
1024 prop_assert!(aa.is_clean(), "join(a, a) must have no conflicts");
1025 prop_assert_eq!(aa.merged, a, "join(a, a) must equal a");
1026 }
1027
1028 #[test]
1029 fn prop_associativity_clean(
1030 a in arb_patchset(),
1031 b in arb_patchset(),
1032 c in arb_patchset()
1033 ) {
1034 let ab = join(&a, &b).unwrap();
1038 let bc = join(&b, &c).unwrap();
1039 if ab.is_clean() && bc.is_clean() {
1040 let abc_left = join(&ab.merged, &c).unwrap();
1041 let abc_right = join(&a, &bc.merged).unwrap();
1042 prop_assert_eq!(abc_left, abc_right, "join must be associative for clean joins");
1043 }
1044 }
1047 }
1048}