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