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    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}
237
238impl CollaborationOperationEnvelope {
239    pub fn new(
240        discussion_id: DiscussionRecordId,
241        mut parents: Vec<CollabOpId>,
242        idempotency_key: CollaborationIdempotencyKey,
243        author: Attribution,
244        occurred_at_ms: i64,
245        body: CollaborationOperationBodyV1,
246    ) -> Result<Self, CollaborationCodecError> {
247        parents.sort();
248        parents.dedup();
249        let operation = Self {
250            metadata: None,
251            discussion_id,
252            parents,
253            idempotency_key,
254            author,
255            occurred_at_ms,
256            body,
257        };
258        operation.validate()?;
259        Ok(operation)
260    }
261
262    /// Attach durable identity and references before signing the canonical record.
263    pub fn with_metadata(
264        mut self,
265        metadata: super::CollaborationMetadata,
266    ) -> Result<Self, CollaborationCodecError> {
267        metadata.validate()?;
268        self.metadata = Some(metadata);
269        Ok(self)
270    }
271
272    pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
273        super::codec::encode(self)
274    }
275
276    pub fn decode(
277        bytes: &[u8],
278    ) -> Result<super::DecodedCollaborationOperation, CollaborationCodecError> {
279        super::codec::decode(bytes)
280    }
281
282    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
283        if let Some(metadata) = &self.metadata {
284            metadata.validate()?;
285        }
286        if self.parents.windows(2).any(|ids| ids[0] >= ids[1]) {
287            return Err(CollaborationCodecError::Invalid(
288                "parent operation ids must be sorted and unique".to_string(),
289            ));
290        }
291        if matches!(
292            self.body,
293            CollaborationOperationBodyV1::Open { .. }
294                | CollaborationOperationBodyV1::LegacyImported { .. }
295        ) {
296            if !self.parents.is_empty() {
297                return Err(CollaborationCodecError::Invalid(
298                    "discussion root operation cannot have parents".to_string(),
299                ));
300            }
301        } else if self.parents.is_empty() {
302            return Err(CollaborationCodecError::Invalid(
303                "non-root collaboration operation requires a parent".to_string(),
304            ));
305        }
306        if let CollaborationOperationBodyV1::ResolveConflict { competing, .. } = &self.body
307            && competing.iter().any(|id| !self.parents.contains(id))
308        {
309            return Err(CollaborationCodecError::Invalid(
310                "conflict resolution must causally follow every competing operation".to_string(),
311            ));
312        }
313        self.body.validate()
314    }
315}
316
317pub(super) fn validate_anchor(anchor: &CollaborationAnchor) -> Result<(), CollaborationCodecError> {
318    match anchor {
319        CollaborationAnchor::Source { source } => source.validate(),
320        CollaborationAnchor::Path { path, .. } => require_text(path, "anchor path"),
321        CollaborationAnchor::Symbol { path, symbol, .. } => {
322            require_text(path, "anchor path")?;
323            require_text(symbol, "anchor symbol")
324        }
325        CollaborationAnchor::Repository
326        | CollaborationAnchor::State { .. }
327        | CollaborationAnchor::Change { .. } => Ok(()),
328    }
329}
330
331fn validate_resolution(value: &CollaborationResolution) -> Result<(), CollaborationCodecError> {
332    match value {
333        CollaborationResolution::Dismissed { reason } => require_text(reason, "dismiss reason"),
334        CollaborationResolution::IntoContext { context } => context.encode().map(|_| ()),
335        CollaborationResolution::IntoAnnotation { content, .. } => {
336            require_text(content, "annotation content")
337        }
338        CollaborationResolution::Annotation { annotation_id } => {
339            require_text(annotation_id, "annotation id")
340        }
341        _ => Ok(()),
342    }
343}
344
345fn validate_legacy_resolution(
346    value: &LegacyDiscussionResolutionV1,
347) -> Result<(), CollaborationCodecError> {
348    match value {
349        LegacyDiscussionResolutionV1::Dismissed { reason } => {
350            require_text(reason, "dismiss reason")
351        }
352        LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
353            require_text(annotation_id, "annotation id")
354        }
355        _ => Ok(()),
356    }
357}
358
359fn require_text(value: &str, field: &str) -> Result<(), CollaborationCodecError> {
360    if value.trim().is_empty() {
361        Err(CollaborationCodecError::Invalid(format!(
362            "{field} must not be empty"
363        )))
364    } else {
365        Ok(())
366    }
367}