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