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
198    #[test]
199    fn test_undo_with_backup() {
200        let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
201        let _ = fs::remove_dir_all(&tmpdir);
202        fs::create_dir_all(&tmpdir).unwrap();
203
204        let test_file = tmpdir.join("test.txt");
205        fs::write(&test_file, "original content").unwrap();
206
207        let backup_dir = tmpdir.join("backups");
208        let job_id = "test-job-123";
209        let job_backup_dir = backup_dir.join(job_id);
210        fs::create_dir_all(&job_backup_dir).unwrap();
211
212        let backup_path = job_backup_dir.join("test.txt");
213        fs::copy(&test_file, &backup_path).unwrap();
214
215        // Modify original
216        fs::write(&test_file, "modified content").unwrap();
217
218        // Write a WAL entry so undo can find the backup→original mapping
219        let wal_file = tmpdir.join("test.wal");
220        std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
221        use crate::wal::{WalEvent, WalEventType, WalWriter};
222        let mut wal = WalWriter::create(&wal_file).unwrap();
223        let ts = std::time::SystemTime::now()
224            .duration_since(std::time::UNIX_EPOCH)
225            .unwrap()
226            .as_secs();
227        wal.append(WalEvent {
228            seq: 0,
229            ts,
230            event_type: WalEventType::JobCompleted,
231            job_id: job_id.to_string(),
232            capability: Some("FileWrite".into()),
233            output: Some(serde_json::json!({
234                "data": {
235                    "path": test_file.to_str().unwrap(),
236                    "backup_path": backup_path.to_str().unwrap()
237                }
238            })),
239            error: None,
240            telemetry_before: None,
241            telemetry_after: None,
242            process_before: None,
243            process_after: None,
244            cmd: None,
245            cmd_stdout: None,
246            cmd_stderr: None,
247            cmd_exit_code: None,
248            cmd_corrected: None,
249            ..Default::default()
250        })
251        .unwrap();
252
253        std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
254
255        let cap = Undo;
256        let ctx = Context {
257            dry_run: false,
258            job_id: "undo-test-job".to_string(),
259            working_dir: tmpdir.clone(),
260        };
261        let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
262
263        assert!(result.is_ok(), "undo failed: {:?}", result.err());
264        let output = result.unwrap();
265        assert!(output.success, "undo not successful: {:?}", output.message);
266        assert!(
267            !output.data["restored"].as_array().unwrap().is_empty(),
268            "no files restored"
269        );
270
271        // Verify original content restored
272        let restored_content = fs::read_to_string(&test_file).unwrap();
273        assert_eq!(restored_content, "original content");
274
275        // Clean up
276        let _ = fs::remove_dir_all(&tmpdir);
277        std::env::remove_var("RUNTIMO_WAL_PATH");
278        std::env::remove_var("RUNTIMO_BACKUP_DIR");
279    }
280}