Skip to main content

heddle_object_model/object/collaboration/codec/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3mod v2;
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    v2::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 != super::COLLABORATION_OPERATION_SCHEMA_VERSION {
45        return Err(CollaborationCodecError::UnsupportedVersion(
46            probe.schema_version,
47        ));
48    }
49    let operation = v2::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: 3,
80            body: &[0xc1],
81        })
82        .unwrap();
83        assert!(matches!(
84            decode(&bytes),
85            Err(CollaborationCodecError::UnsupportedVersion(3))
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            blocking: false,
124            title: "t".to_string(),
125            anchor,
126            visibility: VisibilityTier::default(),
127            turn: turn(),
128            thread_ref: None,
129        };
130        let locator = LegacySourceLocator::new(
131            state,
132            StateAttachmentId::from_hash(ContentHash::from_bytes([3; 32])),
133            ContentHash::from_bytes([4; 32]),
134        );
135        let legacy = |resolution| CollaborationOperationBodyV1::LegacyImported {
136            source: locator.clone(),
137            legacy_discussion_id: LegacyDiscussionId::new("l").unwrap(),
138            aliases: vec![LegacySourceLocator::new(
139                StateId::from_bytes([5; 32]),
140                StateAttachmentId::from_hash(ContentHash::from_bytes([6; 32])),
141                ContentHash::from_bytes([7; 32]),
142            )],
143            title: "t".to_string(),
144            anchor: CollaborationAnchor::Symbol {
145                state_id: state,
146                path: "p".to_string(),
147                symbol: "s".to_string(),
148            },
149            visibility: VisibilityTier::default(),
150            turns: vec![turn()],
151            resolution,
152        };
153        vec![
154            golden_operation("open_repository", open(CollaborationAnchor::Repository)),
155            golden_operation(
156                "open_state",
157                open(CollaborationAnchor::State { state_id: state }),
158            ),
159            golden_operation(
160                "open_change",
161                open(CollaborationAnchor::Change { change_id: change }),
162            ),
163            golden_operation(
164                "open_path",
165                open(CollaborationAnchor::Path {
166                    state_id: state,
167                    path: "p".to_string(),
168                }),
169            ),
170            golden_operation(
171                "open_symbol",
172                open(CollaborationAnchor::Symbol {
173                    state_id: state,
174                    path: "p".to_string(),
175                    symbol: "s".to_string(),
176                }),
177            ),
178            golden_operation(
179                "append_turn",
180                CollaborationOperationBodyV1::AppendTurn { turn: turn() },
181            ),
182            golden_operation(
183                "rebind_anchor",
184                CollaborationOperationBodyV1::RebindAnchor {
185                    anchor: CollaborationAnchor::Symbol {
186                        state_id: state,
187                        path: "p".to_string(),
188                        symbol: "s2".to_string(),
189                    },
190                    status: CollaborationAnchorStatus::Moved,
191                    body_changed_since_open: true,
192                },
193            ),
194            golden_operation(
195                "resolve_state",
196                CollaborationOperationBodyV1::Resolve {
197                    resolution: CollaborationResolution::AddressedByState { state_id: state },
198                },
199            ),
200            golden_operation(
201                "resolve_change",
202                CollaborationOperationBodyV1::Resolve {
203                    resolution: CollaborationResolution::AddressedByChange { change_id: change },
204                },
205            ),
206            golden_operation(
207                "resolve_dismissed",
208                CollaborationOperationBodyV1::Resolve {
209                    resolution: CollaborationResolution::Dismissed {
210                        reason: "r".to_string(),
211                    },
212                },
213            ),
214            golden_operation(
215                "resolve_annotation",
216                CollaborationOperationBodyV1::Resolve {
217                    resolution: CollaborationResolution::Annotation {
218                        annotation_id: "a".to_string(),
219                    },
220                },
221            ),
222            golden_operation(
223                "resolve_into_annotation",
224                CollaborationOperationBodyV1::Resolve {
225                    resolution: CollaborationResolution::IntoAnnotation {
226                        annotation_kind: AnnotationKind::Rationale,
227                        content: "why".to_string(),
228                        tags: vec!["design".to_string()],
229                    },
230                },
231            ),
232            golden_operation(
233                "reopen",
234                CollaborationOperationBodyV1::Reopen {
235                    reason: "r".to_string(),
236                },
237            ),
238            golden_operation(
239                "resolve_conflict",
240                CollaborationOperationBodyV1::ResolveConflict {
241                    competing: vec![
242                        CollabOpId::from_bytes([7; 32]),
243                        CollabOpId::from_bytes([8; 32]),
244                    ],
245                    selected: CollabOpId::from_bytes([7; 32]),
246                },
247            ),
248            golden_operation("legacy_open", legacy(LegacyDiscussionResolutionV1::Open)),
249            golden_operation(
250                "legacy_state",
251                legacy(LegacyDiscussionResolutionV1::AddressedByState { state_id: state }),
252            ),
253            golden_operation(
254                "legacy_dismissed",
255                legacy(LegacyDiscussionResolutionV1::Dismissed {
256                    reason: "r".to_string(),
257                }),
258            ),
259            golden_operation(
260                "legacy_annotation",
261                legacy(LegacyDiscussionResolutionV1::Annotation {
262                    annotation_id: "a".to_string(),
263                }),
264            ),
265        ]
266    }
267
268    #[test]
269    fn v2_full_variant_msgpack_vectors_are_frozen() {
270        let expected = [
271            (
272                "open_repository",
273                "69b3baebacb9d29c3d6c5cac73d6f14b2fe5231e061065249e808e2d92cddefe",
274            ),
275            (
276                "open_state",
277                "785cf6234fdfc4a01ff068a9b67a2c0d60172f59795c39c3c5b4e1037176a964",
278            ),
279            (
280                "open_change",
281                "e520779bc52b05e753a139bddafd25dcc8765182b865cd305691f85129b58c03",
282            ),
283            (
284                "open_path",
285                "1c814db69893d0ee71abe9d5a1d7d17724ff893fdf36bf90e9c40aa4beae9803",
286            ),
287            (
288                "open_symbol",
289                "9f43eb25d8c920d89a31c148fcd177e789e909eb7d3715fc2f899f85fa680197",
290            ),
291            (
292                "append_turn",
293                "777d8164d530fe27545685f23b283765a3b912f96a262dbad836faba498a9279",
294            ),
295            (
296                "rebind_anchor",
297                "e548683d8c7f20c8550627886f196a81b91e6ae52b8cdaadbfdd8944ceda1f8d",
298            ),
299            (
300                "resolve_state",
301                "7063d23abba098f608b13f2b807892bf7a5b61cb7b8487897ba84e95ff519dc9",
302            ),
303            (
304                "resolve_change",
305                "bcaa6bd96243624dcfe25942e92f59104d08291b2daf5dfd4c8d68f6fbd98472",
306            ),
307            (
308                "resolve_dismissed",
309                "4277710d09ae7043616335695b98fc89ce15583fd90437b28587c310492fe8f0",
310            ),
311            (
312                "resolve_annotation",
313                "c4eee97f235a3b534e423f20159ddba3077f9e0079ebc0697fa6c2426b394827",
314            ),
315            (
316                "resolve_into_annotation",
317                "e1470222f139ff1e6e06480996a7c27c2ef9d162eed2405ad3d838304d94d73e",
318            ),
319            (
320                "reopen",
321                "fa59e9c396a77ce07e57cb7b312f76438c3a002a1a4f053d1da13d9d10e03f7d",
322            ),
323            (
324                "resolve_conflict",
325                "d4c76d8ff078907d528613f7811151797d204f01216876623aecd675e9466606",
326            ),
327            (
328                "legacy_open",
329                "56e355d619b3db940a4cce75d525dac493d7e925c3e37cf85e1a789c6454713b",
330            ),
331            (
332                "legacy_state",
333                "44281502243244c40fc8be8b1fa452e3fd4491a455271462cb1236bac5b8136d",
334            ),
335            (
336                "legacy_dismissed",
337                "c9dd8f6850af3fce3797bb2ce853414dee066d29821756cbb8e2c6627ed56688",
338            ),
339            (
340                "legacy_annotation",
341                "8148359eab1c913a5a1b98debc9aa9f2a455cbbbc7b376b43b80b59c0b709331",
342            ),
343        ];
344        let actual = golden_vectors()
345            .into_iter()
346            .map(|(name, bytes)| {
347                let decoded = CollaborationOperationEnvelope::decode(&bytes).unwrap();
348                assert_eq!(decoded.operation_id, CollabOpId::for_bytes(&bytes));
349                (name, ContentHash::compute(&bytes).to_hex())
350            })
351            .collect::<Vec<_>>();
352        assert_eq!(actual.len(), expected.len());
353        for ((actual_name, actual_hash), (expected_name, expected_hash)) in
354            actual.iter().zip(expected)
355        {
356            assert_eq!(actual_name, expected_name);
357            assert_eq!(actual_hash, expected_hash);
358        }
359    }
360}