1use 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}