1use crate::validation::path::{validate_path, PathContext};
20use crate::{Capability, Context, Output, Result};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct UndoArgs {
27 pub job_id: String,
29 pub file: Option<String>,
31}
32
33#[allow(clippy::exhaustive_structs)]
40pub struct Undo;
41
42impl Capability for Undo {
43 fn name(&self) -> &'static str {
44 "Undo"
45 }
46
47 fn description(&self) -> &'static str {
48 "restore from backup. use `runtimo logs` for job IDs."
49 }
50
51 fn schema(&self) -> Value {
52 serde_json::json!({
53 "type": "object",
54 "properties": {
55 "job_id": { "type": "string" },
56 "file": { "type": "string" }
57 },
58 "required": ["job_id"]
59 })
60 }
61
62 fn validate(&self, args: &serde_json::Value) -> Result<()> {
63 let args: UndoArgs = serde_json::from_value(args.clone())
64 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
65 if args.job_id.is_empty() {
66 return Err(crate::Error::SchemaValidationFailed(
67 "job_id is empty".into(),
68 ));
69 }
70 Ok(())
71 }
72
73 fn execute(&self, args: &serde_json::Value, ctx: &Context) -> Result<Output> {
74 let args: UndoArgs = serde_json::from_value(args.clone())
75 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
76
77 let backup_dir = crate::utils::backup_dir();
79
80 let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
81
82 let job_backup_dir = backup_dir.join(&args.job_id);
84 if !job_backup_dir.exists() {
85 return Err(crate::Error::ExecutionFailed(format!(
86 "No backup found for job {}",
87 args.job_id
88 )));
89 }
90
91 let mut restored = Vec::new();
92
93 let wal_path = crate::utils::wal_path();
95
96 let mut original_paths: std::collections::HashMap<String, String> =
97 std::collections::HashMap::new();
98 if wal_path.exists() {
99 match crate::WalReader::load(&wal_path) {
100 Ok(reader) => {
101 for event in reader.events() {
102 if event.job_id == args.job_id {
103 if let Some(output) = &event.output {
104 if let Some(data) = output.get("data") {
105 if let Some(path) = data.get("path").and_then(|p| p.as_str()) {
106 if let Some(backup) =
107 data.get("backup_path").and_then(|b| b.as_str())
108 {
109 original_paths
112 .insert(backup.to_string(), path.to_string());
113 }
114 }
115 }
116 }
117 }
118 }
119 }
120 Err(e) => {
121 return Err(crate::Error::ExecutionFailed(format!(
122 "Failed to load WAL for undo: {}",
123 e
124 )));
125 }
126 }
127 }
128
129 if let Ok(entries) = std::fs::read_dir(&job_backup_dir) {
131 for entry in entries.flatten() {
132 let backup_path = entry.path();
133 if backup_path.is_file() {
134 let backup_path_str = backup_path
135 .to_str()
136 .ok_or_else(|| crate::Error::ExecutionFailed("Invalid backup path".into()))?
137 .to_string();
138
139 let target_path = original_paths
141 .get(&backup_path_str)
142 .map(std::path::PathBuf::from)
143 .ok_or_else(|| {
144 crate::Error::ExecutionFailed(format!(
145 "No original path found for backup {}",
146 backup_path.display()
147 ))
148 })?;
149
150 let restore_ctx = PathContext {
153 require_exists: false,
154 require_file: false,
155 ..Default::default()
156 };
157 let target_path = validate_path(&target_path.to_string_lossy(), &restore_ctx)
158 .map_err(|e| {
159 crate::Error::ExecutionFailed(format!("restore target validation: {}", e))
160 })?;
161
162 if ctx.dry_run {
163 restored.push(format!(
164 "{} -> {} (dry run)",
165 backup_path.display(),
166 target_path.display()
167 ));
168 } else {
169 backup_mgr.restore(&backup_path, &target_path)?;
170 restored.push(format!(
171 "{} -> {}",
172 backup_path.display(),
173 target_path.display()
174 ));
175 }
176 }
177 }
178 }
179
180 Ok(Output {
181 success: true,
182 data: serde_json::json!({
183 "restored": restored,
184 "job_id": args.job_id
185 }),
186 message: Some(format!("Restored {} file(s)", restored.len())),
187 })
188 }
189}
190
191#[cfg(test)]
192#[allow(clippy::items_after_statements)]
193mod tests {
194 use super::*;
195 use crate::capability::Context;
196 use std::fs;
197 use std::sync::Mutex;
198
199 static UNDO_TEST_MUTEX: Mutex<()> = Mutex::new(());
203
204 #[test]
205 fn test_undo_with_backup() {
206 let _guard = UNDO_TEST_MUTEX.lock().unwrap();
207 let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
208 let _ = fs::remove_dir_all(&tmpdir);
209 fs::create_dir_all(&tmpdir).unwrap();
210
211 let test_file = tmpdir.join("test.txt");
212 fs::write(&test_file, "original content").unwrap();
213
214 let backup_dir = tmpdir.join("backups");
215 let job_id = "test-job-123";
216 let job_backup_dir = backup_dir.join(job_id);
217 fs::create_dir_all(&job_backup_dir).unwrap();
218
219 let backup_path = job_backup_dir.join("test.txt");
220 fs::copy(&test_file, &backup_path).unwrap();
221
222 fs::write(&test_file, "modified content").unwrap();
224
225 let wal_file = tmpdir.join("test.wal");
227 std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
228 use crate::wal::{WalEvent, WalEventType, WalWriter};
229 let mut wal = WalWriter::create(&wal_file).unwrap();
230 let ts = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap()
233 .as_secs();
234 wal.append(WalEvent {
235 seq: 0,
236 ts,
237 event_type: WalEventType::JobCompleted,
238 job_id: job_id.to_string(),
239 capability: Some("FileWrite".into()),
240 output: Some(serde_json::json!({
241 "data": {
242 "path": test_file.to_str().unwrap(),
243 "backup_path": backup_path.to_str().unwrap()
244 }
245 })),
246 error: None,
247 telemetry_before: None,
248 telemetry_after: None,
249 process_before: None,
250 process_after: None,
251 cmd: None,
252 cmd_stdout: None,
253 cmd_stderr: None,
254 cmd_exit_code: None,
255 cmd_corrected: None,
256 ..Default::default()
257 })
258 .unwrap();
259
260 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
261
262 let cap = Undo;
263 let ctx = Context {
264 dry_run: false,
265 job_id: "undo-test-job".to_string(),
266 working_dir: tmpdir.clone(),
267 };
268 let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
269
270 assert!(result.is_ok(), "undo failed: {:?}", result.err());
271 let output = result.unwrap();
272 assert!(output.success, "undo not successful: {:?}", output.message);
273 assert!(
274 !output.data["restored"].as_array().unwrap().is_empty(),
275 "no files restored"
276 );
277
278 let restored_content = fs::read_to_string(&test_file).unwrap();
280 assert_eq!(restored_content, "original content");
281
282 let _ = fs::remove_dir_all(&tmpdir);
284 std::env::remove_var("RUNTIMO_WAL_PATH");
285 std::env::remove_var("RUNTIMO_BACKUP_DIR");
286 }
287
288 #[test]
289 fn test_undo_missing_backup_returns_error() {
290 let _guard = UNDO_TEST_MUTEX.lock().unwrap();
291 let tmpdir = std::env::temp_dir().join("runtimo_test_undo_missing");
293 let _ = fs::remove_dir_all(&tmpdir);
294 fs::create_dir_all(&tmpdir).unwrap();
295
296 let backup_dir = tmpdir.join("backups");
297 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
298
299 let cap = Undo;
300 let ctx = Context {
301 dry_run: false,
302 job_id: "undo-missing-test".to_string(),
303 working_dir: tmpdir.clone(),
304 };
305
306 let result = cap.execute(&serde_json::json!({"job_id": "nonexistent-job-xyz"}), &ctx);
308 assert!(result.is_err(), "Should error on missing backup");
309 let err = result.unwrap_err().to_string();
310 assert!(
311 err.contains("No backup found"),
312 "Error should mention missing backup: {}",
313 err
314 );
315
316 let _ = fs::remove_dir_all(&tmpdir);
317 std::env::remove_var("RUNTIMO_BACKUP_DIR");
318 }
319
320 #[test]
321 fn test_undo_missing_job_id_validation() {
322 let cap = Undo;
324 let result = cap.validate(&serde_json::json!({"job_id": ""}));
325 assert!(result.is_err(), "Empty job_id should be rejected");
326 let err = result.unwrap_err().to_string();
327 assert!(
328 err.contains("empty") || err.contains("job_id"),
329 "Error should mention empty job_id: {}",
330 err
331 );
332 }
333
334 #[test]
335 fn test_undo_multi_file_restore() {
336 let _guard = UNDO_TEST_MUTEX.lock().unwrap();
337 let tmpdir = std::env::temp_dir().join("runtimo_test_undo_multi");
339 let _ = fs::remove_dir_all(&tmpdir);
340 fs::create_dir_all(&tmpdir).unwrap();
341
342 let test_file1 = tmpdir.join("file1.txt");
343 let test_file2 = tmpdir.join("file2.txt");
344 fs::write(&test_file1, "original file 1").unwrap();
345 fs::write(&test_file2, "original file 2").unwrap();
346
347 let backup_dir = tmpdir.join("backups_multi");
348 let job_id = "multi-file-job";
349 let job_backup_dir = backup_dir.join(job_id);
350 fs::create_dir_all(&job_backup_dir).unwrap();
351
352 let backup1 = job_backup_dir.join("file1.txt");
353 let backup2 = job_backup_dir.join("file2.txt");
354 fs::copy(&test_file1, &backup1).unwrap();
355 fs::copy(&test_file2, &backup2).unwrap();
356
357 fs::write(&test_file1, "modified file 1").unwrap();
359 fs::write(&test_file2, "modified file 2").unwrap();
360
361 let wal_file = tmpdir.join("multi.wal");
363 std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
364 use crate::wal::{WalEvent, WalEventType, WalWriter};
365 let mut wal = WalWriter::create(&wal_file).unwrap();
366 let ts = std::time::SystemTime::now()
367 .duration_since(std::time::UNIX_EPOCH)
368 .unwrap()
369 .as_secs();
370 wal.append(WalEvent {
371 seq: 0,
372 ts,
373 event_type: WalEventType::JobCompleted,
374 job_id: job_id.to_string(),
375 capability: Some("FileWrite".into()),
376 output: Some(serde_json::json!({
377 "data": {
378 "path": test_file1.to_str().unwrap(),
379 "backup_path": backup1.to_str().unwrap()
380 }
381 })),
382 error: None,
383 telemetry_before: None,
384 telemetry_after: None,
385 process_before: None,
386 process_after: None,
387 cmd: None,
388 cmd_stdout: None,
389 cmd_stderr: None,
390 cmd_exit_code: None,
391 cmd_corrected: None,
392 ..Default::default()
393 })
394 .unwrap();
395 wal.append(WalEvent {
396 seq: 1,
397 ts: ts + 1,
398 event_type: WalEventType::JobCompleted,
399 job_id: job_id.to_string(),
400 capability: Some("FileWrite".into()),
401 output: Some(serde_json::json!({
402 "data": {
403 "path": test_file2.to_str().unwrap(),
404 "backup_path": backup2.to_str().unwrap()
405 }
406 })),
407 error: None,
408 telemetry_before: None,
409 telemetry_after: None,
410 process_before: None,
411 process_after: None,
412 cmd: None,
413 cmd_stdout: None,
414 cmd_stderr: None,
415 cmd_exit_code: None,
416 cmd_corrected: None,
417 ..Default::default()
418 })
419 .unwrap();
420
421 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
422
423 let cap = Undo;
424 let ctx = Context {
425 dry_run: false,
426 job_id: "undo-multi".to_string(),
427 working_dir: tmpdir.clone(),
428 };
429 let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
430
431 assert!(result.is_ok(), "undo multi-file failed: {:?}", result.err());
432 let output = result.unwrap();
433 assert!(output.success);
434 let restored = output.data["restored"].as_array().unwrap();
435 assert!(
436 restored.len() >= 2,
437 "Should restore at least 2 files, got {}: {:?}",
438 restored.len(),
439 restored
440 );
441
442 assert_eq!(fs::read_to_string(&test_file1).unwrap(), "original file 1");
444 assert_eq!(fs::read_to_string(&test_file2).unwrap(), "original file 2");
445
446 let _ = fs::remove_dir_all(&tmpdir);
447 std::env::remove_var("RUNTIMO_WAL_PATH");
448 std::env::remove_var("RUNTIMO_BACKUP_DIR");
449 }
450
451 #[test]
452 fn test_undo_revalidates_target_paths() {
453 let _guard = UNDO_TEST_MUTEX.lock().unwrap();
454 let tmpdir = std::env::temp_dir().join("runtimo_test_undo_validate");
457 let _ = fs::remove_dir_all(&tmpdir);
458 fs::create_dir_all(&tmpdir).unwrap();
459
460 let test_file = tmpdir.join("valid.txt");
461 fs::write(&test_file, "original").unwrap();
462
463 let backup_dir = tmpdir.join("backups_val");
464 let job_id = "validate-job";
465 let job_backup_dir = backup_dir.join(job_id);
466 fs::create_dir_all(&job_backup_dir).unwrap();
467
468 let backup_path = job_backup_dir.join("valid.txt");
469 fs::copy(&test_file, &backup_path).unwrap();
470
471 let wal_file = tmpdir.join("val.wal");
473 std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
474 use crate::wal::{WalEvent, WalEventType, WalWriter};
475 let mut wal = WalWriter::create(&wal_file).unwrap();
476 wal.append(WalEvent {
477 seq: 0,
478 ts: std::time::SystemTime::now()
479 .duration_since(std::time::UNIX_EPOCH)
480 .unwrap()
481 .as_secs(),
482 event_type: WalEventType::JobCompleted,
483 job_id: job_id.to_string(),
484 capability: Some("FileWrite".into()),
485 output: Some(serde_json::json!({
486 "data": {
487 "path": test_file.to_str().unwrap(),
488 "backup_path": backup_path.to_str().unwrap()
489 }
490 })),
491 error: None,
492 telemetry_before: None,
493 telemetry_after: None,
494 process_before: None,
495 process_after: None,
496 cmd: None,
497 cmd_stdout: None,
498 cmd_stderr: None,
499 cmd_exit_code: None,
500 cmd_corrected: None,
501 ..Default::default()
502 })
503 .unwrap();
504
505 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
506
507 let cap = Undo;
508 let ctx = Context {
509 dry_run: false,
510 job_id: "undo-val".to_string(),
511 working_dir: tmpdir.clone(),
512 };
513 let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
515 assert!(
516 result.is_ok(),
517 "Valid path restore should succeed: {:?}",
518 result.err()
519 );
520
521 let _ = fs::remove_dir_all(&tmpdir);
522 std::env::remove_var("RUNTIMO_WAL_PATH");
523 std::env::remove_var("RUNTIMO_BACKUP_DIR");
524 }
525}