1use 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 Latest,
30 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 pub target_action: ActionId,
76 pub recorded_as: Option<ActionId>,
78 pub paths_restored: usize,
79 pub forced: bool,
80 pub dry_run: bool,
81 pub plan: Vec<String>,
83 pub warnings: Vec<String>,
84 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 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 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 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 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 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 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 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 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 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 let recorded = self
331 .journal
332 .record_undo(&journal_target.session_id, journal_target.id)?;
333 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}