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 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 },
118 AppendTurn {
119 turn: DiscussionTurnV1,
120 },
121 RebindAnchor {
122 anchor: CollaborationAnchor,
123 status: CollaborationAnchorStatus,
124 body_changed_since_open: bool,
125 },
126 Resolve {
127 resolution: CollaborationResolution,
128 },
129 Reopen {
130 reason: String,
131 },
132 ResolveConflict {
133 competing: Vec<CollabOpId>,
134 selected: CollabOpId,
135 },
136 LegacyImported {
137 source: LegacySourceLocator,
138 legacy_discussion_id: LegacyDiscussionId,
139 aliases: Vec<LegacySourceLocator>,
140 title: String,
141 anchor: CollaborationAnchor,
142 visibility: VisibilityTier,
143 turns: Vec<DiscussionTurnV1>,
144 resolution: LegacyDiscussionResolutionV1,
145 },
146}
147
148impl CollaborationOperationBodyV1 {
149 pub fn kind_name(&self) -> &'static str {
150 match self {
151 Self::Open { .. } => "open",
152 Self::AppendTurn { .. } => "append_turn",
153 Self::RebindAnchor { .. } => "rebind_anchor",
154 Self::Resolve { .. } => "resolve",
155 Self::Reopen { .. } => "reopen",
156 Self::ResolveConflict { .. } => "resolve_conflict",
157 Self::LegacyImported { .. } => "legacy_imported",
158 }
159 }
160
161 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
162 match self {
163 Self::Open {
164 title,
165 anchor,
166 turn,
167 thread_ref,
168 ..
169 } => {
170 require_text(title, "discussion title")?;
171 validate_anchor(anchor)?;
172 if let Some(thread_ref) = thread_ref {
173 require_text(thread_ref, "discussion thread ref")?;
174 }
175 turn.validate()
176 }
177 Self::AppendTurn { turn } => turn.validate(),
178 Self::RebindAnchor { anchor, .. } => validate_anchor(anchor),
179 Self::Resolve { resolution } => validate_resolution(resolution),
180 Self::Reopen { reason } => require_text(reason, "reopen reason"),
181 Self::ResolveConflict {
182 competing,
183 selected,
184 } => {
185 if competing.len() < 2 || !competing.contains(selected) {
186 return Err(CollaborationCodecError::Invalid(
187 "conflict resolution must select one of at least two competing operations"
188 .to_string(),
189 ));
190 }
191 if competing.windows(2).any(|ids| ids[0] >= ids[1]) {
192 return Err(CollaborationCodecError::Invalid(
193 "competing operation ids must be sorted and unique".to_string(),
194 ));
195 }
196 Ok(())
197 }
198 Self::LegacyImported {
199 title,
200 anchor,
201 aliases,
202 turns,
203 resolution,
204 ..
205 } => {
206 require_text(title, "discussion title")?;
207 validate_anchor(anchor)?;
208 if aliases.windows(2).any(|values| values[0] >= values[1]) {
209 return Err(CollaborationCodecError::Invalid(
210 "legacy aliases must be sorted and unique".to_string(),
211 ));
212 }
213 if turns.is_empty() {
214 return Err(CollaborationCodecError::Invalid(
215 "legacy import must contain a turn".to_string(),
216 ));
217 }
218 for turn in turns {
219 turn.validate()?;
220 }
221 validate_legacy_resolution(resolution)
222 }
223 }
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub struct CollaborationOperationEnvelope {
229 pub metadata: Option<super::CollaborationMetadata>,
230 pub discussion_id: DiscussionRecordId,
231 pub parents: Vec<CollabOpId>,
232 pub idempotency_key: CollaborationIdempotencyKey,
233 pub author: Attribution,
234 pub occurred_at_ms: i64,
235 pub body: CollaborationOperationBodyV1,
236}
237
238impl CollaborationOperationEnvelope {
239 pub fn new(
240 discussion_id: DiscussionRecordId,
241 mut parents: Vec<CollabOpId>,
242 idempotency_key: CollaborationIdempotencyKey,
243 author: Attribution,
244 occurred_at_ms: i64,
245 body: CollaborationOperationBodyV1,
246 ) -> Result<Self, CollaborationCodecError> {
247 parents.sort();
248 parents.dedup();
249 let operation = Self {
250 metadata: None,
251 discussion_id,
252 parents,
253 idempotency_key,
254 author,
255 occurred_at_ms,
256 body,
257 };
258 operation.validate()?;
259 Ok(operation)
260 }
261
262 pub fn with_metadata(
264 mut self,
265 metadata: super::CollaborationMetadata,
266 ) -> Result<Self, CollaborationCodecError> {
267 metadata.validate()?;
268 self.metadata = Some(metadata);
269 Ok(self)
270 }
271
272 pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
273 super::codec::encode(self)
274 }
275
276 pub fn decode(
277 bytes: &[u8],
278 ) -> Result<super::DecodedCollaborationOperation, CollaborationCodecError> {
279 super::codec::decode(bytes)
280 }
281
282 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
283 if let Some(metadata) = &self.metadata {
284 metadata.validate()?;
285 }
286 if self.parents.windows(2).any(|ids| ids[0] >= ids[1]) {
287 return Err(CollaborationCodecError::Invalid(
288 "parent operation ids must be sorted and unique".to_string(),
289 ));
290 }
291 if matches!(
292 self.body,
293 CollaborationOperationBodyV1::Open { .. }
294 | CollaborationOperationBodyV1::LegacyImported { .. }
295 ) {
296 if !self.parents.is_empty() {
297 return Err(CollaborationCodecError::Invalid(
298 "discussion root operation cannot have parents".to_string(),
299 ));
300 }
301 } else if self.parents.is_empty() {
302 return Err(CollaborationCodecError::Invalid(
303 "non-root collaboration operation requires a parent".to_string(),
304 ));
305 }
306 if let CollaborationOperationBodyV1::ResolveConflict { competing, .. } = &self.body
307 && competing.iter().any(|id| !self.parents.contains(id))
308 {
309 return Err(CollaborationCodecError::Invalid(
310 "conflict resolution must causally follow every competing operation".to_string(),
311 ));
312 }
313 self.body.validate()
314 }
315}
316
317pub(super) fn validate_anchor(anchor: &CollaborationAnchor) -> Result<(), CollaborationCodecError> {
318 match anchor {
319 CollaborationAnchor::Source { source } => source.validate(),
320 CollaborationAnchor::Path { path, .. } => require_text(path, "anchor path"),
321 CollaborationAnchor::Symbol { path, symbol, .. } => {
322 require_text(path, "anchor path")?;
323 require_text(symbol, "anchor symbol")
324 }
325 CollaborationAnchor::Repository
326 | CollaborationAnchor::State { .. }
327 | CollaborationAnchor::Change { .. } => Ok(()),
328 }
329}
330
331fn validate_resolution(value: &CollaborationResolution) -> Result<(), CollaborationCodecError> {
332 match value {
333 CollaborationResolution::Dismissed { reason } => require_text(reason, "dismiss reason"),
334 CollaborationResolution::IntoContext { context } => context.encode().map(|_| ()),
335 CollaborationResolution::IntoAnnotation { content, .. } => {
336 require_text(content, "annotation content")
337 }
338 CollaborationResolution::Annotation { annotation_id } => {
339 require_text(annotation_id, "annotation id")
340 }
341 _ => Ok(()),
342 }
343}
344
345fn validate_legacy_resolution(
346 value: &LegacyDiscussionResolutionV1,
347) -> Result<(), CollaborationCodecError> {
348 match value {
349 LegacyDiscussionResolutionV1::Dismissed { reason } => {
350 require_text(reason, "dismiss reason")
351 }
352 LegacyDiscussionResolutionV1::Annotation { annotation_id } => {
353 require_text(annotation_id, "annotation id")
354 }
355 _ => Ok(()),
356 }
357}
358
359fn require_text(value: &str, field: &str) -> Result<(), CollaborationCodecError> {
360 if value.trim().is_empty() {
361 Err(CollaborationCodecError::Invalid(format!(
362 "{field} must not be empty"
363 )))
364 } else {
365 Ok(())
366 }
367}