use crate::{Capability, Context, Output, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UndoArgs {
pub job_id: String,
pub file: Option<String>,
}
pub struct Undo;
impl Capability for Undo {
fn name(&self) -> &'static str {
"Undo"
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"job_id": { "type": "string" },
"file": { "type": "string" }
},
"required": ["job_id"]
})
}
fn validate(&self, args: &serde_json::Value) -> Result<()> {
let args: UndoArgs = serde_json::from_value(args.clone())
.map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
if args.job_id.is_empty() {
return Err(crate::Error::SchemaValidationFailed(
"job_id is empty".into(),
));
}
Ok(())
}
fn execute(&self, args: &serde_json::Value, _ctx: &Context) -> Result<Output> {
let args: UndoArgs = serde_json::from_value(args.clone())
.map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
let backup_dir = crate::utils::backup_dir();
let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
let job_backup_dir = backup_dir.join(&args.job_id);
if !job_backup_dir.exists() {
return Err(crate::Error::ExecutionFailed(format!(
"No backup found for job {}",
args.job_id
)));
}
let mut restored = Vec::new();
let wal_path = crate::utils::wal_path();
let mut original_paths: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
if wal_path.exists() {
match crate::WalReader::load(&wal_path) {
Ok(reader) => {
for event in reader.events() {
if event.job_id == args.job_id {
if let Some(output) = &event.output {
if let Some(data) = output.get("data") {
if let Some(path) = data.get("path").and_then(|p| p.as_str()) {
if let Some(backup) =
data.get("backup_path").and_then(|b| b.as_str())
{
if let Some(filename) = std::path::Path::new(backup)
.file_name()
.and_then(|n| n.to_str())
{
original_paths
.insert(filename.to_string(), path.to_string());
}
}
}
}
}
}
}
}
Err(e) => {
return Err(crate::Error::ExecutionFailed(format!(
"Failed to load WAL for undo: {}",
e
)));
}
}
}
if let Ok(entries) = std::fs::read_dir(&job_backup_dir) {
for entry in entries.flatten() {
let backup_path = entry.path();
if backup_path.is_file() {
let filename = backup_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
crate::Error::ExecutionFailed("Invalid backup filename".into())
})?;
let target_path = original_paths
.get(filename)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(filename));
backup_mgr.restore(&backup_path, &target_path)?;
restored.push(format!("{} -> {}", filename, target_path.display()));
}
}
}
Ok(Output {
success: true,
data: serde_json::json!({
"restored": restored,
"job_id": args.job_id
}),
message: Some(format!("Restored {} file(s)", restored.len())),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::Context;
use std::fs;
#[test]
fn test_undo_with_backup() {
let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
let _ = fs::remove_dir_all(&tmpdir);
fs::create_dir_all(&tmpdir).unwrap();
let test_file = tmpdir.join("test.txt");
fs::write(&test_file, "original content").unwrap();
let backup_dir = tmpdir.join("backups");
let job_id = "test-job-123";
let job_backup_dir = backup_dir.join(job_id);
fs::create_dir_all(&job_backup_dir).unwrap();
let backup_path = job_backup_dir.join("test.txt");
fs::copy(&test_file, &backup_path).unwrap();
fs::write(&test_file, "modified content").unwrap();
std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
let cap = Undo;
let ctx = Context {
dry_run: false,
job_id: "undo-test-job".to_string(),
working_dir: tmpdir.clone(),
};
let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
let _ = fs::remove_dir_all(&tmpdir);
std::env::remove_var("RUNTIMO_BACKUP_DIR");
}
}