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