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    },
118    AppendTurn {
119        turn: DiscussionTurnV1,
120    },
121    RebindAnchor {
122        anchor: CollaborationAnchor,
123        status: CollaborationAnchorStatus,
124        body_changed_since_open: bool,
125    },
126    Resolve {
127        resolution: CollaborationResolution,
128    },
129    Reopen {
130        reason: String,
131    },
132    ResolveConflict {
133        competing: Vec<CollabOpId>,
134        selected: CollabOpId,
135    },
136    LegacyImported {
137        source: LegacySourceLocator,
138        legacy_discussion_id: LegacyDiscussionId,
139        aliases: Vec<LegacySourceLocator>,
140        title: String,
141        anchor: CollaborationAnchor,
142        visibility: VisibilityTier,
143        turns: Vec<DiscussionTurnV1>,
144        resolution: LegacyDiscussionResolutionV1,
145    },
146}
147
148impl CollaborationOperationBodyV1 {
149    pub fn kind_name(&self) -> &'static str {
150        match self {
151            Self::Open { .. } => "open",
152            Self::AppendTurn { .. } => "append_turn",
153            Self::RebindAnchor { .. } => "rebind_anchor",
154            Self::Resolve { .. } => "resolve",
155            Self::Reopen { .. } => "reopen",
156            Self::ResolveConflict { .. } => "resolve_conflict",
157            Self::LegacyImported { .. } => "legacy_imported",
158        }
159    }
160
161    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
162        match self {
163            Self::Open {
164                title,
165                anchor,
166                turn,
167                thread_ref,
168                ..
169            } => {
170                require_text(title, "discussion title")?;
171                validate_anchor(anchor)?;
172                if let Some(thread_ref) = thread_ref {
173                    require_text(thread_ref, "discussion thread ref")?;
174                }
175                turn.validate()
176            }
177            Self::AppendTurn { turn } => turn.validate(),
178            Self::RebindAnchor { anchor, .. } => validate_anchor(anchor),
179            Self::Resolve { resolution } => validate_resolution(resolution),
180            Self::Reopen { reason } => require_text(reason, "reopen reason"),
181            Self::ResolveConflict {
182                competing,
183                selected,
184            } => {
185                if competing.len() < 2 || !competing.contains(selected) {
186                    return Err(CollaborationCodecError::Invalid(
187                        "conflict resolution must select one of at least two competing operations"
188                            .to_string(),
189                    ));
190                }
191                if competing.windows(2).any(|ids| ids[0] >= ids[1]) {
192                    return Err(CollaborationCodecError::Invalid(
193                        "competing operation ids must be sorted and unique".to_string(),
194                    ));
195                }
196                Ok(())
197            }
198            Self::LegacyImported {
199                title,
200                anchor,
201                aliases,
202                turns,
203                resolution,
204                ..
205            } => {
206                require_text(title, "discussion title")?;
207                validate_anchor(anchor)?;
208                if aliases.windows(2).any(|values| values[0] >= values[1]) {
209                    return Err(CollaborationCodecError::Invalid(
210                        "legacy aliases must be sorted and unique".to_string(),
211                    ));
212                }
213                if turns.is_empty() {
214                    return Err(CollaborationCodecError::Invalid(
215                        "legacy import must contain a turn".to_string(),
216                    ));
217                }
218                for turn in turns {
219                    turn.validate()?;
220                }
221                validate_legacy_resolution(resolution)
222            }
223        }
224    }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub struct CollaborationOperationEnvelope {
229    pub metadata: Option<super::CollaborationMetadata>,
230    pub discussion_id: DiscussionRecordId,
231    pub parents: Vec<CollabOpId>,
232    pub idempotency_key: CollaborationIdempotencyKey,
233    pub author: Attribution,
234    pub occurred_at_ms: i64,
235    pub body: CollaborationOperationBodyV1,
236    /// Canonical MessagePack from the last successful [`Self::encode`], or the
237    /// exact bytes [`Self::decode`] hashed. Not serialized. [`Clone`] drops it.
238    #[serde(skip)]
239    pub(crate) canonical_body: CanonicalBody,
240}
241
242impl CollaborationOperationEnvelope {
243    pub fn new(
244        discussion_id: DiscussionRecordId,
245        mut parents: Vec<CollabOpId>,
246        idempotency_key: CollaborationIdempotencyKey,
247        author: Attribution,
248        occurred_at_ms: i64,
249        body: CollaborationOperationBodyV1,
250    ) -> Result<Self, CollaborationCodecError> {
251        parents.sort();
252        parents.dedup();
253        let operation = Self {
254            metadata: None,
255            discussion_id,
256            parents,
257            idempotency_key,
258            author,
259            occurred_at_ms,
260            body,
261            canonical_body: CanonicalBody::default(),
262        };
263        operation.validate()?;
264        Ok(operation)
265    }
266
267    /// Attach durable identity and references before signing the canonical record.
268    pub fn with_metadata(
269        mut self,
270        metadata: super::CollaborationMetadata,
271    ) -> Result<Self, CollaborationCodecError> {
272        metadata.validate()?;
273        self.metadata = Some(metadata);
274        self.canonical_body.clear();
275        Ok(self)
276    }
277
278    pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
279        self.canonical_body.clear();
280        let bytes = super::codec::encode(self)?;
281        self.canonical_body.store(bytes.clone());
282        Ok(bytes)
283    }
284
285    /// Content id of the canonical body. Reuses bytes from [`Self::decode`] or the
286    /// last successful [`Self::encode`] instead of serializing again.
287    pub fn id(&self) -> Result<CollabOpId, CollaborationCodecError> {
288        if let Some(bytes) = self.canonical_body.cloned() {
289            CanonicalBody::debug_matches(&bytes, || super::codec::encode(self));
290            return Ok(CollabOpId::for_bytes(&bytes));
291        }
292        Ok(CollabOpId::for_bytes(&self.encode()?))
293    }
294
295    pub fn decode(
296        bytes: &[u8],
297    ) -> Result<super::DecodedCollaborationOperation, CollaborationCodecError> {
298        super::codec::decode(bytes)
299    }
300
301    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
302        if let Some(metadata) = &self.metadata {
303            metadata.validate()?;
304        }
305        if self.parents.windows(2).any(|ids| ids[0] >= ids[1]) {
306            return Err(CollaborationCodecError::Invalid(
307                "parent operation ids must be sorted and unique".to_string(),
308            ));
309        }
310        if matches!(
311            self.body,
312            CollaborationOperationBodyV1::Open { .. }
313                | CollaborationOperationBodyV1::LegacyImported { .. }
314        ) {
315            if !self.parents.is_empty() {
316                return Err(CollaborationCodecError::Invalid(
317                    "discussion root operation cannot have parents".to_string(),
318                ));
319            }
320        } else if self.parents.is_empty() {
321            return Err(CollaborationCodecError::Invalid(
322                "non-root collaboration operation requires a parent".to_string(),
323            ));
324        }
325        if let CollaborationOperationBodyV1::ResolveConflict { competing, .. } = &self.body
326            && competing.iter().any(|id| !self.parents.contains(id))
327        {
328            return Err(CollaborationCodecError::Invalid(
329                "conflict resolution must causally follow every competing operation".to_string(),
330            ));
331        }
332        self.body.validate()
333    }
334}
335
336pub(super) fn validate_anchor(anchor: &CollaborationAnchor) -> Result<(), CollaborationCodecError> {
337    match anchor {
338        CollaborationAnchor::Source { source } => source.validate(),
339        CollaborationAnchor::Path { path, .. } => require_text(path, "anchor path"),
340        CollaborationAnchor::Symbol { path, symbol, .. } => {
341            require_text(path, "anchor path")?;
342            require_text(symbol, "anchor symbol")
343        }
344        CollaborationAnchor::Repository
345        | CollaborationAnchor::State { .. }
346        | CollaborationAnchor::Change { .. } => Ok(()),
347    }
348}
349
350fn validate_resolution(value: &CollaborationResolution) -> Result<(), CollaborationCodecError> {
351    match value {
352        CollaborationResolution::Dismissed { reason } => require_text(reason, "dismiss reason"),
353        CollaborationResolution::IntoContext { context } => context.encode().map(|_| ()),
354        CollaborationResolution::IntoAnnotation { content, .. } => {
355            require_text(content, "annotation content")
356        }
357        CollaborationResolution::Annotation { annotation_id } => {
358            require_text(annotation_id, "annotation id")
359        }
360        _ => Ok(()),
361    }
362}
363
364fn validate_legacy_resolution(
365    value: &LegacyDiscussionResolutionV1,
366) -> Result<(), CollaborationCodecError> {
367    match value {
368        LegacyDiscussionResolutionV1::Dismissed { reason } => {
369            require_text(reason, "dismiss reason")
370        }
371        LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
372            require_text(annotation_id, "annotation id")
373        }
374        _ => Ok(()),
375    }
376}
377
378fn require_text(value: &str, field: &str) -> Result<(), CollaborationCodecError> {
379    if value.trim().is_empty() {
380        Err(CollaborationCodecError::Invalid(format!(
381            "{field} must not be empty"
382        )))
383    } else {
384        Ok(())
385    }
386}