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