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