Skip to main content

heddle_object_model/object/collaboration/
materialize.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use super::{
8    CollabOpId, CollaborationAnchor, CollaborationAnchorStatus, CollaborationCodecError,
9    CollaborationOperationBodyV1, CollaborationResolution, DecodedCollaborationOperation,
10    DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionResolutionV1,
11};
12use crate::object::VisibilityTier;
13
14#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15pub struct HostedCollaborationSet {
16    pub received: BTreeSet<CollabOpId>,
17    pub accepted: BTreeSet<CollabOpId>,
18    pub rejected: BTreeSet<CollabOpId>,
19}
20
21impl HostedCollaborationSet {
22    pub fn validate(
23        &self,
24        operations: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
25    ) -> Result<(), CollaborationCodecError> {
26        if !self.accepted.is_subset(&self.received) || !self.rejected.is_subset(&self.received) {
27            return Err(CollaborationCodecError::Invalid(
28                "hosted accepted and rejected sets must be subsets of received".to_string(),
29            ));
30        }
31        if !self.accepted.is_disjoint(&self.rejected) {
32            return Err(CollaborationCodecError::Invalid(
33                "hosted accepted and rejected sets must be disjoint".to_string(),
34            ));
35        }
36        for id in &self.accepted {
37            let operation = operations.get(id).ok_or_else(|| {
38                CollaborationCodecError::Invalid(format!(
39                    "hosted accepted operation {id} is unavailable"
40                ))
41            })?;
42            if !operation
43                .operation
44                .parents
45                .iter()
46                .all(|parent| self.accepted.contains(parent))
47            {
48                return Err(CollaborationCodecError::Invalid(format!(
49                    "hosted accepted set is not parent-closed at {id}"
50                )));
51            }
52        }
53        Ok(())
54    }
55
56    pub fn blocked_descendants(
57        &self,
58        operations: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
59    ) -> BTreeSet<CollabOpId> {
60        self.received
61            .difference(&self.accepted)
62            .filter(|id| {
63                !self.rejected.contains(id)
64                    && has_unaccepted_ancestor(**id, &self.accepted, operations)
65            })
66            .copied()
67            .collect()
68    }
69}
70
71fn has_unaccepted_ancestor(
72    id: CollabOpId,
73    accepted: &BTreeSet<CollabOpId>,
74    operations: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
75) -> bool {
76    let Some(operation) = operations.get(&id) else {
77        return true;
78    };
79    let mut pending = operation.operation.parents.clone();
80    let mut seen = BTreeSet::new();
81    while let Some(parent) = pending.pop() {
82        if !accepted.contains(&parent) {
83            return true;
84        }
85        if seen.insert(parent)
86            && let Some(operation) = operations.get(&parent)
87        {
88            pending.extend(operation.operation.parents.iter().copied());
89        }
90    }
91    false
92}
93
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct MaterializedDiscussion {
96    pub blocking: bool,
97    pub discussion_id: DiscussionRecordId,
98    pub title: String,
99    pub anchor: CollaborationAnchor,
100    pub anchor_status: CollaborationAnchorStatus,
101    pub body_changed_since_open: bool,
102    pub visibility: VisibilityTier,
103    pub thread_ref: Option<String>,
104    pub tags: Vec<super::AnnotationTag>,
105    pub turns: Vec<(CollabOpId, DiscussionTurnV1)>,
106    pub resolution: Option<CollaborationResolution>,
107    pub conflict_operations: BTreeSet<CollabOpId>,
108    pub heads: BTreeSet<CollabOpId>,
109    pub display_head: CollabOpId,
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq)]
113pub struct MaterializedRepositoryCollaboration {
114    pub discussions: BTreeMap<DiscussionRecordId, MaterializedDiscussion>,
115    pub pending: BTreeSet<CollabOpId>,
116}
117
118pub fn materialize_repository_collaboration(
119    operations: impl IntoIterator<Item = DecodedCollaborationOperation>,
120) -> Result<MaterializedRepositoryCollaboration, CollaborationCodecError> {
121    let mut by_id = BTreeMap::new();
122    for operation in operations {
123        if by_id.insert(operation.operation_id, operation).is_some() {
124            return Err(CollaborationCodecError::Invalid(
125                "duplicate collaboration operation id".to_string(),
126            ));
127        }
128    }
129
130    let mut visible = BTreeSet::new();
131    let mut ordered = Vec::new();
132    loop {
133        let next = by_id
134            .iter()
135            .filter(|(id, operation)| {
136                !visible.contains(*id)
137                    && operation
138                        .operation
139                        .parents
140                        .iter()
141                        .all(|parent| visible.contains(parent))
142            })
143            .map(|(id, operation)| (operation.operation.occurred_at_ms, *id))
144            .min();
145        let Some((_, id)) = next else { break };
146        visible.insert(id);
147        ordered.push(id);
148    }
149
150    let mut grouped: BTreeMap<DiscussionRecordId, Vec<CollabOpId>> = BTreeMap::new();
151    for id in &ordered {
152        let operation = &by_id[id].operation;
153        for parent in &operation.parents {
154            if by_id[parent].operation.discussion_id != operation.discussion_id {
155                return Err(CollaborationCodecError::Invalid(format!(
156                    "operation {id} has a parent from another discussion"
157                )));
158            }
159        }
160        grouped
161            .entry(operation.discussion_id)
162            .or_default()
163            .push(*id);
164    }
165
166    let mut result = MaterializedRepositoryCollaboration {
167        discussions: BTreeMap::new(),
168        pending: by_id
169            .keys()
170            .filter(|id| !visible.contains(id))
171            .copied()
172            .collect(),
173    };
174    for (discussion_id, ids) in grouped {
175        let discussion = materialize_discussion(discussion_id, &ids, &by_id)?;
176        result.discussions.insert(discussion_id, discussion);
177    }
178    Ok(result)
179}
180
181fn materialize_discussion(
182    discussion_id: DiscussionRecordId,
183    ids: &[CollabOpId],
184    all: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
185) -> Result<MaterializedDiscussion, CollaborationCodecError> {
186    let roots = ids
187        .iter()
188        .filter(|id| all[id].operation.parents.is_empty())
189        .copied()
190        .collect::<Vec<_>>();
191    if roots.len() != 1 {
192        return Err(CollaborationCodecError::Invalid(format!(
193            "discussion {discussion_id} has {} roots",
194            roots.len()
195        )));
196    }
197    let root_id = roots[0];
198    let root = &all[&root_id].operation.body;
199    let (title, root_anchor, visibility, thread_ref, root_turns, base_resolution) = match root {
200        CollaborationOperationBodyV1::Open {
201            title,
202            anchor,
203            visibility,
204            turn,
205            thread_ref,
206            ..
207        } => (
208            title.clone(),
209            anchor.clone(),
210            visibility.clone(),
211            thread_ref.clone(),
212            vec![turn.clone()],
213            None,
214        ),
215        CollaborationOperationBodyV1::LegacyImported {
216            title,
217            anchor,
218            visibility,
219            turns,
220            resolution,
221            ..
222        } => (
223            title.clone(),
224            anchor.clone(),
225            visibility.clone(),
226            None,
227            turns.clone(),
228            legacy_resolution(resolution),
229        ),
230        _ => {
231            return Err(CollaborationCodecError::Invalid(format!(
232                "discussion {discussion_id} root is not an open or legacy import"
233            )));
234        }
235    };
236
237    let mut tags = match root {
238        CollaborationOperationBodyV1::Open { tags, .. } => tags.clone(),
239        _ => Vec::new(),
240    };
241    let mut tag_operations = BTreeSet::new();
242    let mut turns = root_turns
243        .into_iter()
244        .map(|turn| (root_id, turn))
245        .collect::<Vec<_>>();
246    let mut state_operations = BTreeSet::new();
247    let mut anchor_operations = BTreeSet::new();
248    for id in ids.iter().copied().filter(|id| *id != root_id) {
249        match &all[&id].operation.body {
250            CollaborationOperationBodyV1::AppendTurn { turn } => turns.push((id, turn.clone())),
251            CollaborationOperationBodyV1::EditTags { .. } => {
252                tag_operations.insert(id);
253            }
254            CollaborationOperationBodyV1::RebindAnchor { .. } => {
255                anchor_operations.insert(id);
256            }
257            CollaborationOperationBodyV1::Resolve { .. }
258            | CollaborationOperationBodyV1::Reopen { .. }
259            | CollaborationOperationBodyV1::ResolveConflict { .. } => {
260                state_operations.insert(id);
261            }
262            CollaborationOperationBodyV1::Open { .. }
263            | CollaborationOperationBodyV1::LegacyImported { .. } => {
264                return Err(CollaborationCodecError::Invalid(format!(
265                    "discussion {discussion_id} has multiple root operations"
266                )));
267            }
268        }
269    }
270
271    let (anchor, anchor_status, body_changed_since_open) =
272        materialize_anchor(root_anchor, &anchor_operations, all)?;
273
274    let maximal_state = state_operations
275        .iter()
276        .filter(|candidate| {
277            !state_operations
278                .iter()
279                .any(|other| candidate != &other && precedes(**candidate, *other, all))
280        })
281        .copied()
282        .collect::<BTreeSet<_>>();
283    let mut outcomes = BTreeMap::new();
284    for id in &maximal_state {
285        outcomes.insert(*id, resolution_outcome(*id, all, &mut BTreeSet::new())?);
286    }
287    let first_outcome = outcomes.values().next().cloned();
288    let mut conflicts = if outcomes
289        .values()
290        .all(|outcome| Some(outcome) == first_outcome.as_ref())
291    {
292        BTreeSet::new()
293    } else {
294        outcomes.keys().copied().collect()
295    };
296    let resolution = if conflicts.is_empty() {
297        first_outcome.unwrap_or(base_resolution)
298    } else {
299        None
300    };
301
302    let maximal_tags = tag_operations
303        .iter()
304        .filter(|candidate| {
305            !tag_operations
306                .iter()
307                .any(|other| candidate != &other && precedes(**candidate, *other, all))
308        })
309        .copied()
310        .collect::<Vec<_>>();
311    if let Some(first) = maximal_tags.first() {
312        let CollaborationOperationBodyV1::EditTags { tags: selected } = &all[first].operation.body
313        else {
314            return Err(CollaborationCodecError::Invalid(
315                "tag operation is not a tag edit".into(),
316            ));
317        };
318        if maximal_tags.iter().all(|id| matches!(&all[id].operation.body, CollaborationOperationBodyV1::EditTags { tags } if tags == selected)) {
319            tags = selected.clone();
320        } else {
321            conflicts.extend(maximal_tags);
322        }
323    }
324
325    let ids_set = ids.iter().copied().collect::<BTreeSet<_>>();
326    let heads = ids_set
327        .iter()
328        .filter(|candidate| {
329            !ids_set
330                .iter()
331                .any(|other| candidate != &other && precedes(**candidate, *other, all))
332        })
333        .copied()
334        .collect::<BTreeSet<_>>();
335    let display_head = *heads
336        .iter()
337        .next()
338        .ok_or_else(|| CollaborationCodecError::Invalid("discussion has no causal head".into()))?;
339    Ok(MaterializedDiscussion {
340        blocking: matches!(
341            root,
342            CollaborationOperationBodyV1::Open { blocking: true, .. }
343        ),
344        discussion_id,
345        title,
346        anchor,
347        anchor_status,
348        body_changed_since_open,
349        visibility,
350        thread_ref,
351        tags,
352        turns,
353        resolution,
354        conflict_operations: conflicts,
355        heads,
356        display_head,
357    })
358}
359
360fn materialize_anchor(
361    root_anchor: CollaborationAnchor,
362    anchor_operations: &BTreeSet<CollabOpId>,
363    all: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
364) -> Result<(CollaborationAnchor, CollaborationAnchorStatus, bool), CollaborationCodecError> {
365    let maximal = anchor_operations
366        .iter()
367        .filter(|candidate| {
368            !anchor_operations
369                .iter()
370                .any(|other| candidate != &other && precedes(**candidate, *other, all))
371        })
372        .copied()
373        .collect::<Vec<_>>();
374    let mut outcomes = Vec::with_capacity(maximal.len());
375    for id in maximal {
376        let CollaborationOperationBodyV1::RebindAnchor {
377            anchor,
378            status,
379            body_changed_since_open,
380        } = &all[&id].operation.body
381        else {
382            return Err(CollaborationCodecError::Invalid(format!(
383                "anchor operation {id} is not an anchor rebind"
384            )));
385        };
386        outcomes.push((anchor.clone(), *status, *body_changed_since_open));
387    }
388    let Some(first) = outcomes.first().cloned() else {
389        return Ok((root_anchor, CollaborationAnchorStatus::Current, false));
390    };
391    if outcomes.iter().all(|outcome| outcome == &first) {
392        return Ok(first);
393    }
394    Ok((
395        root_anchor,
396        CollaborationAnchorStatus::Ambiguous,
397        outcomes
398            .iter()
399            .any(|(_, _, body_changed_since_open)| *body_changed_since_open),
400    ))
401}
402
403fn resolution_outcome(
404    id: CollabOpId,
405    operations: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
406    visiting: &mut BTreeSet<CollabOpId>,
407) -> Result<Option<CollaborationResolution>, CollaborationCodecError> {
408    if !visiting.insert(id) {
409        return Err(CollaborationCodecError::Invalid(format!(
410            "collaboration conflict resolution cycle at {id}"
411        )));
412    }
413    let body = &operations
414        .get(&id)
415        .ok_or_else(|| CollaborationCodecError::Invalid(format!("missing operation {id}")))?
416        .operation
417        .body;
418    let result = match body {
419        CollaborationOperationBodyV1::Resolve { resolution } => Some(resolution.clone()),
420        CollaborationOperationBodyV1::Reopen { .. } => None,
421        CollaborationOperationBodyV1::ResolveConflict { selected, .. } => {
422            resolution_outcome(*selected, operations, visiting)?
423        }
424        _ => {
425            return Err(CollaborationCodecError::Invalid(format!(
426                "operation {id} does not select a resolution outcome"
427            )));
428        }
429    };
430    visiting.remove(&id);
431    Ok(result)
432}
433
434fn precedes(
435    ancestor: CollabOpId,
436    descendant: CollabOpId,
437    operations: &BTreeMap<CollabOpId, DecodedCollaborationOperation>,
438) -> bool {
439    let mut pending = operations[&descendant].operation.parents.clone();
440    let mut seen = BTreeSet::new();
441    while let Some(id) = pending.pop() {
442        if id == ancestor {
443            return true;
444        }
445        if seen.insert(id)
446            && let Some(operation) = operations.get(&id)
447        {
448            pending.extend(operation.operation.parents.iter().copied());
449        }
450    }
451    false
452}
453
454fn legacy_resolution(value: &LegacyDiscussionResolutionV1) -> Option<CollaborationResolution> {
455    match value {
456        LegacyDiscussionResolutionV1::Open => None,
457        LegacyDiscussionResolutionV1::AddressedByState { state_id } => {
458            Some(CollaborationResolution::AddressedByState {
459                state_id: *state_id,
460            })
461        }
462        LegacyDiscussionResolutionV1::Dismissed { reason } => {
463            Some(CollaborationResolution::Dismissed {
464                reason: reason.clone(),
465            })
466        }
467        LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
468            Some(CollaborationResolution::Annotation {
469                annotation_id: annotation_id.clone(),
470            })
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::object::{
479        Attribution, CollaborationIdempotencyKey, CollaborationOperationEnvelope, Principal,
480        StateId,
481    };
482
483    fn discussion_id() -> DiscussionRecordId {
484        "disc-018f47ea-4a54-7c89-b012-3456789abcde".parse().unwrap()
485    }
486
487    fn author() -> Attribution {
488        Attribution::human(Principal::new("Ada", "ada@example.com"))
489    }
490
491    fn decoded(
492        parents: Vec<CollabOpId>,
493        key: &str,
494        at: i64,
495        body: CollaborationOperationBodyV1,
496    ) -> DecodedCollaborationOperation {
497        let operation = CollaborationOperationEnvelope::new(
498            discussion_id(),
499            parents,
500            CollaborationIdempotencyKey::new(key).unwrap(),
501            author(),
502            at,
503            body,
504        )
505        .unwrap();
506        let bytes = operation.encode().unwrap();
507        CollaborationOperationEnvelope::decode(&bytes).unwrap()
508    }
509
510    fn root() -> DecodedCollaborationOperation {
511        decoded(
512            vec![],
513            "root",
514            1,
515            CollaborationOperationBodyV1::Open {
516                tags: Vec::new(),
517                blocking: false,
518                title: "Review".to_string(),
519                anchor: CollaborationAnchor::Repository,
520                visibility: VisibilityTier::default(),
521                turn: DiscussionTurnV1::new("first").unwrap(),
522                thread_ref: None,
523            },
524        )
525    }
526
527    fn symbol_root() -> DecodedCollaborationOperation {
528        decoded(
529            vec![],
530            "symbol-root",
531            1,
532            CollaborationOperationBodyV1::Open {
533                tags: Vec::new(),
534                blocking: false,
535                title: "Review".to_string(),
536                anchor: CollaborationAnchor::Symbol {
537                    state_id: StateId::from_bytes([1; 32]),
538                    path: "main.rs".to_string(),
539                    symbol: "foo".to_string(),
540                },
541                visibility: VisibilityTier::default(),
542                turn: DiscussionTurnV1::new("first").unwrap(),
543                thread_ref: None,
544            },
545        )
546    }
547
548    #[test]
549    fn opening_tags_replace_clear_and_join_concurrent_edits() {
550        let mut operation = root().operation;
551        let CollaborationOperationBodyV1::Open { tags, .. } = &mut operation.body else {
552            panic!("open");
553        };
554        *tags = vec!["opening".into()];
555        let root = CollaborationOperationEnvelope::decode(&operation.encode().expect("encode"))
556            .expect("decode");
557        let left = decoded(
558            vec![root.operation_id],
559            "tags-left",
560            2,
561            CollaborationOperationBodyV1::EditTags {
562                tags: vec!["left".into()],
563            },
564        );
565        let right = decoded(
566            vec![root.operation_id],
567            "tags-right",
568            3,
569            CollaborationOperationBodyV1::EditTags {
570                tags: vec!["right".into()],
571            },
572        );
573        let view = materialize_repository_collaboration(vec![root.clone()]).expect("opening");
574        assert_eq!(
575            view.discussions[&discussion_id()].tags,
576            vec!["opening".into()]
577        );
578        let view =
579            materialize_repository_collaboration(vec![root.clone(), left.clone()]).expect("edit");
580        assert_eq!(view.discussions[&discussion_id()].tags, vec!["left".into()]);
581        let view =
582            materialize_repository_collaboration(vec![root.clone(), left.clone(), right.clone()])
583                .expect("conflict");
584        assert_eq!(
585            view.discussions[&discussion_id()].conflict_operations,
586            BTreeSet::from([left.operation_id, right.operation_id])
587        );
588        let clear = decoded(
589            vec![left.operation_id, right.operation_id],
590            "clear-tags",
591            4,
592            CollaborationOperationBodyV1::EditTags { tags: vec![] },
593        );
594        let view =
595            materialize_repository_collaboration(vec![clear, right, root, left]).expect("clear");
596        assert!(view.discussions[&discussion_id()].tags.is_empty());
597        assert!(
598            view.discussions[&discussion_id()]
599                .conflict_operations
600                .is_empty()
601        );
602        assert_eq!(view.discussions[&discussion_id()].turns.len(), 1);
603    }
604
605    #[test]
606    fn op_set_union_converges_independent_of_arrival_order() {
607        let root = root();
608        let a = decoded(
609            vec![root.operation_id],
610            "a",
611            2,
612            CollaborationOperationBodyV1::AppendTurn {
613                turn: DiscussionTurnV1::new("a").unwrap(),
614            },
615        );
616        let b = decoded(
617            vec![root.operation_id],
618            "b",
619            3,
620            CollaborationOperationBodyV1::AppendTurn {
621                turn: DiscussionTurnV1::new("b").unwrap(),
622            },
623        );
624        let forward =
625            materialize_repository_collaboration(vec![root.clone(), a.clone(), b.clone()]).unwrap();
626        let reverse = materialize_repository_collaboration(vec![b, a, root]).unwrap();
627        assert_eq!(forward, reverse);
628        let discussion = &forward.discussions[&discussion_id()];
629        assert_eq!(discussion.turns.len(), 3);
630        assert_eq!(discussion.heads.len(), 2);
631        assert_eq!(
632            discussion.display_head,
633            *discussion.heads.iter().next().unwrap()
634        );
635    }
636
637    #[test]
638    fn anchor_rebind_materializes_as_the_durable_anchor() {
639        let root = symbol_root();
640        let rebound = decoded(
641            vec![root.operation_id],
642            "rebind",
643            2,
644            CollaborationOperationBodyV1::RebindAnchor {
645                anchor: CollaborationAnchor::Symbol {
646                    state_id: StateId::from_bytes([2; 32]),
647                    path: "main.rs".to_string(),
648                    symbol: "bar".to_string(),
649                },
650                status: CollaborationAnchorStatus::Moved,
651                body_changed_since_open: true,
652            },
653        );
654        let view = materialize_repository_collaboration(vec![root, rebound]).unwrap();
655        let discussion = &view.discussions[&discussion_id()];
656        assert_eq!(
657            discussion.anchor,
658            CollaborationAnchor::Symbol {
659                state_id: StateId::from_bytes([2; 32]),
660                path: "main.rs".to_string(),
661                symbol: "bar".to_string(),
662            }
663        );
664        assert_eq!(discussion.anchor_status, CollaborationAnchorStatus::Moved);
665        assert!(discussion.body_changed_since_open);
666    }
667
668    #[test]
669    fn concurrent_different_anchor_rebinds_materialize_as_ambiguous() {
670        let root = symbol_root();
671        let rebind = |key: &str, symbol: &str| {
672            decoded(
673                vec![root.operation_id],
674                key,
675                2,
676                CollaborationOperationBodyV1::RebindAnchor {
677                    anchor: CollaborationAnchor::Symbol {
678                        state_id: StateId::from_bytes([2; 32]),
679                        path: "main.rs".to_string(),
680                        symbol: symbol.to_string(),
681                    },
682                    status: CollaborationAnchorStatus::Moved,
683                    body_changed_since_open: false,
684                },
685            )
686        };
687        let left = rebind("left", "bar");
688        let right = rebind("right", "baz");
689        let view = materialize_repository_collaboration(vec![root.clone(), left, right]).unwrap();
690        let discussion = &view.discussions[&discussion_id()];
691        assert_eq!(
692            discussion.anchor,
693            match root.operation.body {
694                CollaborationOperationBodyV1::Open { anchor, .. } => anchor,
695                _ => unreachable!(),
696            }
697        );
698        assert_eq!(
699            discussion.anchor_status,
700            CollaborationAnchorStatus::Ambiguous
701        );
702    }
703
704    #[test]
705    fn missing_parent_blocks_descendant_until_causal_closure_arrives() {
706        let root = root();
707        let missing = CollabOpId::from_bytes([9; 32]);
708        let child = decoded(
709            vec![missing],
710            "child",
711            2,
712            CollaborationOperationBodyV1::AppendTurn {
713                turn: DiscussionTurnV1::new("waiting").unwrap(),
714            },
715        );
716        let materialized = materialize_repository_collaboration(vec![root, child.clone()]).unwrap();
717        assert_eq!(materialized.pending, BTreeSet::from([child.operation_id]));
718    }
719
720    #[test]
721    fn competing_resolutions_conflict_and_causal_reopen_clears_resolution() {
722        let root = root();
723        let left = decoded(
724            vec![root.operation_id],
725            "left",
726            2,
727            CollaborationOperationBodyV1::Resolve {
728                resolution: CollaborationResolution::Dismissed {
729                    reason: "obsolete".to_string(),
730                },
731            },
732        );
733        let right = decoded(
734            vec![root.operation_id],
735            "right",
736            3,
737            CollaborationOperationBodyV1::Resolve {
738                resolution: CollaborationResolution::Annotation {
739                    annotation_id: "ann-1".to_string(),
740                },
741            },
742        );
743        let conflicted =
744            materialize_repository_collaboration(vec![root.clone(), left.clone(), right.clone()])
745                .unwrap();
746        assert_eq!(
747            conflicted.discussions[&discussion_id()]
748                .conflict_operations
749                .len(),
750            2
751        );
752        assert_eq!(conflicted.discussions[&discussion_id()].resolution, None);
753
754        let mut competing = vec![left.operation_id, right.operation_id];
755        competing.sort();
756        let selected = competing[0];
757        let resolved = decoded(
758            competing.clone(),
759            "resolve-conflict",
760            4,
761            CollaborationOperationBodyV1::ResolveConflict {
762                competing,
763                selected,
764            },
765        );
766        let reopened = decoded(
767            vec![resolved.operation_id],
768            "reopen",
769            5,
770            CollaborationOperationBodyV1::Reopen {
771                reason: "new evidence".to_string(),
772            },
773        );
774        let view =
775            materialize_repository_collaboration(vec![root, left, right, resolved, reopened])
776                .unwrap();
777        assert_eq!(view.discussions[&discussion_id()].resolution, None);
778    }
779
780    #[test]
781    fn concurrent_reopen_and_resolve_surface_conflict() {
782        let root = root();
783        let resolved = decoded(
784            vec![root.operation_id],
785            "resolve",
786            2,
787            CollaborationOperationBodyV1::Resolve {
788                resolution: CollaborationResolution::Dismissed {
789                    reason: "done".to_string(),
790                },
791            },
792        );
793        let reopened = decoded(
794            vec![root.operation_id],
795            "reopen",
796            3,
797            CollaborationOperationBodyV1::Reopen {
798                reason: "new evidence".to_string(),
799            },
800        );
801        let view =
802            materialize_repository_collaboration(vec![root, resolved.clone(), reopened.clone()])
803                .unwrap();
804        assert_eq!(
805            view.discussions[&discussion_id()].conflict_operations,
806            BTreeSet::from([resolved.operation_id, reopened.operation_id])
807        );
808    }
809
810    #[test]
811    fn competing_conflict_resolutions_form_a_recursive_conflict() {
812        let root = root();
813        let left = decoded(
814            vec![root.operation_id],
815            "left",
816            2,
817            CollaborationOperationBodyV1::Resolve {
818                resolution: CollaborationResolution::Dismissed {
819                    reason: "left".to_string(),
820                },
821            },
822        );
823        let right = decoded(
824            vec![root.operation_id],
825            "right",
826            3,
827            CollaborationOperationBodyV1::Resolve {
828                resolution: CollaborationResolution::Dismissed {
829                    reason: "right".to_string(),
830                },
831            },
832        );
833        let mut competing = vec![left.operation_id, right.operation_id];
834        competing.sort();
835        let choose_left = decoded(
836            competing.clone(),
837            "choose-left",
838            4,
839            CollaborationOperationBodyV1::ResolveConflict {
840                competing: competing.clone(),
841                selected: left.operation_id,
842            },
843        );
844        let choose_right = decoded(
845            competing.clone(),
846            "choose-right",
847            5,
848            CollaborationOperationBodyV1::ResolveConflict {
849                competing,
850                selected: right.operation_id,
851            },
852        );
853        let view = materialize_repository_collaboration(vec![
854            root,
855            left,
856            right,
857            choose_left.clone(),
858            choose_right.clone(),
859        ])
860        .unwrap();
861        assert_eq!(
862            view.discussions[&discussion_id()].conflict_operations,
863            BTreeSet::from([choose_left.operation_id, choose_right.operation_id])
864        );
865    }
866
867    #[test]
868    fn hosted_sets_separate_rejected_and_blocked_descendants() {
869        let root = root();
870        let rejected = decoded(
871            vec![root.operation_id],
872            "rejected",
873            2,
874            CollaborationOperationBodyV1::AppendTurn {
875                turn: DiscussionTurnV1::new("rejected").unwrap(),
876            },
877        );
878        let child = decoded(
879            vec![rejected.operation_id],
880            "child",
881            3,
882            CollaborationOperationBodyV1::AppendTurn {
883                turn: DiscussionTurnV1::new("blocked").unwrap(),
884            },
885        );
886        let grandchild = decoded(
887            vec![child.operation_id],
888            "grandchild",
889            4,
890            CollaborationOperationBodyV1::AppendTurn {
891                turn: DiscussionTurnV1::new("also blocked").unwrap(),
892            },
893        );
894        let operations = [
895            root.clone(),
896            rejected.clone(),
897            child.clone(),
898            grandchild.clone(),
899        ]
900        .into_iter()
901        .map(|operation| (operation.operation_id, operation))
902        .collect();
903        let hosted = HostedCollaborationSet {
904            received: BTreeSet::from([
905                root.operation_id,
906                rejected.operation_id,
907                child.operation_id,
908                grandchild.operation_id,
909            ]),
910            accepted: BTreeSet::from([root.operation_id]),
911            rejected: BTreeSet::from([rejected.operation_id]),
912        };
913        hosted.validate(&operations).unwrap();
914        assert_eq!(
915            hosted.blocked_descendants(&operations),
916            BTreeSet::from([child.operation_id, grandchild.operation_id])
917        );
918        let invalid = HostedCollaborationSet {
919            received: hosted.received.clone(),
920            accepted: BTreeSet::from([root.operation_id, child.operation_id]),
921            rejected: BTreeSet::new(),
922        };
923        assert!(invalid.validate(&operations).is_err());
924    }
925}