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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct UndoArgs {
26    /// Job ID to undo (restores backup from that job).
27    pub job_id: String,
28    /// Optional: specific file to restore (if job modified multiple).
29    pub file: Option<String>,
30}
31
32pub struct Undo;
33
34impl Capability for Undo {
35    fn name(&self) -> &'static str {
36        "Undo"
37    }
38
39    fn description(&self) -> &'static str {
40        "restore from backup. use `runtimo logs` for job IDs."
41    }
42
43    fn schema(&self) -> Value {
44        serde_json::json!({
45            "type": "object",
46            "properties": {
47                "job_id": { "type": "string" },
48                "file": { "type": "string" }
49            },
50            "required": ["job_id"]
51        })
52    }
53
54    fn validate(&self, args: &serde_json::Value) -> Result<()> {
55        let args: UndoArgs = serde_json::from_value(args.clone())
56            .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
57        if args.job_id.is_empty() {
58            return Err(crate::Error::SchemaValidationFailed(
59                "job_id is empty".into(),
60            ));
61        }
62        Ok(())
63    }
64
65    fn execute(&self, args: &serde_json::Value, _ctx: &Context) -> Result<Output> {
66        let args: UndoArgs = serde_json::from_value(args.clone())
67            .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
68
69        // Get backup directory from canonical utility
70        let backup_dir = crate::utils::backup_dir();
71
72        let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
73
74        // Find backup directory for the job
75        let job_backup_dir = backup_dir.join(&args.job_id);
76        if !job_backup_dir.exists() {
77            return Err(crate::Error::ExecutionFailed(format!(
78                "No backup found for job {}",
79                args.job_id
80            )));
81        }
82
83        let mut restored = Vec::new();
84
85        // Read WAL to find original paths for this job
86        let wal_path = crate::utils::wal_path();
87
88        let mut original_paths: std::collections::HashMap<String, String> =
89            std::collections::HashMap::new();
90        if wal_path.exists() {
91            match crate::WalReader::load(&wal_path) {
92                Ok(reader) => {
93                    for event in reader.events() {
94                        if event.job_id == args.job_id {
95                            if let Some(output) = &event.output {
96                                if let Some(data) = output.get("data") {
97                                    if let Some(path) = data.get("path").and_then(|p| p.as_str()) {
98                                        if let Some(backup) =
99                                            data.get("backup_path").and_then(|b| b.as_str())
100                                        {
101                                            // FINDING #12: Use full backup path as key, not just filename
102                                            // This prevents collisions when multiple files share the same name
103                                            original_paths
104                                                .insert(backup.to_string(), path.to_string());
105                                        }
106                                    }
107                                }
108                            }
109                        }
110                    }
111                }
112                Err(e) => {
113                    return Err(crate::Error::ExecutionFailed(format!(
114                        "Failed to load WAL for undo: {}",
115                        e
116                    )));
117                }
118            }
119        }
120
121        // Restore all files in the job's backup directory
122        if let Ok(entries) = std::fs::read_dir(&job_backup_dir) {
123            for entry in entries.flatten() {
124                let backup_path = entry.path();
125                if backup_path.is_file() {
126                    let backup_path_str = backup_path
127                        .to_str()
128                        .ok_or_else(|| {
129                            crate::Error::ExecutionFailed("Invalid backup path".into())
130                        })?
131                        .to_string();
132
133                    // FINDING #12: Look up by full backup path, not just filename
134                    let target_path = original_paths
135                        .get(&backup_path_str)
136                        .map(std::path::PathBuf::from)
137                        .ok_or_else(|| {
138                            crate::Error::ExecutionFailed(format!(
139                                "No original path found for backup {}",
140                                backup_path.display()
141                            ))
142                        })?;
143
144                    // Re-validate restore target against allowed prefixes
145                    // (WAL data crossed a persistence boundary — must re-check)
146                    let restore_ctx = PathContext {
147                        require_exists: false,
148                        require_file: false,
149                        ..Default::default()
150                    };
151                    let target_path = validate_path(
152                        &target_path.to_string_lossy(),
153                        &restore_ctx,
154                    )
155                    .map_err(|e| {
156                        crate::Error::ExecutionFailed(format!(
157                            "restore target validation: {}", e
158                        ))
159                    })?;
160
161                    backup_mgr.restore(&backup_path, &target_path)?;
162                    restored.push(format!("{} -> {}", backup_path.display(), target_path.display()));
163                }
164            }
165        }
166
167        Ok(Output {
168            success: true,
169            data: serde_json::json!({
170                "restored": restored,
171                "job_id": args.job_id
172            }),
173            message: Some(format!("Restored {} file(s)", restored.len())),
174        })
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::capability::Context;
182    use std::fs;
183
184    #[test]
185    fn test_undo_with_backup() {
186        let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
187        let _ = fs::remove_dir_all(&tmpdir);
188        fs::create_dir_all(&tmpdir).unwrap();
189
190        let test_file = tmpdir.join("test.txt");
191        fs::write(&test_file, "original content").unwrap();
192
193        let backup_dir = tmpdir.join("backups");
194        let job_id = "test-job-123";
195        let job_backup_dir = backup_dir.join(job_id);
196        fs::create_dir_all(&job_backup_dir).unwrap();
197
198        let backup_path = job_backup_dir.join("test.txt");
199        fs::copy(&test_file, &backup_path).unwrap();
200
201        // Modify original
202        fs::write(&test_file, "modified content").unwrap();
203
204        // Write a WAL entry so undo can find the backup→original mapping
205        let wal_file = tmpdir.join("test.wal");
206        std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
207        use crate::wal::{WalEvent, WalEventType, WalWriter};
208        let mut wal = WalWriter::create(&wal_file).unwrap();
209        let ts = std::time::SystemTime::now()
210            .duration_since(std::time::UNIX_EPOCH)
211            .unwrap()
212            .as_secs();
213        wal.append(WalEvent {
214            seq: 0,
215            ts,
216            event_type: WalEventType::JobCompleted,
217            job_id: job_id.to_string(),
218            capability: Some("FileWrite".into()),
219            output: Some(serde_json::json!({
220                "data": {
221                    "path": test_file.to_str().unwrap(),
222                    "backup_path": backup_path.to_str().unwrap()
223                }
224            })),
225            error: None,
226            telemetry_before: None,
227            telemetry_after: None,
228            process_before: None,
229            process_after: None,
230            cmd: None,
231            cmd_stdout: None,
232            cmd_stderr: None,
233            cmd_exit_code: None,
234            cmd_corrected: None,
235        })
236        .unwrap();
237
238        std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
239
240        let cap = Undo;
241        let ctx = Context {
242            dry_run: false,
243            job_id: "undo-test-job".to_string(),
244            working_dir: tmpdir.clone(),
245        };
246        let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
247
248        assert!(result.is_ok(), "undo failed: {:?}", result.err());
249        let output = result.unwrap();
250        assert!(output.success, "undo not successful: {:?}", output.message);
251        assert!(
252            !output.data["restored"].as_array().unwrap().is_empty(),
253            "no files restored"
254        );
255
256        // Verify original content restored
257        let restored_content = fs::read_to_string(&test_file).unwrap();
258        assert_eq!(restored_content, "original content");
259
260        // Clean up
261        let _ = fs::remove_dir_all(&tmpdir);
262        std::env::remove_var("RUNTIMO_WAL_PATH");
263        std::env::remove_var("RUNTIMO_BACKUP_DIR");
264    }
265}