1use 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}