1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::model::types::{EpochId, GitOid, WorkspaceId};
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Operation {
32 pub parent_ids: Vec<GitOid>,
34
35 pub workspace_id: WorkspaceId,
37
38 pub timestamp: String,
43
44 pub payload: OpPayload,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum OpPayload {
60 Create {
62 epoch: EpochId,
64 },
65
66 Destroy,
68
69 Snapshot {
72 patch_set_oid: GitOid,
74 },
75
76 Merge {
78 sources: Vec<WorkspaceId>,
80 epoch_before: EpochId,
82 epoch_after: EpochId,
84 },
85
86 Compensate {
88 target_op: GitOid,
90 reason: String,
92 },
93
94 Describe {
96 message: String,
98 },
99
100 Annotate {
103 key: String,
105 data: BTreeMap<String, serde_json::Value>,
109 },
110}
111
112impl Operation {
117 pub fn to_canonical_json(&self) -> Result<Vec<u8>, serde_json::Error> {
125 serde_json::to_vec(self)
129 }
130
131 pub fn from_json(bytes: &[u8]) -> Result<Self, serde_json::Error> {
136 serde_json::from_slice(bytes)
137 }
138}
139
140#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn oid(c: char) -> String {
150 c.to_string().repeat(40)
151 }
152
153 fn git_oid(c: char) -> GitOid {
154 GitOid::new(&oid(c)).unwrap()
155 }
156
157 fn epoch(c: char) -> EpochId {
158 EpochId::new(&oid(c)).unwrap()
159 }
160
161 fn ws(name: &str) -> WorkspaceId {
162 WorkspaceId::new(name).unwrap()
163 }
164
165 fn timestamp() -> String {
166 "2026-02-19T12:00:00Z".to_owned()
167 }
168
169 #[test]
174 fn create_round_trip() {
175 let op = Operation {
176 parent_ids: vec![],
177 workspace_id: ws("agent-1"),
178 timestamp: timestamp(),
179 payload: OpPayload::Create { epoch: epoch('a') },
180 };
181 let json = op.to_canonical_json().unwrap();
182 let parsed = Operation::from_json(&json).unwrap();
183 assert_eq!(op, parsed);
184 }
185
186 #[test]
187 fn destroy_round_trip() {
188 let op = Operation {
189 parent_ids: vec![git_oid('b')],
190 workspace_id: ws("agent-2"),
191 timestamp: timestamp(),
192 payload: OpPayload::Destroy,
193 };
194 let json = op.to_canonical_json().unwrap();
195 let parsed = Operation::from_json(&json).unwrap();
196 assert_eq!(op, parsed);
197 }
198
199 #[test]
200 fn snapshot_round_trip() {
201 let op = Operation {
202 parent_ids: vec![git_oid('c')],
203 workspace_id: ws("feature-x"),
204 timestamp: timestamp(),
205 payload: OpPayload::Snapshot {
206 patch_set_oid: git_oid('d'),
207 },
208 };
209 let json = op.to_canonical_json().unwrap();
210 let parsed = Operation::from_json(&json).unwrap();
211 assert_eq!(op, parsed);
212 }
213
214 #[test]
215 fn merge_round_trip() {
216 let op = Operation {
217 parent_ids: vec![git_oid('e'), git_oid('f')],
218 workspace_id: ws("default"),
219 timestamp: timestamp(),
220 payload: OpPayload::Merge {
221 sources: vec![ws("agent-1"), ws("agent-2")],
222 epoch_before: epoch('a'),
223 epoch_after: epoch('b'),
224 },
225 };
226 let json = op.to_canonical_json().unwrap();
227 let parsed = Operation::from_json(&json).unwrap();
228 assert_eq!(op, parsed);
229 }
230
231 #[test]
232 fn compensate_round_trip() {
233 let op = Operation {
234 parent_ids: vec![git_oid('c')],
235 workspace_id: ws("agent-1"),
236 timestamp: timestamp(),
237 payload: OpPayload::Compensate {
238 target_op: git_oid('a'),
239 reason: "reverted broken snapshot".to_owned(),
240 },
241 };
242 let json = op.to_canonical_json().unwrap();
243 let parsed = Operation::from_json(&json).unwrap();
244 assert_eq!(op, parsed);
245 }
246
247 #[test]
248 fn describe_round_trip() {
249 let op = Operation {
250 parent_ids: vec![git_oid('d')],
251 workspace_id: ws("agent-1"),
252 timestamp: timestamp(),
253 payload: OpPayload::Describe {
254 message: "implementing auth module".to_owned(),
255 },
256 };
257 let json = op.to_canonical_json().unwrap();
258 let parsed = Operation::from_json(&json).unwrap();
259 assert_eq!(op, parsed);
260 }
261
262 #[test]
263 fn annotate_round_trip() {
264 let mut data = BTreeMap::new();
265 data.insert("passed".to_owned(), serde_json::Value::Bool(true));
266 data.insert(
267 "duration_ms".to_owned(),
268 serde_json::Value::Number(1234.into()),
269 );
270 data.insert(
271 "command".to_owned(),
272 serde_json::Value::String("cargo test".to_owned()),
273 );
274
275 let op = Operation {
276 parent_ids: vec![git_oid('e')],
277 workspace_id: ws("default"),
278 timestamp: timestamp(),
279 payload: OpPayload::Annotate {
280 key: "validation".to_owned(),
281 data,
282 },
283 };
284 let json = op.to_canonical_json().unwrap();
285 let parsed = Operation::from_json(&json).unwrap();
286 assert_eq!(op, parsed);
287 }
288
289 #[test]
294 fn canonical_json_is_deterministic() {
295 let mut data = BTreeMap::new();
296 data.insert("z_key".to_owned(), serde_json::Value::Bool(false));
297 data.insert("a_key".to_owned(), serde_json::Value::Bool(true));
298 data.insert(
299 "m_key".to_owned(),
300 serde_json::Value::String("hello".to_owned()),
301 );
302
303 let op = Operation {
304 parent_ids: vec![git_oid('a'), git_oid('b')],
305 workspace_id: ws("agent-1"),
306 timestamp: timestamp(),
307 payload: OpPayload::Annotate {
308 key: "test".to_owned(),
309 data,
310 },
311 };
312
313 let json1 = op.to_canonical_json().unwrap();
314 let json2 = op.to_canonical_json().unwrap();
315 assert_eq!(json1, json2, "canonical JSON must be deterministic");
316
317 let json_str = String::from_utf8(json1).unwrap();
319 let a_pos = json_str.find("\"a_key\"").unwrap();
320 let m_pos = json_str.find("\"m_key\"").unwrap();
321 let z_pos = json_str.find("\"z_key\"").unwrap();
322 assert!(a_pos < m_pos, "a_key should come before m_key");
323 assert!(m_pos < z_pos, "m_key should come before z_key");
324 }
325
326 #[test]
327 fn canonical_json_sorted_keys_in_annotate() {
328 let mut data = BTreeMap::new();
329 data.insert("zebra".to_owned(), serde_json::Value::Null);
330 data.insert("apple".to_owned(), serde_json::Value::Null);
331
332 let op = Operation {
333 parent_ids: vec![],
334 workspace_id: ws("w"),
335 timestamp: timestamp(),
336 payload: OpPayload::Annotate {
337 key: "test".to_owned(),
338 data,
339 },
340 };
341
342 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
343 let apple_pos = json.find("\"apple\"").unwrap();
344 let zebra_pos = json.find("\"zebra\"").unwrap();
345 assert!(
346 apple_pos < zebra_pos,
347 "BTreeMap keys must be sorted: apple < zebra"
348 );
349 }
350
351 #[test]
356 fn payload_type_tag_create() {
357 let op = Operation {
358 parent_ids: vec![],
359 workspace_id: ws("w"),
360 timestamp: timestamp(),
361 payload: OpPayload::Create { epoch: epoch('a') },
362 };
363 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
364 assert!(
365 json.contains("\"type\":\"create\""),
366 "Create variant should have type:create tag"
367 );
368 }
369
370 #[test]
371 fn payload_type_tag_destroy() {
372 let op = Operation {
373 parent_ids: vec![],
374 workspace_id: ws("w"),
375 timestamp: timestamp(),
376 payload: OpPayload::Destroy,
377 };
378 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
379 assert!(
380 json.contains("\"type\":\"destroy\""),
381 "Destroy variant should have type:destroy tag"
382 );
383 }
384
385 #[test]
386 fn payload_type_tag_snapshot() {
387 let op = Operation {
388 parent_ids: vec![],
389 workspace_id: ws("w"),
390 timestamp: timestamp(),
391 payload: OpPayload::Snapshot {
392 patch_set_oid: git_oid('a'),
393 },
394 };
395 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
396 assert!(
397 json.contains("\"type\":\"snapshot\""),
398 "Snapshot variant should have type:snapshot tag"
399 );
400 }
401
402 #[test]
403 fn payload_type_tag_merge() {
404 let op = Operation {
405 parent_ids: vec![],
406 workspace_id: ws("w"),
407 timestamp: timestamp(),
408 payload: OpPayload::Merge {
409 sources: vec![],
410 epoch_before: epoch('a'),
411 epoch_after: epoch('b'),
412 },
413 };
414 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
415 assert!(
416 json.contains("\"type\":\"merge\""),
417 "Merge variant should have type:merge tag"
418 );
419 }
420
421 #[test]
422 fn payload_type_tag_compensate() {
423 let op = Operation {
424 parent_ids: vec![],
425 workspace_id: ws("w"),
426 timestamp: timestamp(),
427 payload: OpPayload::Compensate {
428 target_op: git_oid('a'),
429 reason: "test".to_owned(),
430 },
431 };
432 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
433 assert!(
434 json.contains("\"type\":\"compensate\""),
435 "Compensate variant should have type:compensate tag"
436 );
437 }
438
439 #[test]
440 fn payload_type_tag_describe() {
441 let op = Operation {
442 parent_ids: vec![],
443 workspace_id: ws("w"),
444 timestamp: timestamp(),
445 payload: OpPayload::Describe {
446 message: "hello".to_owned(),
447 },
448 };
449 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
450 assert!(
451 json.contains("\"type\":\"describe\""),
452 "Describe variant should have type:describe tag"
453 );
454 }
455
456 #[test]
457 fn payload_type_tag_annotate() {
458 let op = Operation {
459 parent_ids: vec![],
460 workspace_id: ws("w"),
461 timestamp: timestamp(),
462 payload: OpPayload::Annotate {
463 key: "k".to_owned(),
464 data: BTreeMap::new(),
465 },
466 };
467 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
468 assert!(
469 json.contains("\"type\":\"annotate\""),
470 "Annotate variant should have type:annotate tag"
471 );
472 }
473
474 #[test]
479 fn empty_parent_ids() {
480 let op = Operation {
481 parent_ids: vec![],
482 workspace_id: ws("first"),
483 timestamp: timestamp(),
484 payload: OpPayload::Create { epoch: epoch('a') },
485 };
486 let json = String::from_utf8(op.to_canonical_json().unwrap()).unwrap();
487 assert!(json.contains("\"parent_ids\":[]"));
488 }
489
490 #[test]
491 fn multiple_parent_ids() {
492 let op = Operation {
493 parent_ids: vec![git_oid('a'), git_oid('b'), git_oid('c')],
494 workspace_id: ws("merged"),
495 timestamp: timestamp(),
496 payload: OpPayload::Merge {
497 sources: vec![ws("w1"), ws("w2"), ws("w3")],
498 epoch_before: epoch('d'),
499 epoch_after: epoch('e'),
500 },
501 };
502 let json = op.to_canonical_json().unwrap();
503 let parsed = Operation::from_json(&json).unwrap();
504 assert_eq!(parsed.parent_ids.len(), 3);
505 assert_eq!(parsed.payload, op.payload);
506 }
507
508 #[test]
509 fn describe_with_newlines_and_unicode() {
510 let op = Operation {
511 parent_ids: vec![],
512 workspace_id: ws("w"),
513 timestamp: timestamp(),
514 payload: OpPayload::Describe {
515 message: "line 1\nline 2\n日本語".to_owned(),
516 },
517 };
518 let json = op.to_canonical_json().unwrap();
519 let parsed = Operation::from_json(&json).unwrap();
520 assert_eq!(op, parsed);
521 }
522}