Skip to main content

heddle_object_model/object/collaboration/
operation.rs

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