Skip to main content

maw/model/
join.rs

1//! `PatchSet` join — CRDT merge of two patch-sets sharing the same base epoch.
2//!
3//! The join operation combines two [`PatchSet`]s into one by unioning their
4//! path maps. When both patch-sets touch the same path, we resolve:
5//!
6//! 1. **Identical** — same [`PatchValue`]: idempotent, keep one copy.
7//! 2. **Compatible** — mergeable edits on the same path (e.g. both Add the
8//!    same blob via different `FileIds` would conflict, but identical Adds are
9//!    idempotent per case 1).
10//! 3. **Conflicting** — incompatible edits: emit a [`PathConflict`].
11//!
12//! # CRDT properties
13//!
14//! `join` is:
15//! - **Commutative**: `join(a, b) == join(b, a)`
16//! - **Associative**: `join(join(a, b), c) == join(a, join(b, c))`
17//! - **Idempotent**: `join(a, a) == a`
18//!
19//! These hold because:
20//! - [`BTreeMap`] iteration is deterministic.
21//! - Identical entries collapse (idempotent).
22//! - Conflict detection is symmetric (both sides produce the same
23//!   [`PathConflict`] regardless of argument order because sides are sorted).
24
25#![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// ---------------------------------------------------------------------------
37// JoinResult
38// ---------------------------------------------------------------------------
39
40/// The result of joining two [`PatchSet`]s.
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct JoinResult {
43    /// The merged patch-set containing all non-conflicting entries.
44    pub merged: PatchSet,
45    /// Paths where the two patch-sets could not be reconciled.
46    pub conflicts: Vec<PathConflict>,
47}
48
49impl JoinResult {
50    /// Returns `true` if the join completed without conflicts.
51    #[must_use]
52    pub const fn is_clean(&self) -> bool {
53        self.conflicts.is_empty()
54    }
55}
56
57// ---------------------------------------------------------------------------
58// PathConflict
59// ---------------------------------------------------------------------------
60
61/// A conflict on a single path during [`join`].
62///
63/// Contains both sides so callers can inspect what each patch-set wanted
64/// to do and decide on a resolution strategy.
65///
66/// `sides` is always sorted (by [`PatchValue`]'s `Ord`-equivalent canonical
67/// JSON) to guarantee commutativity.
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PathConflict {
70    /// The path where the conflict occurred.
71    pub path: PathBuf,
72    /// What the two patch-sets each wanted to do to this path.
73    /// Always exactly 2 elements, sorted for determinism.
74    pub sides: [PatchValue; 2],
75    /// Human-readable reason for the conflict.
76    pub reason: ConflictReason,
77}
78
79// ---------------------------------------------------------------------------
80// ConflictReason
81// ---------------------------------------------------------------------------
82
83/// Why two [`PatchValue`]s on the same path could not be merged.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ConflictReason {
87    /// Both sides add a file but with different content or file identity.
88    DivergentAdd,
89    /// Both sides modify a file but produce different new blobs.
90    DivergentModify,
91    /// One side modifies the file, the other deletes it.
92    ModifyDelete,
93    /// One side renames, the other modifies in a way that conflicts.
94    RenameConflict,
95    /// Both sides rename the same file to different destinations.
96    DivergentRename,
97    /// General incompatibility (different operation types on the same path).
98    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// ---------------------------------------------------------------------------
115// JoinError
116// ---------------------------------------------------------------------------
117
118/// Error that occurs if `join` is called on patch-sets with different base epochs.
119#[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
137// ---------------------------------------------------------------------------
138// join
139// ---------------------------------------------------------------------------
140
141/// Join (CRDT merge) two [`PatchSet`]s that share the same base epoch.
142///
143/// # Precondition
144///
145/// Both patch-sets must share the same `base_epoch`. If they don't,
146/// [`EpochMismatch`] is returned.
147///
148/// # Algorithm
149///
150/// 1. Iterate the union of all paths from both patch-sets (`BTreeMap` ensures
151///    sorted, deterministic order).
152/// 2. For each path:
153///    - Present in only one side → take it (disjoint union).
154///    - Present in both, identical → keep one copy (idempotent).
155///    - Present in both, different → classify as compatible or conflicting.
156///
157/// # CRDT guarantees
158///
159/// - **Commutativity**: `join(a, b) == join(b, a)` — ensured by sorting
160///   conflict sides canonically.
161/// - **Associativity**: `join(join(a, b), c) == join(a, join(b, c))` —
162///   ensured by deterministic conflict detection and identical-entry collapse.
163/// - **Idempotency**: `join(a, a) == a` — identical entries collapse.
164pub 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    // Collect all paths from both sides.
176    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            // Only in left → take it.
190            (Some(l), None) => {
191                merged.insert((*path).clone(), (*l).clone());
192            }
193            // Only in right → take it.
194            (None, Some(r)) => {
195                merged.insert((*path).clone(), (*r).clone());
196            }
197            // In both — check if identical.
198            (Some(l), Some(r)) => {
199                if *l == *r {
200                    // Idempotent: identical entries collapse.
201                    merged.insert((*path).clone(), (*l).clone());
202                } else {
203                    // Try to classify the conflict.
204                    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            // Neither (impossible due to construction, but handle gracefully).
214            (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
227// ---------------------------------------------------------------------------
228// Conflict classification
229// ---------------------------------------------------------------------------
230
231/// Classify why two different `PatchValues` on the same path conflict.
232fn classify_conflict(left: &PatchValue, right: &PatchValue) -> ConflictReason {
233    use PatchValue::{Add, Delete, Modify, Rename};
234    match (left, right) {
235        // Both Add, different content/identity → divergent add.
236        (Add { .. }, Add { .. }) => ConflictReason::DivergentAdd,
237
238        // Both Modify, different new_blob → divergent modify.
239        (Modify { .. }, Modify { .. }) => ConflictReason::DivergentModify,
240
241        // Modify + Delete or Delete + Modify → modify/delete conflict.
242        (Modify { .. }, Delete { .. }) | (Delete { .. }, Modify { .. }) => {
243            ConflictReason::ModifyDelete
244        }
245
246        // Both Rename → check if destinations differ.
247        (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 + something else → rename conflict.
256        (Rename { .. }, _) | (_, Rename { .. }) => ConflictReason::RenameConflict,
257
258        // Anything else → generic incompatible.
259        _ => ConflictReason::Incompatible,
260    }
261}
262
263/// Return both sides sorted by their canonical JSON for deterministic ordering.
264///
265/// This ensures `join(a, b)` and `join(b, a)` produce the same conflict entries.
266fn 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// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[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    // -----------------------------------------------------------------------
288    // Helpers
289    // -----------------------------------------------------------------------
290
291    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    // -----------------------------------------------------------------------
312    // Precondition: epoch mismatch
313    // -----------------------------------------------------------------------
314
315    #[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    // -----------------------------------------------------------------------
325    // Disjoint paths (union)
326    // -----------------------------------------------------------------------
327
328    #[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    // -----------------------------------------------------------------------
392    // Identical entries (idempotent)
393    // -----------------------------------------------------------------------
394
395    #[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    // -----------------------------------------------------------------------
467    // Conflicting entries
468    // -----------------------------------------------------------------------
469
470    #[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        // Conflicted path should NOT be in merged.
496        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        // Same source file renamed to two different destinations by the two sides.
562        // Side a: old.rs → new_a.rs
563        // Side b: old.rs → new_b.rs
564        // Both entries appear under their respective destination paths,
565        // but since the destinations differ, they land in disjoint paths.
566        // However, if both appear under the SAME destination path with
567        // different source, that's an incompatible conflict.
568
569        // Actually, for divergent rename: same `from`, different destination key.
570        // These would be disjoint paths, so no conflict at the path level.
571        // Rename conflicts at the path level happen when both sides
572        // write to the SAME destination from the SAME source with different content.
573
574        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    // -----------------------------------------------------------------------
653    // Mixed: some disjoint, some identical, some conflicting
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn join_mixed_scenario() {
658        let mut a = empty_ps('a');
659        // Disjoint: only in a
660        a.patches.insert(
661            "only_a.rs".into(),
662            PatchValue::Add {
663                blob: git_oid('1'),
664                file_id: fid(1),
665            },
666        );
667        // Identical: same in both
668        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        // Conflicting: different in both
677        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        // Disjoint: only in b
687        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        // Identical: same as a
695        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        // Conflicting: different from a
704        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        // 3 paths merged (only_a, only_b, shared), 1 conflict
714        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    // -----------------------------------------------------------------------
744    // CRDT property: commutativity
745    // -----------------------------------------------------------------------
746
747    #[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    // -----------------------------------------------------------------------
798    // CRDT property: idempotency
799    // -----------------------------------------------------------------------
800
801    #[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    // -----------------------------------------------------------------------
826    // CRDT property: associativity
827    // -----------------------------------------------------------------------
828
829    #[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        // (a ⊕ b) ⊕ c
859        let ab = join(&a, &b).unwrap();
860        assert!(ab.is_clean());
861        let abc_left = join(&ab.merged, &c).unwrap();
862
863        // a ⊕ (b ⊕ c)
864        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    // -----------------------------------------------------------------------
872    // Edge cases
873    // -----------------------------------------------------------------------
874
875    #[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// ---------------------------------------------------------------------------
949// Property tests
950// ---------------------------------------------------------------------------
951
952#[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    // Strategy: generate a random PatchSet with a fixed epoch.
961    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        // Fixed epoch for join compatibility.
1004        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            // Associativity only holds cleanly when there are no conflicts
1035            // in intermediate joins (conflicted paths are excluded from merged,
1036            // so the composition may differ). Test the clean case.
1037            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            // When there are conflicts, we intentionally skip — associativity
1045            // with conflicts requires a richer algebra (conflict sets).
1046        }
1047    }
1048}