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