Skip to main content

runtimo_core/capabilities/
undo.rs

1//! Undo capability — restores files from backup.
2//!
3//! Enables rollback of file mutations by restoring from backups created
4//! by [`FileWrite`](crate::capabilities::FileWrite).
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use runtimo_core::{Undo, Capability};
10//! use serde_json::json;
11//!
12//! let cap = Undo;
13//! let result = cap.execute(
14//!     &json!({"job_id": "abc123"}),
15//!     &Context::default()
16//! ).unwrap();
17//! ```
18
19use crate::validation::path::{validate_path, PathContext};
20use crate::{Capability, Context, Output, Result};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24/// Input parameters for [`Undo::execute`].
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct UndoArgs {
27    /// Job ID to undo (restores backup from that job).
28    pub job_id: String,
29    /// Optional: specific file to restore (if job modified multiple).
30    pub file: Option<String>,
31}
32
33/// Capability that restores files from backup.
34///
35/// Each undo reverses the file mutations made by a specific job,
36/// restoring the pre-mutation state captured by [`FileWrite`](crate::capabilities::FileWrite).
37// This struct is intentionally simple — it implements the Capability trait
38// and adding non-pub fields would serve no purpose.
39#[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        // Get backup directory from canonical utility
78        let backup_dir = crate::utils::backup_dir();
79
80        let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
81
82        // Find backup directory for the job
83        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        // Read WAL to find original paths for this job
94        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                                            // FINDING #12: Use full backup path as key, not just filename
110                                            // This prevents collisions when multiple files share the same name
111                                            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        // Restore all files in the job's backup directory
130        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                    // FINDING #12: Look up by full backup path, not just filename
140                    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                    // Re-validate restore target against allowed prefixes
151                    // (WAL data crossed a persistence boundary — must re-check)
152                    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    /// Mutex to serialize undo tests that set environment variables
200    /// (RUNTIMO_WAL_PATH, RUNTIMO_BACKUP_DIR). Without this, concurrent
201    /// tests fight over process-global env vars and produce spurious failures.
202    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        // Modify original
223        fs::write(&test_file, "modified content").unwrap();
224
225        // Write a WAL entry so undo can find the backup→original mapping
226        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        // Verify original content restored
279        let restored_content = fs::read_to_string(&test_file).unwrap();
280        assert_eq!(restored_content, "original content");
281
282        // Clean up
283        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        // GAP 7: Verify missing backup returns proper error (not panic)
292        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        // Try to undo a job that has no backup
307        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        // GAP 7: empty job_id should be rejected by validate
323        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        // GAP 7: Test restoring multiple files from the same job backup
338        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        // Modify originals
358        fs::write(&test_file1, "modified file 1").unwrap();
359        fs::write(&test_file2, "modified file 2").unwrap();
360
361        // Write WAL entries for both files
362        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        // Verify both files restored
443        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        // GAP 7: Restore should re-validate target paths against allowed prefixes
455        // This test verifies that validation happens (restore calls validate_path)
456        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        // Write WAL with an allowed path (/tmp/...)
472        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        // This should succeed because /tmp is in allowed prefixes
514        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}