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