use crate::models::{Error, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RollbackAction {
Command(String),
RestoreFile {
original_path: PathBuf,
backup_path: PathBuf,
},
RemoveFile(PathBuf),
RemoveDirectory(PathBuf),
RestorePackages {
install: Vec<String>,
remove: Vec<String>,
},
RestoreService {
name: String,
was_enabled: bool,
was_running: bool,
},
RestoreUserGroup {
user: String,
group: String,
was_member: bool,
},
None,
}
impl RollbackAction {
pub fn command(cmd: &str) -> Self {
Self::Command(cmd.to_string())
}
pub fn restore_file(original: impl AsRef<Path>, backup: impl AsRef<Path>) -> Self {
Self::RestoreFile {
original_path: original.as_ref().to_path_buf(),
backup_path: backup.as_ref().to_path_buf(),
}
}
pub fn remove_file(path: impl AsRef<Path>) -> Self {
Self::RemoveFile(path.as_ref().to_path_buf())
}
pub fn is_none(&self) -> bool {
matches!(self, Self::None)
}
pub fn description(&self) -> String {
match self {
Self::Command(cmd) => format!("Execute: {}", truncate(cmd, 60)),
Self::RestoreFile {
original_path,
backup_path,
} => format!(
"Restore {} from {}",
original_path.display(),
backup_path.display()
),
Self::RemoveFile(path) => format!("Remove file: {}", path.display()),
Self::RemoveDirectory(path) => format!("Remove directory: {}", path.display()),
Self::RestorePackages { install, remove } => describe_restore_packages(install, remove),
Self::RestoreService {
name,
was_enabled,
was_running,
} => describe_restore_service(name, *was_enabled, *was_running),
Self::RestoreUserGroup {
user,
group,
was_member,
} => describe_restore_user_group(user, group, *was_member),
Self::None => "No action required".to_string(),
}
}
}
fn describe_restore_packages(install: &[String], remove: &[String]) -> String {
let mut parts = Vec::new();
if !install.is_empty() {
parts.push(format!("Reinstall: {}", install.join(", ")));
}
if !remove.is_empty() {
parts.push(format!("Remove: {}", remove.join(", ")));
}
parts.join("; ")
}
fn describe_restore_service(name: &str, was_enabled: bool, was_running: bool) -> String {
let enabled = if was_enabled { "enable" } else { "disable" };
let running = if was_running { "start" } else { "stop" };
format!("Service {}: {}, {}", name, enabled, running)
}
fn describe_restore_user_group(user: &str, group: &str, was_member: bool) -> String {
if was_member {
format!("Add {} back to group {}", user, group)
} else {
format!("Remove {} from group {}", user, group)
}
}
#[derive(Debug, Clone)]
pub struct StepRollback {
pub step_id: String,
pub step_name: String,
pub actions: Vec<RollbackAction>,
pub state_files: Vec<StateFileBackup>,
pub completed: bool,
pub error: Option<String>,
}
impl StepRollback {
pub fn new(step_id: &str, step_name: &str) -> Self {
Self {
step_id: step_id.to_string(),
step_name: step_name.to_string(),
actions: Vec::new(),
state_files: Vec::new(),
completed: false,
error: None,
}
}
pub fn add_action(&mut self, action: RollbackAction) {
if !action.is_none() {
self.actions.push(action);
}
}
pub fn add_state_file(&mut self, backup: StateFileBackup) {
self.state_files.push(backup);
}
pub fn mark_completed(&mut self) {
self.completed = true;
}
pub fn mark_failed(&mut self, error: &str) {
self.completed = false;
self.error = Some(error.to_string());
}
pub fn needs_rollback(&self) -> bool {
!self.completed && !self.actions.is_empty()
}
pub fn rollback_actions(&self) -> impl Iterator<Item = &RollbackAction> {
self.actions.iter().rev()
}
}
#[derive(Debug, Clone)]
pub struct StateFileBackup {
pub original_path: PathBuf,
pub backup_path: PathBuf,
pub content_hash: String,
pub existed: bool,
pub backed_up_at: u64,
}
impl StateFileBackup {
pub fn new(
original: impl AsRef<Path>,
backup: impl AsRef<Path>,
content_hash: &str,
existed: bool,
) -> Self {
Self {
original_path: original.as_ref().to_path_buf(),
backup_path: backup.as_ref().to_path_buf(),
content_hash: content_hash.to_string(),
existed,
backed_up_at: current_timestamp(),
}
}
}
#[derive(Debug)]
pub struct RollbackManager {
backup_dir: PathBuf,
steps: Vec<StepRollback>,
step_index: HashMap<String, usize>,
auto_rollback: bool,
}
impl RollbackManager {
pub fn new(backup_dir: impl AsRef<Path>) -> Result<Self> {
let backup_dir = backup_dir.as_ref().to_path_buf();
if !backup_dir.exists() {
std::fs::create_dir_all(&backup_dir).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to create backup directory: {}", e),
))
})?;
}
Ok(Self {
backup_dir,
steps: Vec::new(),
step_index: HashMap::new(),
auto_rollback: true,
})
}
pub fn set_auto_rollback(&mut self, enabled: bool) {
self.auto_rollback = enabled;
}
pub fn is_auto_rollback(&self) -> bool {
self.auto_rollback
}
pub fn backup_dir(&self) -> &Path {
&self.backup_dir
}
#[allow(clippy::expect_used)]
pub fn register_step(&mut self, step_id: &str, step_name: &str) -> &mut StepRollback {
let index = self.steps.len();
self.steps.push(StepRollback::new(step_id, step_name));
self.step_index.insert(step_id.to_string(), index);
self.steps.get_mut(index).expect("just pushed element")
}
pub fn get_step(&self, step_id: &str) -> Option<&StepRollback> {
self.step_index
.get(step_id)
.and_then(|&idx| self.steps.get(idx))
}
pub fn get_step_mut(&mut self, step_id: &str) -> Option<&mut StepRollback> {
self.step_index
.get(step_id)
.copied()
.and_then(|idx| self.steps.get_mut(idx))
}
pub fn backup_file(
&mut self,
step_id: &str,
path: impl AsRef<Path>,
) -> Result<StateFileBackup> {
let path = path.as_ref();
let existed = path.exists();
let backup_name = format!(
"{}-{}-{}",
step_id,
path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
current_timestamp()
);
let backup_path = self.backup_dir.join(&backup_name);
let content_hash = if existed {
std::fs::copy(path, &backup_path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to backup file {}: {}", path.display(), e),
))
})?;
let content = std::fs::read(path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to read file for hashing: {}", e),
))
})?;
compute_hash(&content)
} else {
"nonexistent".to_string()
};
let backup = StateFileBackup::new(path, &backup_path, &content_hash, existed);
if let Some(step) = self.get_step_mut(step_id) {
step.add_state_file(backup.clone());
if existed {
step.add_action(RollbackAction::restore_file(path, &backup_path));
} else {
step.add_action(RollbackAction::remove_file(path));
}
}
Ok(backup)
}
pub fn steps_needing_rollback(&self) -> impl Iterator<Item = &StepRollback> {
self.steps.iter().rev().filter(|s| s.needs_rollback())
}
pub fn completed_steps_reverse(&self) -> impl Iterator<Item = &StepRollback> {
self.steps.iter().rev().filter(|s| s.completed)
}
pub fn plan_rollback_from(&self, from_step: &str) -> Result<RollbackPlan> {
let from_idx = self.step_index.get(from_step).ok_or_else(|| {
Error::Validation(format!(
"Step '{}' not found in rollback manager",
from_step
))
})?;
let steps_to_rollback: Vec<_> = self
.steps
.get(..=*from_idx)
.unwrap_or(&[])
.iter()
.rev()
.filter(|s| s.completed || s.needs_rollback())
.cloned()
.collect();
Ok(RollbackPlan {
steps: steps_to_rollback,
backup_dir: self.backup_dir.clone(),
})
}
pub fn plan_rollback_failed(&self) -> RollbackPlan {
let steps_to_rollback: Vec<_> = self
.steps
.iter()
.rev()
.filter(|s| s.needs_rollback())
.cloned()
.collect();
RollbackPlan {
steps: steps_to_rollback,
backup_dir: self.backup_dir.clone(),
}
}
pub fn step_count(&self) -> usize {
self.steps.len()
}
pub fn completed_count(&self) -> usize {
self.steps.iter().filter(|s| s.completed).count()
}
pub fn failed_count(&self) -> usize {
self.steps
.iter()
.filter(|s| !s.completed && s.error.is_some())
.count()
}
}
include!("rollback_rollbackplan.rs");