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