1use crate::event::data::{
24 AssignAction, AssignData, CreateData, DeleteData, EventData, LinkData, MoveData, UnlinkData,
25 UpdateData,
26};
27use crate::event::{Event, EventType};
28use crate::model::item::State;
29use crate::model::item_id::ItemId;
30use std::collections::BTreeMap;
31use std::fmt;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum UndoError {
40 GrowOnly(EventType),
42 NoPriorState(String),
44}
45
46impl fmt::Display for UndoError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::GrowOnly(et) => write!(
50 f,
51 "cannot undo {et}: this event type is grow-only and permanently recorded"
52 ),
53 Self::NoPriorState(msg) => write!(f, "cannot undo: {msg}"),
54 }
55 }
56}
57
58impl std::error::Error for UndoError {}
59
60pub fn compensating_event(
84 original: &Event,
85 prior_events: &[&Event],
86 current_agent: &str,
87 now: i64,
88) -> Result<Event, UndoError> {
89 let item_id = original.item_id.clone();
90 let parents = vec![original.event_hash.clone()];
91
92 let (event_type, data) = match &original.data {
93 EventData::Create(_) => (
95 EventType::Delete,
96 EventData::Delete(DeleteData {
97 reason: Some(format!(
98 "undo create (compensating for {})",
99 original.event_hash
100 )),
101 extra: BTreeMap::new(),
102 }),
103 ),
104
105 EventData::Update(d) => {
107 let prev = find_previous_field_value(prior_events, &d.field).ok_or_else(|| {
108 UndoError::NoPriorState(format!(
109 "no prior value for field '{}' found in event history",
110 d.field
111 ))
112 })?;
113 (
114 EventType::Update,
115 EventData::Update(UpdateData {
116 field: d.field.clone(),
117 value: prev,
118 extra: BTreeMap::new(),
119 }),
120 )
121 }
122
123 EventData::Move(d) => {
125 let prior_state = find_previous_state(prior_events).unwrap_or(State::Open);
126 (
127 EventType::Move,
128 EventData::Move(MoveData {
129 state: prior_state,
130 reason: Some(format!(
131 "undo move from {} (compensating for {})",
132 d.state, original.event_hash
133 )),
134 extra: BTreeMap::new(),
135 }),
136 )
137 }
138
139 EventData::Assign(d) => {
141 let inverse = match d.action {
142 AssignAction::Assign => AssignAction::Unassign,
143 AssignAction::Unassign => AssignAction::Assign,
144 };
145 (
146 EventType::Assign,
147 EventData::Assign(AssignData {
148 agent: d.agent.clone(),
149 action: inverse,
150 extra: BTreeMap::new(),
151 }),
152 )
153 }
154
155 EventData::Link(d) => (
157 EventType::Unlink,
158 EventData::Unlink(UnlinkData {
159 target: d.target.clone(),
160 link_type: Some(d.link_type.clone()),
161 extra: BTreeMap::new(),
162 }),
163 ),
164
165 EventData::Unlink(d) => (
167 EventType::Link,
168 EventData::Link(LinkData {
169 target: d.target.clone(),
170 link_type: d
171 .link_type
172 .clone()
173 .unwrap_or_else(|| "related_to".to_string()),
174 extra: BTreeMap::new(),
175 }),
176 ),
177
178 EventData::Delete(_) => {
180 let create_data = build_create_from_history(prior_events).ok_or_else(|| {
181 UndoError::NoPriorState(
182 "no prior item.create event found to reconstruct item for undelete".to_string(),
183 )
184 })?;
185 (EventType::Create, EventData::Create(create_data))
186 }
187
188 EventData::Comment(_) => return Err(UndoError::GrowOnly(EventType::Comment)),
190 EventData::Compact(_) => return Err(UndoError::GrowOnly(EventType::Compact)),
191 EventData::Snapshot(_) => return Err(UndoError::GrowOnly(EventType::Snapshot)),
192 EventData::Redact(_) => return Err(UndoError::GrowOnly(EventType::Redact)),
193 };
194
195 Ok(Event {
196 wall_ts_us: now,
197 agent: current_agent.to_string(),
198 itc: "itc:AQ".to_string(),
199 parents,
200 event_type,
201 item_id: ItemId::new_unchecked(item_id.as_str()),
202 data,
203 event_hash: String::new(), })
205}
206
207fn find_previous_state(prior_events: &[&Event]) -> Option<State> {
217 for event in prior_events.iter().rev() {
218 match &event.data {
219 EventData::Move(d) => return Some(d.state),
220 EventData::Create(_) => return Some(State::Open),
221 _ => {}
222 }
223 }
224 None
225}
226
227fn find_previous_field_value(prior_events: &[&Event], field: &str) -> Option<serde_json::Value> {
232 for event in prior_events.iter().rev() {
233 match &event.data {
234 EventData::Update(d) if d.field == field => return Some(d.value.clone()),
235 EventData::Create(d) => return initial_create_field_value(d, field),
236 _ => {}
237 }
238 }
239 None
240}
241
242fn initial_create_field_value(create: &CreateData, field: &str) -> Option<serde_json::Value> {
244 match field {
245 "title" => Some(serde_json::Value::String(create.title.clone())),
246 "description" => create
247 .description
248 .as_ref()
249 .map(|d| serde_json::Value::String(d.clone())),
250 "size" => create
251 .size
252 .map(|s| serde_json::to_value(s).unwrap_or(serde_json::Value::Null)),
253 "urgency" => serde_json::to_value(create.urgency).ok(),
254 "labels" => Some(serde_json::Value::Array(
255 create
256 .labels
257 .iter()
258 .map(|l| serde_json::Value::String(l.clone()))
259 .collect(),
260 )),
261 "kind" => serde_json::to_value(create.kind).ok(),
262 _ => None,
263 }
264}
265
266fn build_create_from_history(prior_events: &[&Event]) -> Option<CreateData> {
271 let create_idx = prior_events
273 .iter()
274 .position(|e| matches!(e.data, EventData::Create(_)))?;
275
276 let mut create_data = match &prior_events[create_idx].data {
277 EventData::Create(d) => d.clone(),
278 _ => unreachable!(),
279 };
280
281 for event in &prior_events[create_idx + 1..] {
283 if let EventData::Update(u) = &event.data {
284 apply_update_to_create(&mut create_data, &u.field, &u.value);
285 }
286 }
287
288 Some(create_data)
289}
290
291fn apply_update_to_create(create: &mut CreateData, field: &str, value: &serde_json::Value) {
293 match field {
294 "title" => {
295 if let Some(s) = value.as_str() {
296 create.title = s.to_string();
297 }
298 }
299 "description" => {
300 create.description = value.as_str().map(String::from);
301 }
302 "labels" => {
303 if let Some(arr) = value.as_array() {
304 create.labels = arr
305 .iter()
306 .filter_map(|v| v.as_str().map(String::from))
307 .collect();
308 }
309 }
310 "size" => {
311 create.size = serde_json::from_value(value.clone()).ok();
312 }
313 "urgency" => {
314 if let Ok(u) = serde_json::from_value(value.clone()) {
315 create.urgency = u;
316 }
317 }
318 "kind" => {
319 if let Ok(k) = serde_json::from_value(value.clone()) {
320 create.kind = k;
321 }
322 }
323 _ => {}
324 }
325}
326
327#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::event::data::{CommentData, CreateData, MoveData};
335 use crate::model::item::{Kind, State, Urgency};
336
337 fn make_event(event_type: EventType, data: EventData, hash: &str) -> Event {
338 Event {
339 wall_ts_us: 1_000_000,
340 agent: "test-agent".into(),
341 itc: "itc:AQ".into(),
342 parents: vec![],
343 event_type,
344 item_id: ItemId::new_unchecked("bn-test"),
345 data,
346 event_hash: hash.to_string(),
347 }
348 }
349
350 fn minimal_create() -> Event {
351 make_event(
352 EventType::Create,
353 EventData::Create(CreateData {
354 title: "Test item".into(),
355 kind: Kind::Task,
356 size: None,
357 urgency: Urgency::Default,
358 labels: vec![],
359 parent: None,
360 causation: None,
361 description: None,
362 extra: BTreeMap::new(),
363 }),
364 "blake3:create001",
365 )
366 }
367
368 #[test]
369 fn undo_create_emits_delete() {
370 let create_event = minimal_create();
371 let result = compensating_event(&create_event, &[], "agent", 2_000_000);
372 assert!(result.is_ok());
373 let comp = result.unwrap();
374 assert_eq!(comp.event_type, EventType::Delete);
375 assert!(matches!(comp.data, EventData::Delete(_)));
376 assert_eq!(comp.parents, vec!["blake3:create001"]);
377 assert_eq!(comp.agent, "agent");
378 }
379
380 #[test]
381 fn undo_assign_flips_to_unassign() {
382 let assign_event = make_event(
383 EventType::Assign,
384 EventData::Assign(AssignData {
385 agent: "alice".into(),
386 action: AssignAction::Assign,
387 extra: BTreeMap::new(),
388 }),
389 "blake3:assign001",
390 );
391 let result = compensating_event(&assign_event, &[], "undoer", 2_000_000);
392 assert!(result.is_ok());
393 let comp = result.unwrap();
394 assert_eq!(comp.event_type, EventType::Assign);
395 if let EventData::Assign(d) = &comp.data {
396 assert_eq!(d.agent, "alice");
397 assert_eq!(d.action, AssignAction::Unassign);
398 } else {
399 panic!("expected Assign data");
400 }
401 }
402
403 #[test]
404 fn undo_unassign_flips_to_assign() {
405 let unassign_event = make_event(
406 EventType::Assign,
407 EventData::Assign(AssignData {
408 agent: "bob".into(),
409 action: AssignAction::Unassign,
410 extra: BTreeMap::new(),
411 }),
412 "blake3:unassign001",
413 );
414 let result = compensating_event(&unassign_event, &[], "undoer", 2_000_000);
415 assert!(result.is_ok());
416 let comp = result.unwrap();
417 if let EventData::Assign(d) = &comp.data {
418 assert_eq!(d.action, AssignAction::Assign);
419 } else {
420 panic!("expected Assign data");
421 }
422 }
423
424 #[test]
425 fn undo_link_emits_unlink() {
426 let link_event = make_event(
427 EventType::Link,
428 EventData::Link(LinkData {
429 target: "bn-other".into(),
430 link_type: "blocks".into(),
431 extra: BTreeMap::new(),
432 }),
433 "blake3:link001",
434 );
435 let result = compensating_event(&link_event, &[], "undoer", 2_000_000);
436 assert!(result.is_ok());
437 let comp = result.unwrap();
438 assert_eq!(comp.event_type, EventType::Unlink);
439 if let EventData::Unlink(d) = &comp.data {
440 assert_eq!(d.target, "bn-other");
441 assert_eq!(d.link_type.as_deref(), Some("blocks"));
442 } else {
443 panic!("expected Unlink data");
444 }
445 }
446
447 #[test]
448 fn undo_unlink_emits_link() {
449 let unlink_event = make_event(
450 EventType::Unlink,
451 EventData::Unlink(UnlinkData {
452 target: "bn-other".into(),
453 link_type: Some("blocks".into()),
454 extra: BTreeMap::new(),
455 }),
456 "blake3:unlink001",
457 );
458 let result = compensating_event(&unlink_event, &[], "undoer", 2_000_000);
459 assert!(result.is_ok());
460 let comp = result.unwrap();
461 assert_eq!(comp.event_type, EventType::Link);
462 if let EventData::Link(d) = &comp.data {
463 assert_eq!(d.target, "bn-other");
464 assert_eq!(d.link_type, "blocks");
465 } else {
466 panic!("expected Link data");
467 }
468 }
469
470 #[test]
471 fn undo_move_returns_to_prior_state() {
472 let create_event = minimal_create();
473 let move_to_doing = make_event(
474 EventType::Move,
475 EventData::Move(MoveData {
476 state: State::Doing,
477 reason: None,
478 extra: BTreeMap::new(),
479 }),
480 "blake3:move001",
481 );
482 let prior = vec![&create_event];
484 let result = compensating_event(&move_to_doing, &prior, "undoer", 2_000_000);
485 assert!(result.is_ok());
486 let comp = result.unwrap();
487 assert_eq!(comp.event_type, EventType::Move);
488 if let EventData::Move(d) = &comp.data {
489 assert_eq!(d.state, State::Open); } else {
491 panic!("expected Move data");
492 }
493 }
494
495 #[test]
496 fn undo_move_falls_back_to_open_with_no_prior() {
497 let move_event = make_event(
498 EventType::Move,
499 EventData::Move(MoveData {
500 state: State::Done,
501 reason: None,
502 extra: BTreeMap::new(),
503 }),
504 "blake3:move002",
505 );
506 let result = compensating_event(&move_event, &[], "undoer", 2_000_000);
507 assert!(result.is_ok());
508 let comp = result.unwrap();
509 if let EventData::Move(d) = &comp.data {
510 assert_eq!(d.state, State::Open);
511 } else {
512 panic!("expected Move data");
513 }
514 }
515
516 #[test]
517 fn undo_update_finds_prior_value() {
518 let create_event = minimal_create();
519 let update_event = make_event(
520 EventType::Update,
521 EventData::Update(UpdateData {
522 field: "title".into(),
523 value: serde_json::Value::String("New title".into()),
524 extra: BTreeMap::new(),
525 }),
526 "blake3:update001",
527 );
528 let prior = vec![&create_event];
529 let result = compensating_event(&update_event, &prior, "undoer", 2_000_000);
530 assert!(result.is_ok());
531 let comp = result.unwrap();
532 if let EventData::Update(d) = &comp.data {
533 assert_eq!(d.field, "title");
534 assert_eq!(d.value, serde_json::Value::String("Test item".into()));
535 } else {
536 panic!("expected Update data");
537 }
538 }
539
540 #[test]
541 fn undo_update_no_prior_returns_error() {
542 let update_event = make_event(
543 EventType::Update,
544 EventData::Update(UpdateData {
545 field: "title".into(),
546 value: serde_json::Value::String("New".into()),
547 extra: BTreeMap::new(),
548 }),
549 "blake3:update002",
550 );
551 let result = compensating_event(&update_event, &[], "undoer", 2_000_000);
552 assert!(result.is_err());
553 assert!(matches!(result.unwrap_err(), UndoError::NoPriorState(_)));
554 }
555
556 #[test]
557 fn undo_delete_reconstructs_create() {
558 let create_event = minimal_create();
559 let delete_event = make_event(
560 EventType::Delete,
561 EventData::Delete(DeleteData {
562 reason: Some("accident".into()),
563 extra: BTreeMap::new(),
564 }),
565 "blake3:delete001",
566 );
567 let prior = vec![&create_event];
568 let result = compensating_event(&delete_event, &prior, "undoer", 2_000_000);
569 assert!(result.is_ok());
570 let comp = result.unwrap();
571 assert_eq!(comp.event_type, EventType::Create);
572 if let EventData::Create(d) = &comp.data {
573 assert_eq!(d.title, "Test item");
574 } else {
575 panic!("expected Create data");
576 }
577 }
578
579 #[test]
580 fn undo_delete_no_prior_create_returns_error() {
581 let delete_event = make_event(
582 EventType::Delete,
583 EventData::Delete(DeleteData {
584 reason: None,
585 extra: BTreeMap::new(),
586 }),
587 "blake3:delete002",
588 );
589 let result = compensating_event(&delete_event, &[], "undoer", 2_000_000);
590 assert!(result.is_err());
591 assert!(matches!(result.unwrap_err(), UndoError::NoPriorState(_)));
592 }
593
594 #[test]
595 fn undo_comment_is_grow_only() {
596 let comment_event = make_event(
597 EventType::Comment,
598 EventData::Comment(CommentData {
599 body: "A comment".into(),
600 extra: BTreeMap::new(),
601 }),
602 "blake3:comment001",
603 );
604 let result = compensating_event(&comment_event, &[], "undoer", 2_000_000);
605 assert!(result.is_err());
606 assert!(matches!(
607 result.unwrap_err(),
608 UndoError::GrowOnly(EventType::Comment)
609 ));
610 }
611
612 #[test]
613 fn undo_redact_is_grow_only() {
614 let redact_event = make_event(
615 EventType::Redact,
616 EventData::Redact(crate::event::data::RedactData {
617 target_hash: "blake3:xyz".into(),
618 reason: "test".into(),
619 extra: BTreeMap::new(),
620 }),
621 "blake3:redact001",
622 );
623 let result = compensating_event(&redact_event, &[], "undoer", 2_000_000);
624 assert!(result.is_err());
625 assert!(matches!(
626 result.unwrap_err(),
627 UndoError::GrowOnly(EventType::Redact)
628 ));
629 }
630
631 #[test]
632 fn undo_snapshot_is_grow_only() {
633 let snap_event = make_event(
634 EventType::Snapshot,
635 EventData::Snapshot(crate::event::data::SnapshotData {
636 state: serde_json::json!({}),
637 extra: BTreeMap::new(),
638 }),
639 "blake3:snap001",
640 );
641 let result = compensating_event(&snap_event, &[], "undoer", 2_000_000);
642 assert!(result.is_err());
643 assert!(matches!(
644 result.unwrap_err(),
645 UndoError::GrowOnly(EventType::Snapshot)
646 ));
647 }
648
649 #[test]
650 fn compensating_event_references_original_in_parents() {
651 let create_event = minimal_create();
652 let comp = compensating_event(&create_event, &[], "undoer", 2_000_000).unwrap();
653 assert_eq!(comp.parents, vec!["blake3:create001"]);
654 }
655
656 #[test]
657 fn compensating_event_uses_current_agent_and_timestamp() {
658 let create_event = minimal_create();
659 let comp = compensating_event(&create_event, &[], "new-agent", 9_999_999).unwrap();
660 assert_eq!(comp.agent, "new-agent");
661 assert_eq!(comp.wall_ts_us, 9_999_999);
662 }
663
664 #[test]
665 fn undo_update_uses_most_recent_prior_value() {
666 let create_event = minimal_create();
667 let update1 = make_event(
668 EventType::Update,
669 EventData::Update(UpdateData {
670 field: "title".into(),
671 value: serde_json::Value::String("Second title".into()),
672 extra: BTreeMap::new(),
673 }),
674 "blake3:upd1",
675 );
676 let update2 = make_event(
677 EventType::Update,
678 EventData::Update(UpdateData {
679 field: "title".into(),
680 value: serde_json::Value::String("Third title".into()),
681 extra: BTreeMap::new(),
682 }),
683 "blake3:upd2",
684 );
685 let prior = vec![&create_event, &update1];
687 let result = compensating_event(&update2, &prior, "undoer", 2_000_000).unwrap();
688 if let EventData::Update(d) = &result.data {
689 assert_eq!(d.value, serde_json::Value::String("Second title".into()));
690 } else {
691 panic!("expected Update");
692 }
693 }
694}