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::capability::{CapabilityError, Context, Output, TypedCapability};
20use crate::validation::path::{validate_path, PathContext};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24/// Input parameters for [`Undo::execute`].
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[allow(clippy::exhaustive_structs)] // args struct — fields are the contract
27pub struct UndoArgs {
28    /// Job ID to undo (restores backup from that job).
29    pub job_id: String,
30    /// Optional: specific file to restore (if job modified multiple).
31    pub file: Option<String>,
32}
33
34/// Capability that restores files from backup.
35///
36/// Each undo reverses the file mutations made by a specific job,
37/// restoring the pre-mutation state captured by [`FileWrite`](crate::capabilities::FileWrite).
38// This struct is intentionally simple — it implements the Capability trait
39// and adding non-pub fields would serve no purpose.
40#[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        // Get backup directory from canonical utility
75        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        // Find backup directory for the job
81        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        // Read WAL to find original paths for this job
92        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                                            // FINDING #12: Use full backup path as key, not just filename
108                                            // This prevents collisions when multiple files share the same name
109                                            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        // Restore all files in the job's backup directory
128        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                    // FINDING #12: Look up by full backup path, not just filename
138                    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                    // Re-validate restore target against allowed prefixes
149                    // (WAL data crossed a persistence boundary — must re-check)
150                    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    /// Mutex to serialize undo tests that set environment variables
202    /// (RUNTIMO_WAL_PATH, XDG_DATA_HOME). Without this, concurrent
203    /// tests fight over process-global env vars and produce spurious failures.
204    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        // Set XDG_DATA_HOME so backup_dir() derives from temp dir (ADR-C28)
217        std::env::set_var("XDG_DATA_HOME", &tmpdir);
218        // backup_dir() = data_dir().join("backups") = tmpdir/runtimo/backups
219        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        // Modify original
228        fs::write(&test_file, "modified content").unwrap();
229
230        // Write a WAL entry so undo can find the backup→original mapping
231        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        // Verify original content restored
289        let restored_content = fs::read_to_string(&test_file).unwrap();
290        assert_eq!(restored_content, "original content");
291
292        // Clean up
293        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        // GAP 7: Verify missing backup returns proper error (not panic)
302        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        // Set XDG_DATA_HOME so backup_dir() derives from temp dir
307        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        // Try to undo a job that has no backup
317        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        // GAP 7: empty job_id should be rejected by execute
337        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        // GAP 7: Test restoring multiple files from the same job backup
363        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        // Set XDG_DATA_HOME so backup_dir() derives from temp dir (ADR-C28)
373        std::env::set_var("XDG_DATA_HOME", &tmpdir);
374        // backup_dir() = data_dir().join("backups") = tmpdir/runtimo/backups
375        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        // Modify originals
386        fs::write(&test_file1, "modified file 1").unwrap();
387        fs::write(&test_file2, "modified file 2").unwrap();
388
389        // Write WAL entries for both files
390        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        // Verify both files restored
471        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        // GAP 7: Restore should re-validate target paths against allowed prefixes
483        // This test verifies that validation happens (restore calls validate_path)
484        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        // Set XDG_DATA_HOME so backup_dir() derives from temp dir (ADR-C28)
492        std::env::set_var("XDG_DATA_HOME", &tmpdir);
493        // backup_dir() = data_dir().join("backups") = tmpdir/runtimo/backups
494        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        // Write WAL with an allowed path (/tmp/...)
503        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        // This should succeed because /tmp is in allowed prefixes
543        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}