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