use crate::journal::{
ActionId, ActionKind, ActionRecord, ActionStatus, Journal, JournalError, ManifestRole,
};
use crate::snapshot::{Manifest, SnapshotError, Store};
#[derive(Debug)]
pub enum Selector {
Latest,
Action(ActionId),
}
#[derive(Debug, thiserror::Error)]
pub enum UndoError {
#[error("nothing to undo: no completed action with a snapshot was found")]
NoUndoableAction,
#[error("action {id} has no restorable snapshot ({reason}); nothing to do")]
NothingToRestore { id: ActionId, reason: String },
#[error("action {id} cannot be {verb}: {reason}")]
NotUndoable {
id: ActionId,
verb: &'static str,
reason: String,
},
#[error(
"refusing: the world changed since this action (use --force to restore anyway):\n{}",
.0.join("\n")
)]
Conflicts(Vec<String>),
#[error(transparent)]
Journal(#[from] JournalError),
#[error(transparent)]
Snapshot(#[from] SnapshotError),
#[error(
"restore of {path} failed ({cause}); the partial restore was rolled back — \
nothing changed, safe to retry"
)]
PartialRolledBack { path: String, cause: String },
#[error(
"restore of {path} failed ({cause}) AND rollback failed for: {}; \
the tree is in a mixed state — inspect before retrying",
.rollback_failures.join(", ")
)]
PartialInconsistent {
path: String,
cause: String,
rollback_failures: Vec<String>,
},
}
#[derive(Debug)]
pub struct UndoReport {
pub target_action: ActionId,
pub recorded_as: Option<ActionId>,
pub paths_restored: usize,
pub forced: bool,
pub dry_run: bool,
pub plan: Vec<String>,
pub warnings: Vec<String>,
}
pub struct UndoEngine<'a> {
journal: &'a Journal,
store: &'a Store,
}
impl<'a> UndoEngine<'a> {
pub fn new(journal: &'a Journal, store: &'a Store) -> Self {
Self { journal, store }
}
pub fn undo(&self, sel: Selector, force: bool, dry_run: bool) -> Result<UndoReport, UndoError> {
let target = self.select_undo_target(sel)?;
let pre = self
.journal
.manifests_by_role(target.id, ManifestRole::Pre)?;
if pre.is_empty() {
return Err(UndoError::NothingToRestore {
id: target.id,
reason: format!("a {} action snapshots nothing", target.effect),
});
}
let post = self
.journal
.manifests_by_role(target.id, ManifestRole::Post)?;
self.execute(&target, &pre, &post, force, dry_run)
}
pub fn redo(&self, sel: Selector, force: bool, dry_run: bool) -> Result<UndoReport, UndoError> {
let undo_action = match sel {
Selector::Latest => self
.journal
.latest_redoable()?
.ok_or(UndoError::NoUndoableAction)?,
Selector::Action(id) => self.journal.action(id)?,
};
if undo_action.kind != ActionKind::Undo {
return Err(UndoError::NotUndoable {
id: undo_action.id,
verb: "redone",
reason: "not an undo action (redo reverts an undo)".into(),
});
}
if undo_action.status != ActionStatus::Completed {
return Err(UndoError::NotUndoable {
id: undo_action.id,
verb: "redone",
reason: format!("status is {:?}", undo_action.status),
});
}
let original_id = undo_action
.target_action_id
.ok_or_else(|| UndoError::NotUndoable {
id: undo_action.id,
verb: "redone",
reason: "undo action has no target".into(),
})?;
let post = self
.journal
.manifests_by_role(original_id, ManifestRole::Post)?;
if post.is_empty() {
return Err(UndoError::NothingToRestore {
id: original_id,
reason: "no post-state was recorded (the command may have failed)".into(),
});
}
let expect_now = self
.journal
.manifests_by_role(original_id, ManifestRole::Pre)?;
self.execute(&undo_action, &post, &expect_now, force, dry_run)
}
fn select_undo_target(&self, sel: Selector) -> Result<ActionRecord, UndoError> {
let target = match sel {
Selector::Latest => self
.journal
.latest_undoable()?
.ok_or(UndoError::NoUndoableAction)?,
Selector::Action(id) => self.journal.action(id)?,
};
if target.kind == ActionKind::Undo {
return Err(UndoError::NotUndoable {
id: target.id,
verb: "undone",
reason: "it is an undo action; use redo to revert it".into(),
});
}
match target.status {
ActionStatus::Completed | ActionStatus::Abandoned => Ok(target),
other => Err(UndoError::NotUndoable {
id: target.id,
verb: "undone",
reason: format!("status is {other:?}"),
}),
}
}
fn execute(
&self,
journal_target: &ActionRecord,
restore_set: &[Manifest],
oracle: &[Manifest],
force: bool,
dry_run: bool,
) -> Result<UndoReport, UndoError> {
let mut warnings = Vec::new();
let mut conflicts = Vec::new();
for m in restore_set {
if m.truncated {
let msg = format!(
"{}: the recorded capture was TRUNCATED (partial); restoring would \
replace the tree with the partial capture, deleting anything it \
missed",
m.path.display()
);
if force {
warnings.push(msg);
} else {
conflicts.push(msg);
}
}
match oracle.iter().find(|o| o.path == m.path) {
Some(o) => {
if o.truncated {
warnings.push(format!(
"{}: recorded state was truncated; conflict check is partial",
m.path.display()
));
}
if !self.store.state_matches(o)? {
conflicts.push(format!("{} changed since the action", m.path.display()));
}
}
None => conflicts.push(format!(
"{}: cannot verify it is unchanged (no post-state was recorded); \
the command may have failed",
m.path.display()
)),
}
}
if !conflicts.is_empty() && !force {
return Err(UndoError::Conflicts(conflicts));
}
let plan: Vec<String> = restore_set
.iter()
.map(|m| {
if m.root == crate::snapshot::Root::Absent {
format!("delete {} (did not exist before)", m.path.display())
} else {
format!("restore {} ({} entries)", m.path.display(), m.entries.len())
}
})
.collect();
if dry_run {
return Ok(UndoReport {
target_action: journal_target.id,
recorded_as: None,
paths_restored: 0,
forced: !conflicts.is_empty(),
dry_run: true,
plan,
warnings,
});
}
let mut rollback: Vec<Manifest> = Vec::with_capacity(restore_set.len());
for m in restore_set {
match self.store.snapshot(&m.path, None) {
Ok(current) => rollback.push(current),
Err(e) => {
return Err(UndoError::Snapshot(e));
}
}
}
let mut restored = 0usize;
for (i, m) in restore_set.iter().enumerate() {
match self.store.restore(m) {
Ok(report) => {
restored += 1;
warnings.extend(report.warnings);
}
Err(e) => {
let mut rollback_failures = Vec::new();
for done in rollback.iter().take(i) {
if let Err(re) = self.store.restore(done) {
rollback_failures.push(format!("{}: {re}", done.path.display()));
}
}
if rollback_failures.is_empty() {
return Err(UndoError::PartialRolledBack {
path: m.path.display().to_string(),
cause: e.to_string(),
});
}
return Err(UndoError::PartialInconsistent {
path: m.path.display().to_string(),
cause: e.to_string(),
rollback_failures,
});
}
}
}
let recorded = self
.journal
.record_undo(&journal_target.session_id, journal_target.id)?;
for rb in &rollback {
let _ = self
.journal
.attach_manifest(recorded, rb, ManifestRole::Pre);
}
Ok(UndoReport {
target_action: journal_target.id,
recorded_as: Some(recorded),
paths_restored: restored,
forced: !conflicts.is_empty(),
dry_run: false,
plan,
warnings,
})
}
}