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