Skip to main content

bones_core/
undo.rs

1//! Compensating event generation for `bn undo`.
2//!
3//! Events are immutable and append-only. `undo` does **not** delete or modify
4//! events — it emits *compensating events* that reverse the observable effect:
5//!
6//! | Original event | Compensating event |
7//! |---|---|
8//! | `item.create` | `item.delete` |
9//! | `item.update` | `item.update` with prior field value |
10//! | `item.move` | `item.move` back to prior state |
11//! | `item.assign(assign)` | `item.assign(unassign)` |
12//! | `item.assign(unassign)` | `item.assign(assign)` |
13//! | `item.link` | `item.unlink` |
14//! | `item.unlink` | `item.link` |
15//! | `item.delete` | `item.create` (reconstruct from history) |
16//!
17//! Events that **cannot** be undone (grow-only by design):
18//! - `item.comment` — G-Set: comments are permanent
19//! - `item.compact` — compaction is not reversible without original events
20//! - `item.snapshot` — same as compact
21//! - `item.redact` — intentionally permanent
22
23use 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// ---------------------------------------------------------------------------
34// Error type
35// ---------------------------------------------------------------------------
36
37/// Reason a compensating event cannot be generated.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum UndoError {
40    /// The event type uses a grow-only CRDT and cannot be undone.
41    GrowOnly(EventType),
42    /// Context from prior events is needed but unavailable or insufficient.
43    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
60// ---------------------------------------------------------------------------
61// Public API
62// ---------------------------------------------------------------------------
63
64/// Generate a compensating event that reverses the effect of `original`.
65///
66/// `prior_events` must be all events for the same item that occurred
67/// **before** `original`, sorted in ascending chronological order. This
68/// context is required for:
69/// - `item.move` — to find the prior lifecycle state
70/// - `item.update` — to find the prior field value
71/// - `item.delete` — to reconstruct the original `item.create` data
72///
73/// The returned event has `event_hash` set to an empty string; callers are
74/// responsible for calling [`crate::event::writer::write_event`] to compute
75/// and fill the hash before appending to the shard.
76///
77/// # Errors
78///
79/// - [`UndoError::GrowOnly`] — for `item.comment`, `item.compact`,
80///   `item.snapshot`, and `item.redact`.
81/// - [`UndoError::NoPriorState`] — when prior event context is needed but
82///   cannot be found (e.g. undo `item.delete` with no prior `item.create`).
83pub 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        // item.create → item.delete
94        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        // item.update → item.update with previous field value
106        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        // item.move → item.move back to prior state
124        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        // item.assign(assign) → item.assign(unassign), and vice-versa
140        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        // item.link → item.unlink
156        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        // item.unlink → item.link (restore with original link_type or "related_to")
166        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        // item.delete → item.create (reconstruct from history)
179        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        // Grow-only / irreversible event types
189        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(), // filled by write_event
204    })
205}
206
207// ---------------------------------------------------------------------------
208// Internal helpers
209// ---------------------------------------------------------------------------
210
211/// Find the most recent lifecycle state from the events preceding `original`.
212///
213/// Scans backwards through prior events looking for `item.move` or
214/// `item.create`. Falls back to `None` if none found (caller should default
215/// to `State::Open`).
216fn 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
227/// Find the most recent value for `field` from prior events.
228///
229/// Scans backwards through prior events looking for `item.update` targeting
230/// the same field, then falls back to the initial value from `item.create`.
231fn 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
242/// Extract the initial value for `field` from an `item.create` payload.
243fn 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
266/// Reconstruct `CreateData` from prior event history (for undo of delete).
267///
268/// Finds the original `item.create` event and applies any subsequent
269/// `item.update` events to reflect the item's state just before deletion.
270fn build_create_from_history(prior_events: &[&Event]) -> Option<CreateData> {
271    // Find the original create data
272    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    // Apply subsequent update events to reflect the latest field values
282    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
291/// Apply a single field update to a `CreateData` struct (for undo-delete reconstruction).
292fn 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// ---------------------------------------------------------------------------
328// Tests
329// ---------------------------------------------------------------------------
330
331#[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        // Now undo the move-to-doing; prior events include create
483        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); // initial state from create
490        } 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        // Undo update2; prior = [create, update1]
686        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}