Skip to main content

objects/object/collaboration/
operation.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use serde::{Deserialize, Serialize};
4
5use crate::object::{Attribution, ChangeId, ContentHash, StateId, VisibilityTier};
6
7use super::{
8    CollabOpId, CollaborationCodecError, CollaborationIdempotencyKey, DiscussionRecordId,
9    LegacyDiscussionId, LegacySourceLocator,
10};
11
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case", tag = "kind")]
14pub enum CollaborationAnchor {
15    Repository,
16    State {
17        state_id: StateId,
18    },
19    Change {
20        change_id: ChangeId,
21    },
22    Path {
23        state_id: StateId,
24        path: String,
25    },
26    Symbol {
27        state_id: StateId,
28        path: String,
29        symbol: String,
30    },
31}
32
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub struct DiscussionTurnV1 {
35    pub body: String,
36    pub content_hash: ContentHash,
37}
38
39impl DiscussionTurnV1 {
40    pub fn new(body: impl Into<String>) -> Result<Self, CollaborationCodecError> {
41        let body = body.into();
42        require_text(&body, "turn body")?;
43        let content_hash = ContentHash::compute_typed("collaboration-turn", body.as_bytes());
44        Ok(Self { body, content_hash })
45    }
46
47    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
48        require_text(&self.body, "turn body")?;
49        if ContentHash::compute_typed("collaboration-turn", self.body.as_bytes())
50            != self.content_hash
51        {
52            return Err(CollaborationCodecError::Invalid(
53                "turn content hash does not match its body".to_string(),
54            ));
55        }
56        Ok(())
57    }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case", tag = "kind")]
62pub enum CollaborationResolution {
63    AddressedByState { state_id: StateId },
64    AddressedByChange { change_id: ChangeId },
65    Dismissed { reason: String },
66    Annotation { annotation_id: String },
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case", tag = "kind")]
71pub enum LegacyDiscussionResolutionV1 {
72    Open,
73    AddressedByState { state_id: StateId },
74    Dismissed { reason: String },
75    Annotation { annotation_id: String },
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case", tag = "kind")]
80pub enum CollaborationOperationBodyV1 {
81    Open {
82        title: String,
83        anchor: CollaborationAnchor,
84        visibility: VisibilityTier,
85        turn: DiscussionTurnV1,
86    },
87    AppendTurn {
88        turn: DiscussionTurnV1,
89    },
90    Resolve {
91        resolution: CollaborationResolution,
92    },
93    Reopen {
94        reason: String,
95    },
96    ResolveConflict {
97        competing: Vec<CollabOpId>,
98        selected: CollabOpId,
99    },
100    LegacyImported {
101        source: LegacySourceLocator,
102        legacy_discussion_id: LegacyDiscussionId,
103        aliases: Vec<LegacySourceLocator>,
104        title: String,
105        anchor: CollaborationAnchor,
106        visibility: VisibilityTier,
107        turns: Vec<DiscussionTurnV1>,
108        resolution: LegacyDiscussionResolutionV1,
109    },
110}
111
112impl CollaborationOperationBodyV1 {
113    pub fn kind_name(&self) -> &'static str {
114        match self {
115            Self::Open { .. } => "open",
116            Self::AppendTurn { .. } => "append_turn",
117            Self::Resolve { .. } => "resolve",
118            Self::Reopen { .. } => "reopen",
119            Self::ResolveConflict { .. } => "resolve_conflict",
120            Self::LegacyImported { .. } => "legacy_imported",
121        }
122    }
123
124    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
125        match self {
126            Self::Open {
127                title,
128                anchor,
129                turn,
130                ..
131            } => {
132                require_text(title, "discussion title")?;
133                validate_anchor(anchor)?;
134                turn.validate()
135            }
136            Self::AppendTurn { turn } => turn.validate(),
137            Self::Resolve { resolution } => validate_resolution(resolution),
138            Self::Reopen { reason } => require_text(reason, "reopen reason"),
139            Self::ResolveConflict {
140                competing,
141                selected,
142            } => {
143                if competing.len() < 2 || !competing.contains(selected) {
144                    return Err(CollaborationCodecError::Invalid(
145                        "conflict resolution must select one of at least two competing operations"
146                            .to_string(),
147                    ));
148                }
149                if competing.windows(2).any(|ids| ids[0] >= ids[1]) {
150                    return Err(CollaborationCodecError::Invalid(
151                        "competing operation ids must be sorted and unique".to_string(),
152                    ));
153                }
154                Ok(())
155            }
156            Self::LegacyImported {
157                title,
158                anchor,
159                aliases,
160                turns,
161                resolution,
162                ..
163            } => {
164                require_text(title, "discussion title")?;
165                validate_anchor(anchor)?;
166                if aliases.windows(2).any(|values| values[0] >= values[1]) {
167                    return Err(CollaborationCodecError::Invalid(
168                        "legacy aliases must be sorted and unique".to_string(),
169                    ));
170                }
171                if turns.is_empty() {
172                    return Err(CollaborationCodecError::Invalid(
173                        "legacy import must contain a turn".to_string(),
174                    ));
175                }
176                for turn in turns {
177                    turn.validate()?;
178                }
179                validate_legacy_resolution(resolution)
180            }
181        }
182    }
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
186pub struct CollaborationOperationEnvelope {
187    pub discussion_id: DiscussionRecordId,
188    pub parents: Vec<CollabOpId>,
189    pub idempotency_key: CollaborationIdempotencyKey,
190    pub author: Attribution,
191    pub occurred_at_ms: i64,
192    pub body: CollaborationOperationBodyV1,
193}
194
195impl CollaborationOperationEnvelope {
196    pub fn new(
197        discussion_id: DiscussionRecordId,
198        mut parents: Vec<CollabOpId>,
199        idempotency_key: CollaborationIdempotencyKey,
200        author: Attribution,
201        occurred_at_ms: i64,
202        body: CollaborationOperationBodyV1,
203    ) -> Result<Self, CollaborationCodecError> {
204        parents.sort();
205        parents.dedup();
206        let operation = Self {
207            discussion_id,
208            parents,
209            idempotency_key,
210            author,
211            occurred_at_ms,
212            body,
213        };
214        operation.validate()?;
215        Ok(operation)
216    }
217
218    pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
219        super::codec::encode(self)
220    }
221
222    pub fn decode(
223        bytes: &[u8],
224    ) -> Result<super::DecodedCollaborationOperation, CollaborationCodecError> {
225        super::codec::decode(bytes)
226    }
227
228    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
229        if self.parents.windows(2).any(|ids| ids[0] >= ids[1]) {
230            return Err(CollaborationCodecError::Invalid(
231                "parent operation ids must be sorted and unique".to_string(),
232            ));
233        }
234        if matches!(
235            self.body,
236            CollaborationOperationBodyV1::Open { .. }
237                | CollaborationOperationBodyV1::LegacyImported { .. }
238        ) {
239            if !self.parents.is_empty() {
240                return Err(CollaborationCodecError::Invalid(
241                    "discussion root operation cannot have parents".to_string(),
242                ));
243            }
244        } else if self.parents.is_empty() {
245            return Err(CollaborationCodecError::Invalid(
246                "non-root collaboration operation requires a parent".to_string(),
247            ));
248        }
249        if let CollaborationOperationBodyV1::ResolveConflict { competing, .. } = &self.body
250            && competing.iter().any(|id| !self.parents.contains(id))
251        {
252            return Err(CollaborationCodecError::Invalid(
253                "conflict resolution must causally follow every competing operation".to_string(),
254            ));
255        }
256        self.body.validate()
257    }
258}
259
260fn validate_anchor(anchor: &CollaborationAnchor) -> Result<(), CollaborationCodecError> {
261    match anchor {
262        CollaborationAnchor::Path { path, .. } => require_text(path, "anchor path"),
263        CollaborationAnchor::Symbol { path, symbol, .. } => {
264            require_text(path, "anchor path")?;
265            require_text(symbol, "anchor symbol")
266        }
267        CollaborationAnchor::Repository
268        | CollaborationAnchor::State { .. }
269        | CollaborationAnchor::Change { .. } => Ok(()),
270    }
271}
272
273fn validate_resolution(value: &CollaborationResolution) -> Result<(), CollaborationCodecError> {
274    match value {
275        CollaborationResolution::Dismissed { reason } => require_text(reason, "dismiss reason"),
276        CollaborationResolution::Annotation { annotation_id } => {
277            require_text(annotation_id, "annotation id")
278        }
279        _ => Ok(()),
280    }
281}
282
283fn validate_legacy_resolution(
284    value: &LegacyDiscussionResolutionV1,
285) -> Result<(), CollaborationCodecError> {
286    match value {
287        LegacyDiscussionResolutionV1::Dismissed { reason } => {
288            require_text(reason, "dismiss reason")
289        }
290        LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
291            require_text(annotation_id, "annotation id")
292        }
293        _ => Ok(()),
294    }
295}
296
297fn require_text(value: &str, field: &str) -> Result<(), CollaborationCodecError> {
298    if value.trim().is_empty() {
299        Err(CollaborationCodecError::Invalid(format!(
300            "{field} must not be empty"
301        )))
302    } else {
303        Ok(())
304    }
305}