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