Skip to main content

heddle_object_model/object/collaboration/codec/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3mod v1;
4
5use serde::Deserialize;
6
7use super::{CollabOpId, CollaborationOperationEnvelope};
8
9#[derive(Debug, thiserror::Error)]
10pub enum CollaborationCodecError {
11    #[error("collaboration operation encoding failed: {0}")]
12    Encoding(String),
13    #[error("collaboration operation decoding failed: {0}")]
14    Decoding(String),
15    #[error("unsupported collaboration operation version {0}")]
16    UnsupportedVersion(u16),
17    #[error("invalid collaboration operation: {0}")]
18    Invalid(String),
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct DecodedCollaborationOperation {
23    pub operation_id: CollabOpId,
24    pub operation: CollaborationOperationEnvelope,
25}
26
27#[derive(Deserialize)]
28struct VersionProbe {
29    schema_version: u16,
30}
31
32pub(crate) fn encode(
33    operation: &CollaborationOperationEnvelope,
34) -> Result<Vec<u8>, CollaborationCodecError> {
35    operation.validate()?;
36    v1::encode(operation)
37}
38
39pub(crate) fn decode(
40    bytes: &[u8],
41) -> Result<DecodedCollaborationOperation, CollaborationCodecError> {
42    let probe: VersionProbe = rmp_serde::from_slice(bytes)
43        .map_err(|error| CollaborationCodecError::Decoding(error.to_string()))?;
44    if probe.schema_version != 1 {
45        return Err(CollaborationCodecError::UnsupportedVersion(
46            probe.schema_version,
47        ));
48    }
49    let operation = v1::decode(bytes)?;
50    operation.validate()?;
51    Ok(DecodedCollaborationOperation {
52        operation_id: CollabOpId::for_bytes(bytes),
53        operation,
54    })
55}
56
57#[cfg(test)]
58mod tests {
59    use serde::Serialize;
60
61    use super::*;
62    use crate::object::{
63        AnnotationKind, Attribution, ChangeId, CollaborationAnchor, CollaborationAnchorStatus,
64        CollaborationIdempotencyKey, CollaborationOperationBodyV1, CollaborationResolution,
65        ContentHash, DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId,
66        LegacyDiscussionResolutionV1, LegacySourceLocator, Principal, StateAttachmentId, StateId,
67        VisibilityTier,
68    };
69
70    #[derive(Serialize)]
71    struct Unsupported<'a> {
72        schema_version: u16,
73        body: &'a [u8],
74    }
75
76    #[test]
77    fn unsupported_version_is_rejected_before_body_decode() {
78        let bytes = rmp_serde::to_vec_named(&Unsupported {
79            schema_version: 2,
80            body: &[0xc1],
81        })
82        .unwrap();
83        assert!(matches!(
84            decode(&bytes),
85            Err(CollaborationCodecError::UnsupportedVersion(2))
86        ));
87    }
88
89    fn golden_operation(name: &str, body: CollaborationOperationBodyV1) -> (String, Vec<u8>) {
90        let root = matches!(
91            body,
92            CollaborationOperationBodyV1::Open { .. }
93                | CollaborationOperationBodyV1::LegacyImported { .. }
94        );
95        let operation = CollaborationOperationEnvelope::new(
96            "disc-018f47ea-4a54-7c89-b012-3456789abcde"
97                .parse::<DiscussionRecordId>()
98                .unwrap(),
99            if root {
100                Vec::new()
101            } else if matches!(body, CollaborationOperationBodyV1::ResolveConflict { .. }) {
102                vec![
103                    CollabOpId::from_bytes([7; 32]),
104                    CollabOpId::from_bytes([8; 32]),
105                ]
106            } else {
107                vec![CollabOpId::from_bytes([7; 32])]
108            },
109            CollaborationIdempotencyKey::new("k").unwrap(),
110            Attribution::human(Principal::new("A", "a@b")),
111            0,
112            body,
113        )
114        .unwrap();
115        (name.to_string(), operation.encode().unwrap())
116    }
117
118    fn golden_vectors() -> Vec<(String, Vec<u8>)> {
119        let state = StateId::from_bytes([1; 32]);
120        let change = ChangeId::from_bytes([2; 16]);
121        let turn = || DiscussionTurnV1::new("x").unwrap();
122        let open = |anchor| CollaborationOperationBodyV1::Open {
123            title: "t".to_string(),
124            anchor,
125            visibility: VisibilityTier::default(),
126            turn: turn(),
127            thread_ref: None,
128        };
129        let locator = LegacySourceLocator::new(
130            state,
131            StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
132            ContentHash::from_bytes([4; 32]),
133        );
134        let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
135            source: locator.clone(),
136            legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
137            aliases: vec![LegacySourceLocator::new(
138                StateId::from_bytes([5; 32]),
139                StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
140                ContentHash::from_bytes([7; 32]),
141            )],
142            title: "t".to_string(),
143            anchor: CollaborationAnchor::Symbol {
144                state_id: state,
145                path: "p".to_string(),
146                symbol: "s".to_string(),
147            },
148            visibility: VisibilityTier::default(),
149            turns: vec![turn()],
150            resolution,
151        };
152        vec![
153            golden_operation("open_repository", open(CollaborationAnchor::Repository)),
154            golden_operation(
155                "open_state",
156                open(CollaborationAnchor::State { state_id: state }),
157            ),
158            golden_operation(
159                "open_change",
160                open(CollaborationAnchor::Change { change_id: change }),
161            ),
162            golden_operation(
163                "open_path",
164                open(CollaborationAnchor::Path {
165                    state_id: state,
166                    path: "p".to_string(),
167                }),
168            ),
169            golden_operation(
170                "open_symbol",
171                open(CollaborationAnchor::Symbol {
172                    state_id: state,
173                    path: "p".to_string(),
174                    symbol: "s".to_string(),
175                }),
176            ),
177            golden_operation(
178                "append_turn",
179                CollaborationOperationBodyV1::AppendTurn { turn: turn() },
180            ),
181            golden_operation(
182                "rebind_anchor",
183                CollaborationOperationBodyV1::RebindAnchor {
184                    anchor: CollaborationAnchor::Symbol {
185                        state_id: state,
186                        path: "p".to_string(),
187                        symbol: "s2".to_string(),
188                    },
189                    status: CollaborationAnchorStatus::Moved,
190                    body_changed_since_open: true,
191                },
192            ),
193            golden_operation(
194                "resolve_state",
195                CollaborationOperationBodyV1::Resolve {
196                    resolution: CollaborationResolution::AddressedByState { state_id: state },
197                },
198            ),
199            golden_operation(
200                "resolve_change",
201                CollaborationOperationBodyV1::Resolve {
202                    resolution: CollaborationResolution::AddressedByChange { change_id: change },
203                },
204            ),
205            golden_operation(
206                "resolve_dismissed",
207                CollaborationOperationBodyV1::Resolve {
208                    resolution: CollaborationResolution::Dismissed {
209                        reason: "r".to_string(),
210                    },
211                },
212            ),
213            golden_operation(
214                "resolve_annotation",
215                CollaborationOperationBodyV1::Resolve {
216                    resolution: CollaborationResolution::Annotation {
217                        annotation_id: "a".to_string(),
218                    },
219                },
220            ),
221            golden_operation(
222                "resolve_into_annotation",
223                CollaborationOperationBodyV1::Resolve {
224                    resolution: CollaborationResolution::IntoAnnotation {
225                        annotation_kind: AnnotationKind::Rationale,
226                        content: "why".to_string(),
227                        tags: vec!["design".to_string()],
228                    },
229                },
230            ),
231            golden_operation(
232                "reopen",
233                CollaborationOperationBodyV1::Reopen {
234                    reason: "r".to_string(),
235                },
236            ),
237            golden_operation(
238                "resolve_conflict",
239                CollaborationOperationBodyV1::ResolveConflict {
240                    competing: vec![
241                        CollabOpId::from_bytes([7; 32]),
242                        CollabOpId::from_bytes([8; 32]),
243                    ],
244                    selected: CollabOpId::from_bytes([7; 32]),
245                },
246            ),
247            golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
248            golden_operation(
249                "legacy_state",
250                legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
251            ),
252            golden_operation(
253                "legacy_dismissed",
254                legacy(LegacyDiscussionResolutionV1::Dismissed {
255                    reason: "r".to_string(),
256                }),
257            ),
258            golden_operation(
259                "legacy_annotation",
260                legacy(LegacyDiscussionResolutionV1::Annotation {
261                    annotation_id: "a".to_string(),
262                }),
263            ),
264        ]
265    }
266
267    #[test]
268    fn v1_full_variant_msgpack_vectors_are_frozen() {
269        let expected = [
270            (
271                "open_repository",
272                "6c4929bfebf65a906406b48957440c591eb8c4f0f7306aea37aca01016c7c256",
273            ),
274            (
275                "open_state",
276                "3733767e55beab34c5add4fa6ad514846320151cab84b8c077ee31456db14e94",
277            ),
278            (
279                "open_change",
280                "0504854868da192b823626573c041b53394217db9a1adb3eb290e913e2471c29",
281            ),
282            (
283                "open_path",
284                "39e55ce36dbc40d6bcad094825366eb962fe5e6170bf7a8a53b0a972679cc4ae",
285            ),
286            (
287                "open_symbol",
288                "a3ec2abb9288b42ab57cb2b1095ebb4b87fcf51bce6e7d57ec60838908fb81e4",
289            ),
290            (
291                "append_turn",
292                "b542d7f781fed9266dd557a8a422af1f3fce98b4fd834c0e88cc867787e32d1f",
293            ),
294            (
295                "rebind_anchor",
296                "105cff08ce66523d98a47a8f384aa4f2a3e91eada886e11c8c7a45a6d4a7aca6",
297            ),
298            (
299                "resolve_state",
300                "7646fc4ed8e8975805491f7760c652514191bd07b7b15eabdcf6b5c8f068439f",
301            ),
302            (
303                "resolve_change",
304                "e5021f4da168fef2bb26297c6fe554cc545a3a3b7dd5a056a23d0de2bdda38b0",
305            ),
306            (
307                "resolve_dismissed",
308                "e81909c0875c57ac3920109d2291aec767595952f883b4ca4390e0af61bce9f3",
309            ),
310            (
311                "resolve_annotation",
312                "9ca40f41cc72bbf6208a22f9dcbfeaa3773d1669366864b704e2251fd012bb39",
313            ),
314            (
315                "resolve_into_annotation",
316                "b3319a3a18c77c52dbf748dba14fdd06542555b7cd89b228b2938ef88888beb7",
317            ),
318            (
319                "reopen",
320                "2b997fdd5a1011255a4b85aa4f3cca3f2d0f97b2ce35d7cdcbb76a5349eeffd0",
321            ),
322            (
323                "resolve_conflict",
324                "01e63b8dbb33f11e1fa8b630e045f73030ff99db66a628fe13e84d5f9e9007b2",
325            ),
326            (
327                "legacy_open",
328                "ada646a3ab1feb54cb0b70a682079a8af0a603930f73ae0716cb861107fc4af3",
329            ),
330            (
331                "legacy_state",
332                "83a69502c2aa2d32606c2d1d5b568ddc4c555933960921e4a4f8b2e375a70e66",
333            ),
334            (
335                "legacy_dismissed",
336                "69cb8998b89d44d77e9851d2eaa619dd550ecf78e6212b07b18c3c1fd5d47989",
337            ),
338            (
339                "legacy_annotation",
340                "368a60c692b5e50bfb542f5654eccc3c62a4f65442e7404c48734386da141d32",
341            ),
342        ];
343        let actual = golden_vectors()
344            .into_iter()
345            .map(|(name, bytes)| {
346                let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
347                assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
348                (name, ContentHash::compute(&bytes).to_hex())
349            })
350            .collect::<Vec<_>>();
351        assert_eq!(actual.len(), expected.len());
352        for ((actual_name, actual_hash), (expected_name, expected_hash)) in
353            actual.iter().zip(expected)
354        {
355            assert_eq!(actual_name, expected_name);
356            assert_eq!(actual_hash, expected_hash);
357        }
358    }
359}