1use serde::{Deserialize, Serialize};
4
5use super::{
6 CollabOpId, CollaborationCodecError, CollaborationIdempotencyKey, DiscussionRecordId,
7 LegacyDiscussionId, LegacySourceLocator, canonical_body::CanonicalBody,
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 Source {
15 source: super::CollaborationSourceAnchor,
16 },
17 Repository,
18 State {
19 state_id: StateId,
20 },
21 Change {
22 change_id: ChangeId,
23 },
24 Path {
25 state_id: StateId,
26 path: String,
27 },
28 Symbol {
29 state_id: StateId,
30 path: String,
31 symbol: String,
32 },
33}
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum CollaborationAnchorStatus {
38 #[default]
39 Current,
40 Moved,
41 Ambiguous,
42 Orphaned,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct DiscussionTurnV1 {
47 pub body: String,
48 pub content_hash: ContentHash,
49}
50
51impl DiscussionTurnV1 {
52 pub fn new(body: impl Into<String>) -> Result<Self, CollaborationCodecError> {
53 let body = body.into();
54 require_text(&body, "turn body")?;
55 let content_hash = ContentHash::compute_typed("collaboration-turn", body.as_bytes());
56 Ok(Self { body, content_hash })
57 }
58
59 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
60 require_text(&self.body, "turn body")?;
61 if ContentHash::compute_typed("collaboration-turn", self.body.as_bytes())
62 != self.content_hash
63 {
64 return Err(CollaborationCodecError::Invalid(
65 "turn content hash does not match its body".to_string(),
66 ));
67 }
68 Ok(())
69 }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case", tag = "kind")]
74#[allow(clippy::large_enum_variant)] pub enum CollaborationResolution {
76 AddressedByState {
77 state_id: StateId,
78 },
79 AddressedByChange {
80 change_id: ChangeId,
81 },
82 Dismissed {
83 reason: String,
84 },
85 IntoContext {
86 context: super::ContextRevision,
87 },
88 IntoAnnotation {
89 annotation_kind: AnnotationKind,
90 content: String,
91 tags: Vec<String>,
92 },
93 Annotation {
94 annotation_id: String,
95 },
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case", tag = "kind")]
100pub enum LegacyDiscussionResolutionV1 {
101 Open,
102 AddressedByState { state_id: StateId },
103 Dismissed { reason: String },
104 Annotation { annotation_id: String },
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case", tag = "kind")]
109pub enum CollaborationOperationBodyV1 {
110 Open {
111 blocking: bool,
112 title: String,
113 anchor: CollaborationAnchor,
114 visibility: VisibilityTier,
115 turn: DiscussionTurnV1,
116 thread_ref: Option<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 tags: Vec<super::AnnotationTag>,
119 },
120 AppendTurn {
121 turn: DiscussionTurnV1,
122 },
123 EditTags {
124 tags: Vec<super::AnnotationTag>,
125 },
126 RebindAnchor {
127 anchor: CollaborationAnchor,
128 status: CollaborationAnchorStatus,
129 body_changed_since_open: bool,
130 },
131 Resolve {
132 resolution: CollaborationResolution,
133 },
134 Reopen {
135 reason: String,
136 },
137 ResolveConflict {
138 competing: Vec<CollabOpId>,
139 selected: CollabOpId,
140 },
141 LegacyImported {
142 source: LegacySourceLocator,
143 legacy_discussion_id: LegacyDiscussionId,
144 aliases: Vec<LegacySourceLocator>,
145 title: String,
146 anchor: CollaborationAnchor,
147 visibility: VisibilityTier,
148 turns: Vec<DiscussionTurnV1>,
149 resolution: LegacyDiscussionResolutionV1,
150 },
151}
152
153impl CollaborationOperationBodyV1 {
154 pub fn kind_name(&self) -> &'static str {
155 match self {
156 Self::Open { .. } => "open",
157 Self::AppendTurn { .. } => "append_turn",
158 Self::EditTags { .. } => "edit_tags",
159 Self::RebindAnchor { .. } => "rebind_anchor",
160 Self::Resolve { .. } => "resolve",
161 Self::Reopen { .. } => "reopen",
162 Self::ResolveConflict { .. } => "resolve_conflict",
163 Self::LegacyImported { .. } => "legacy_imported",
164 }
165 }
166
167 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
168 match self {
169 Self::Open {
170 title,
171 anchor,
172 turn,
173 thread_ref,
174 tags,
175 ..
176 } => {
177 require_text(title, "discussion title")?;
178 validate_anchor(anchor)?;
179 if let Some(thread_ref) = thread_ref {
180 require_text(thread_ref, "discussion thread ref")?;
181 }
182 super::validate_annotation_tags(tags)?;
183 turn.validate()
184 }
185 Self::AppendTurn { turn } => turn.validate(),
186 Self::EditTags { tags } => super::validate_annotation_tags(tags),
187 Self::RebindAnchor { anchor, .. } => validate_anchor(anchor),
188 Self::Resolve { resolution } => validate_resolution(resolution),
189 Self::Reopen { reason } => require_text(reason, "reopen reason"),
190 Self::ResolveConflict {
191 competing,
192 selected,
193 } => {
194 if competing.len() < 2 || !competing.contains(selected) {
195 return Err(CollaborationCodecError::Invalid(
196 "conflict resolution must select one of at least two competing operations"
197 .to_string(),
198 ));
199 }
200 if competing.windows(2).any(|ids| ids[0] >= ids[1]) {
201 return Err(CollaborationCodecError::Invalid(
202 "competing operation ids must be sorted and unique".to_string(),
203 ));
204 }
205 Ok(())
206 }
207 Self::LegacyImported {
208 title,
209 anchor,
210 aliases,
211 turns,
212 resolution,
213 ..
214 } => {
215 require_text(title, "discussion title")?;
216 validate_anchor(anchor)?;
217 if aliases.windows(2).any(|values| values[0] >= values[1]) {
218 return Err(CollaborationCodecError::Invalid(
219 "legacy aliases must be sorted and unique".to_string(),
220 ));
221 }
222 if turns.is_empty() {
223 return Err(CollaborationCodecError::Invalid(
224 "legacy import must contain a turn".to_string(),
225 ));
226 }
227 for turn in turns {
228 turn.validate()?;
229 }
230 validate_legacy_resolution(resolution)
231 }
232 }
233 }
234}
235
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237pub struct CollaborationOperationEnvelope {
238 pub metadata: Option<super::CollaborationMetadata>,
239 pub discussion_id: DiscussionRecordId,
240 pub parents: Vec<CollabOpId>,
241 pub idempotency_key: CollaborationIdempotencyKey,
242 pub author: Attribution,
243 pub occurred_at_ms: i64,
244 pub body: CollaborationOperationBodyV1,
245 #[serde(skip)]
248 pub(crate) canonical_body: CanonicalBody,
249}
250
251impl CollaborationOperationEnvelope {
252 pub fn new(
253 discussion_id: DiscussionRecordId,
254 mut parents: Vec<CollabOpId>,
255 idempotency_key: CollaborationIdempotencyKey,
256 author: Attribution,
257 occurred_at_ms: i64,
258 body: CollaborationOperationBodyV1,
259 ) -> Result<Self, CollaborationCodecError> {
260 parents.sort();
261 parents.dedup();
262 let operation = Self {
263 metadata: None,
264 discussion_id,
265 parents,
266 idempotency_key,
267 author,
268 occurred_at_ms,
269 body,
270 canonical_body: CanonicalBody::default(),
271 };
272 operation.validate()?;
273 Ok(operation)
274 }
275
276 pub fn with_metadata(
278 mut self,
279 metadata: super::CollaborationMetadata,
280 ) -> Result<Self, CollaborationCodecError> {
281 metadata.validate()?;
282 self.metadata = Some(metadata);
283 self.canonical_body.clear();
284 Ok(self)
285 }
286
287 pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
288 self.canonical_body.clear();
289 let bytes = super::codec::encode(self)?;
290 self.canonical_body.store(bytes.clone());
291 Ok(bytes)
292 }
293
294 pub fn id(&self) -> Result<CollabOpId, CollaborationCodecError> {
297 if let Some(bytes) = self.canonical_body.cloned() {
298 CanonicalBody::debug_matches(&bytes, || super::codec::encode(self));
299 return Ok(CollabOpId::for_bytes(&bytes));
300 }
301 Ok(CollabOpId::for_bytes(&self.encode()?))
302 }
303
304 pub fn decode(
305 bytes: &[u8],
306 ) -> Result<super::DecodedCollaborationOperation, CollaborationCodecError> {
307 super::codec::decode(bytes)
308 }
309
310 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
311 if let Some(metadata) = &self.metadata {
312 metadata.validate()?;
313 }
314 if self.parents.windows(2).any(|ids| ids[0] >= ids[1]) {
315 return Err(CollaborationCodecError::Invalid(
316 "parent operation ids must be sorted and unique".to_string(),
317 ));
318 }
319 if matches!(
320 self.body,
321 CollaborationOperationBodyV1::Open { .. }
322 | CollaborationOperationBodyV1::LegacyImported { .. }
323 ) {
324 if !self.parents.is_empty() {
325 return Err(CollaborationCodecError::Invalid(
326 "discussion root operation cannot have parents".to_string(),
327 ));
328 }
329 } else if self.parents.is_empty() {
330 return Err(CollaborationCodecError::Invalid(
331 "non-root collaboration operation requires a parent".to_string(),
332 ));
333 }
334 if let CollaborationOperationBodyV1::ResolveConflict { competing, .. } = &self.body
335 && competing.iter().any(|id| !self.parents.contains(id))
336 {
337 return Err(CollaborationCodecError::Invalid(
338 "conflict resolution must causally follow every competing operation".to_string(),
339 ));
340 }
341 self.body.validate()
342 }
343}
344
345pub(super) fn validate_anchor(anchor: &CollaborationAnchor) -> Result<(), CollaborationCodecError> {
346 match anchor {
347 CollaborationAnchor::Source { source } => source.validate(),
348 CollaborationAnchor::Path { path, .. } => require_text(path, "anchor path"),
349 CollaborationAnchor::Symbol { path, symbol, .. } => {
350 require_text(path, "anchor path")?;
351 require_text(symbol, "anchor symbol")
352 }
353 CollaborationAnchor::Repository
354 | CollaborationAnchor::State { .. }
355 | CollaborationAnchor::Change { .. } => Ok(()),
356 }
357}
358
359fn validate_resolution(value: &CollaborationResolution) -> Result<(), CollaborationCodecError> {
360 match value {
361 CollaborationResolution::Dismissed { reason } => require_text(reason, "dismiss reason"),
362 CollaborationResolution::IntoContext { context } => context.encode().map(|_| ()),
363 CollaborationResolution::IntoAnnotation { content, .. } => {
364 require_text(content, "annotation content")
365 }
366 CollaborationResolution::Annotation { annotation_id } => {
367 require_text(annotation_id, "annotation id")
368 }
369 _ => Ok(()),
370 }
371}
372
373fn validate_legacy_resolution(
374 value: &LegacyDiscussionResolutionV1,
375) -> Result<(), CollaborationCodecError> {
376 match value {
377 LegacyDiscussionResolutionV1::Dismissed { reason } => {
378 require_text(reason, "dismiss reason")
379 }
380 LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
381 require_text(annotation_id, "annotation id")
382 }
383 _ => Ok(()),
384 }
385}
386
387fn require_text(value: &str, field: &str) -> Result<(), CollaborationCodecError> {
388 if value.trim().is_empty() {
389 Err(CollaborationCodecError::Invalid(format!(
390 "{field} must not be empty"
391 )))
392 } else {
393 Ok(())
394 }
395}