Skip to main content

doover_core/
undo.rs

1//! Undo/redo engine (step 6): the user-facing payoff of everything upstream.
2//!
3//! Model: each protected action carries PRE manifests (state before the
4//! command — undo restores these) and POST manifests (state after — redo
5//! restores these, and they answer "is the world still as the action left
6//! it?" for conflict detection). The journal's bounded chain semantics do the
7//! bookkeeping: undo is itself a journaled action; redo = undo of the undo;
8//! undoing a redo is refused with a pointer to the original.
9//!
10//! Safety posture:
11//! - conflict-checked by default: if the touched paths changed since the
12//!   action (user edits, later agent actions) — or if the action failed and
13//!   left no post-state to verify against — refuse unless --force;
14//! - all-or-nothing and restore-BEFORE-record: a partial failure rolls the
15//!   restored paths back and returns an error WITHOUT recording, so the target
16//!   stays retryable and the journal never claims an undo that only half
17//!   happened;
18//! - dry-run plans without writing anything, journal included.
19
20use crate::journal::{
21    ActionId, ActionKind, ActionRecord, ActionStatus, Journal, JournalError, ManifestRole,
22};
23use crate::snapshot::{Manifest, SnapshotError, Store};
24use std::path::PathBuf;
25
26#[derive(Debug)]
27pub enum Selector {
28    /// The most recent plausible target (undoable command / live undo).
29    Latest,
30    /// A specific journal action id.
31    Action(ActionId),
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum UndoError {
36    #[error("nothing to undo: no completed action with a snapshot was found")]
37    NoUndoableAction,
38    #[error("action {id} has no restorable snapshot ({reason}); nothing to do")]
39    NothingToRestore { id: ActionId, reason: String },
40    #[error("action {id} cannot be {verb}: {reason}")]
41    NotUndoable {
42        id: ActionId,
43        verb: &'static str,
44        reason: String,
45    },
46    #[error(
47        "refusing: the world changed since this action (use --force to restore anyway):\n{}",
48        .0.join("\n")
49    )]
50    Conflicts(Vec<String>),
51    #[error(transparent)]
52    Journal(#[from] JournalError),
53    #[error(transparent)]
54    Snapshot(#[from] SnapshotError),
55    #[error(
56        "restore of {path} failed ({cause}); the partial restore was rolled back — \
57         nothing changed, safe to retry"
58    )]
59    PartialRolledBack { path: String, cause: String },
60    #[error(
61        "restore of {path} failed ({cause}) AND rollback failed for: {}; \
62         the tree is in a mixed state — inspect before retrying",
63        .rollback_failures.join(", ")
64    )]
65    PartialInconsistent {
66        path: String,
67        cause: String,
68        rollback_failures: Vec<String>,
69    },
70}
71
72#[derive(Debug)]
73pub struct UndoReport {
74    /// The action whose effect was reverted (undo) or re-applied (redo).
75    pub target_action: ActionId,
76    /// The new journal row recording this undo/redo (absent on dry-run).
77    pub recorded_as: Option<ActionId>,
78    pub paths_restored: usize,
79    pub forced: bool,
80    pub dry_run: bool,
81    /// Human-readable restore plan, one line per path.
82    pub plan: Vec<String>,
83    pub warnings: Vec<String>,
84    /// The paths actually restored. A directory restore replaces the directory
85    /// itself (stage-then-swap), so a shell standing inside one is left with a
86    /// stale cwd; the CLI uses this to tell the user to `cd .`.
87    pub restored_paths: Vec<PathBuf>,
88}
89
90pub struct UndoEngine<'a> {
91    journal: &'a Journal,
92    store: &'a Store,
93}
94
95impl<'a> UndoEngine<'a> {
96    pub fn new(journal: &'a Journal, store: &'a Store) -> Self {
97        Self { journal, store }
98    }
99
100    /// Revert `target`'s effect by restoring its PRE manifests.
101    pub fn undo(&self, sel: Selector, force: bool, dry_run: bool) -> Result<UndoReport, UndoError> {
102        let target = self.select_undo_target(sel)?;
103        let pre = self
104            .journal
105            .manifests_by_role(target.id, ManifestRole::Pre)?;
106        if pre.is_empty() {
107            return Err(UndoError::NothingToRestore {
108                id: target.id,
109                reason: format!("a {} action snapshots nothing", target.effect),
110            });
111        }
112        let post = self
113            .journal
114            .manifests_by_role(target.id, ManifestRole::Post)?;
115        self.execute(&target, &pre, &post, force, dry_run)
116    }
117
118    /// Re-apply an undone action's effect by restoring its POST manifests.
119    /// `sel` addresses the UNDO action to revert (Latest = most recent one).
120    pub fn redo(&self, sel: Selector, force: bool, dry_run: bool) -> Result<UndoReport, UndoError> {
121        let undo_action = match sel {
122            Selector::Latest => self
123                .journal
124                .latest_redoable()?
125                .ok_or(UndoError::NoUndoableAction)?,
126            Selector::Action(id) => self.journal.action(id)?,
127        };
128        if undo_action.kind != ActionKind::Undo {
129            return Err(UndoError::NotUndoable {
130                id: undo_action.id,
131                verb: "redone",
132                reason: "not an undo action (redo reverts an undo)".into(),
133            });
134        }
135        if undo_action.status != ActionStatus::Completed {
136            return Err(UndoError::NotUndoable {
137                id: undo_action.id,
138                verb: "redone",
139                reason: format!("status is {:?}", undo_action.status),
140            });
141        }
142        let original_id = undo_action
143            .target_action_id
144            .ok_or_else(|| UndoError::NotUndoable {
145                id: undo_action.id,
146                verb: "redone",
147                reason: "undo action has no target".into(),
148            })?;
149        // redo restores the original's POST state
150        let post = self
151            .journal
152            .manifests_by_role(original_id, ManifestRole::Post)?;
153        if post.is_empty() {
154            return Err(UndoError::NothingToRestore {
155                id: original_id,
156                reason: "no post-state was recorded (the command may have failed)".into(),
157            });
158        }
159        // conflict oracle after an undo: the world should equal the original's
160        // PRE state (that is what the undo restored)
161        let expect_now = self
162            .journal
163            .manifests_by_role(original_id, ManifestRole::Pre)?;
164        self.execute(&undo_action, &post, &expect_now, force, dry_run)
165    }
166
167    fn select_undo_target(&self, sel: Selector) -> Result<ActionRecord, UndoError> {
168        let target = match sel {
169            Selector::Latest => self
170                .journal
171                .latest_undoable()?
172                .ok_or(UndoError::NoUndoableAction)?,
173            Selector::Action(id) => self.journal.action(id)?,
174        };
175        if target.kind == ActionKind::Undo {
176            return Err(UndoError::NotUndoable {
177                id: target.id,
178                verb: "undone",
179                reason: "it is an undo action; use redo to revert it".into(),
180            });
181        }
182        match target.status {
183            ActionStatus::Completed | ActionStatus::Abandoned => Ok(target),
184            other => Err(UndoError::NotUndoable {
185                id: target.id,
186                verb: "undone",
187                reason: format!("status is {other:?}"),
188            }),
189        }
190    }
191
192    /// Shared tail: conflict-check `restore_set` against `oracle`, then (unless
193    /// dry-run) capture rollback state, restore all-or-nothing, and only record
194    /// the undo once every path is restored.
195    fn execute(
196        &self,
197        journal_target: &ActionRecord,
198        restore_set: &[Manifest],
199        oracle: &[Manifest],
200        force: bool,
201        dry_run: bool,
202    ) -> Result<UndoReport, UndoError> {
203        let mut warnings = Vec::new();
204        let mut conflicts = Vec::new();
205        for m in restore_set {
206            // A TRUNCATED capture is a partial tree. Restore is stage-then-
207            // swap: swapping the partial in DELETES every live file the
208            // snapshot skipped — undo destroying exactly what it failed to
209            // capture (round 18). Refuse by default; --force proceeds with a
210            // loud warning for the "partial restore beats nothing" cases
211            // (e.g. recovering some of an rm -rf'd tree).
212            if m.truncated {
213                let msg = format!(
214                    "{}: the recorded capture was TRUNCATED (partial); restoring would \
215                     replace the tree with the partial capture, deleting anything it \
216                     missed",
217                    m.path.display()
218                );
219                if force {
220                    warnings.push(msg);
221                } else {
222                    conflicts.push(msg);
223                }
224            }
225            match oracle.iter().find(|o| o.path == m.path) {
226                Some(o) => {
227                    if o.truncated {
228                        warnings.push(format!(
229                            "{}: recorded state was truncated; conflict check is partial",
230                            m.path.display()
231                        ));
232                    }
233                    if !self.store.state_matches(o)? {
234                        conflicts.push(format!("{} changed since the action", m.path.display()));
235                    }
236                }
237                // no recorded state to verify against (audit round 10): a
238                // failed/abandoned command has no post-snapshot, so we CANNOT
239                // confirm the world is unchanged. Refusing-by-default is the
240                // safe choice — undoing might clobber the user's own work.
241                // --force proceeds.
242                None => conflicts.push(format!(
243                    "{}: cannot verify it is unchanged (no post-state was recorded); \
244                     the command may have failed",
245                    m.path.display()
246                )),
247            }
248        }
249        if !conflicts.is_empty() && !force {
250            return Err(UndoError::Conflicts(conflicts));
251        }
252
253        let plan: Vec<String> = restore_set
254            .iter()
255            .map(|m| {
256                if m.root == crate::snapshot::Root::Absent {
257                    format!("delete {} (did not exist before)", m.path.display())
258                } else {
259                    format!("restore {} ({} entries)", m.path.display(), m.entries.len())
260                }
261            })
262            .collect();
263
264        if dry_run {
265            return Ok(UndoReport {
266                target_action: journal_target.id,
267                recorded_as: None,
268                paths_restored: 0,
269                forced: !conflicts.is_empty(),
270                dry_run: true,
271                plan,
272                warnings,
273                restored_paths: Vec::new(),
274            });
275        }
276
277        // All-or-nothing, restore-BEFORE-record (audit round 10): if any path
278        // fails, roll the succeeded ones back to their pre-undo state and
279        // return an error WITHOUT recording — the target stays in its current
280        // status so `doover undo` can simply be retried. The journal never
281        // claims an undo that only partly happened.
282        //
283        // Capture each path's current state first (in memory) as the rollback
284        // point. A path we cannot even snapshot is a path we cannot safely
285        // restore transactionally, so refuse before touching anything.
286        let mut rollback: Vec<Manifest> = Vec::with_capacity(restore_set.len());
287        for m in restore_set {
288            match self.store.snapshot(&m.path, None) {
289                Ok(current) => rollback.push(current),
290                Err(e) => {
291                    return Err(UndoError::Snapshot(e));
292                }
293            }
294        }
295
296        let mut restored = 0usize;
297        for (i, m) in restore_set.iter().enumerate() {
298            match self.store.restore(m) {
299                Ok(report) => {
300                    restored += 1;
301                    warnings.extend(report.warnings);
302                }
303                Err(e) => {
304                    // roll back everything already restored (best effort) so
305                    // the world returns to its pre-undo state
306                    let mut rollback_failures = Vec::new();
307                    for done in rollback.iter().take(i) {
308                        if let Err(re) = self.store.restore(done) {
309                            rollback_failures.push(format!("{}: {re}", done.path.display()));
310                        }
311                    }
312                    if rollback_failures.is_empty() {
313                        return Err(UndoError::PartialRolledBack {
314                            path: m.path.display().to_string(),
315                            cause: e.to_string(),
316                        });
317                    }
318                    return Err(UndoError::PartialInconsistent {
319                        path: m.path.display().to_string(),
320                        cause: e.to_string(),
321                        rollback_failures,
322                    });
323                }
324            }
325        }
326
327        // every path restored: NOW record the undo (flips status). record_undo
328        // is the double-undo guard; if a concurrent undo already recorded, this
329        // errors but the world is already correctly restored (idempotent).
330        let recorded = self
331            .journal
332            .record_undo(&journal_target.session_id, journal_target.id)?;
333        // stash the pre-undo state on the new row for forensics/manual recovery
334        for rb in &rollback {
335            let _ = self
336                .journal
337                .attach_manifest(recorded, rb, ManifestRole::Pre);
338        }
339
340        Ok(UndoReport {
341            target_action: journal_target.id,
342            recorded_as: Some(recorded),
343            paths_restored: restored,
344            forced: !conflicts.is_empty(),
345            dry_run: false,
346            plan,
347            warnings,
348            restored_paths: restore_set.iter().map(|m| m.path.clone()).collect(),
349        })
350    }
351}