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