Skip to main content

codemod_core/transform/
rollback.rs

1//! Rollback management for undoing applied transformations.
2//!
3//! Before any file is modified, the original content is saved so that the
4//! user can undo the changes later. Rollback data is stored as JSON files
5//! inside a `.codemod-pilot/rollback/` directory under the project root.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::CodemodError;
13
14// ---------------------------------------------------------------------------
15// Public types
16// ---------------------------------------------------------------------------
17
18/// An entry in the rollback history.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct RollbackEntry {
21    /// Path to the rollback JSON file.
22    pub path: PathBuf,
23    /// ISO-8601 timestamp of when the rollback was created.
24    pub timestamp: String,
25    /// Number of files affected.
26    pub file_count: usize,
27    /// Human-readable description.
28    pub description: String,
29}
30
31/// Internal representation stored on disk.
32#[derive(Debug, Serialize, Deserialize)]
33struct RollbackData {
34    timestamp: String,
35    description: String,
36    files: Vec<RollbackFile>,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40struct RollbackFile {
41    path: PathBuf,
42    original_content: String,
43}
44
45// ---------------------------------------------------------------------------
46// RollbackManager
47// ---------------------------------------------------------------------------
48
49/// Manages rollback data for transformation undo support.
50///
51/// Rollback files are stored as JSON under `<project_root>/.codemod-pilot/rollback/`.
52pub struct RollbackManager {
53    rollback_dir: PathBuf,
54}
55
56impl RollbackManager {
57    /// Create a new `RollbackManager` for the given project root.
58    ///
59    /// The rollback directory is created lazily on the first [`Self::save_rollback`]
60    /// call.
61    pub fn new(project_root: &Path) -> crate::Result<Self> {
62        let rollback_dir = project_root.join(".codemod-pilot").join("rollback");
63        Ok(Self { rollback_dir })
64    }
65
66    /// Save a rollback entry for the given transformation results.
67    ///
68    /// Returns the path to the saved rollback JSON file.
69    pub fn save_rollback(&self, results: &[super::TransformResult]) -> crate::Result<PathBuf> {
70        // Ensure directory exists.
71        fs::create_dir_all(&self.rollback_dir)?;
72
73        let now = chrono::Utc::now();
74        let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
75        let iso_timestamp = now.to_rfc3339();
76
77        let data = RollbackData {
78            timestamp: iso_timestamp.clone(),
79            description: format!(
80                "Rollback for {} file(s) transformed at {}",
81                results.len(),
82                iso_timestamp
83            ),
84            files: results
85                .iter()
86                .map(|r| RollbackFile {
87                    path: r.file_path.clone(),
88                    original_content: r.original_content.clone(),
89                })
90                .collect(),
91        };
92
93        let filename = format!("rollback_{timestamp}.json");
94        let file_path = self.rollback_dir.join(&filename);
95
96        let json = serde_json::to_string_pretty(&data).map_err(|e| {
97            CodemodError::Transform(format!("Failed to serialize rollback data: {e}"))
98        })?;
99
100        fs::write(&file_path, json)?;
101
102        log::info!("Saved rollback to {}", file_path.display());
103        Ok(file_path)
104    }
105
106    /// Apply a rollback from a saved JSON file, restoring original file
107    /// contents.
108    ///
109    /// Returns the number of files restored.
110    pub fn apply_rollback(&self, patch_path: &Path) -> crate::Result<usize> {
111        let content = fs::read_to_string(patch_path)?;
112        let data: RollbackData = serde_json::from_str(&content)
113            .map_err(|e| CodemodError::Transform(format!("Failed to parse rollback file: {e}")))?;
114
115        let mut restored = 0usize;
116        for file in &data.files {
117            if file.path.exists() {
118                fs::write(&file.path, &file.original_content)?;
119                restored += 1;
120                log::info!("Restored {}", file.path.display());
121            } else {
122                log::warn!("Skipping {} — file does not exist", file.path.display());
123            }
124        }
125
126        Ok(restored)
127    }
128
129    /// List all available rollback entries, most recent first.
130    pub fn list_rollbacks(&self) -> crate::Result<Vec<RollbackEntry>> {
131        if !self.rollback_dir.exists() {
132            return Ok(Vec::new());
133        }
134
135        let mut entries = Vec::new();
136        for entry in fs::read_dir(&self.rollback_dir)? {
137            let entry = entry?;
138            let path = entry.path();
139
140            if path.extension().and_then(|e| e.to_str()) != Some("json") {
141                continue;
142            }
143
144            match self.read_rollback_entry(&path) {
145                Ok(re) => entries.push(re),
146                Err(e) => {
147                    log::warn!("Skipping malformed rollback file {}: {e}", path.display());
148                }
149            }
150        }
151
152        // Sort by timestamp descending (newest first).
153        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
154
155        Ok(entries)
156    }
157
158    // -----------------------------------------------------------------
159    // Internal helpers
160    // -----------------------------------------------------------------
161
162    /// Read a single rollback file and produce a [`RollbackEntry`].
163    fn read_rollback_entry(&self, path: &Path) -> crate::Result<RollbackEntry> {
164        let content = fs::read_to_string(path)?;
165        let data: RollbackData = serde_json::from_str(&content)
166            .map_err(|e| CodemodError::Transform(format!("Failed to parse rollback file: {e}")))?;
167
168        Ok(RollbackEntry {
169            path: path.to_path_buf(),
170            timestamp: data.timestamp,
171            file_count: data.files.len(),
172            description: data.description,
173        })
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::transform::TransformResult;
181
182    #[test]
183    fn test_rollback_roundtrip() {
184        let tmp = std::env::temp_dir().join("codemod_test_rollback");
185        let _ = fs::remove_dir_all(&tmp);
186        fs::create_dir_all(&tmp).unwrap();
187
188        // Create a dummy file to transform.
189        let test_file = tmp.join("test.rs");
190        fs::write(&test_file, "original content").unwrap();
191
192        let manager = RollbackManager::new(&tmp).unwrap();
193
194        let results = vec![TransformResult {
195            file_path: test_file.clone(),
196            match_count: 1,
197            applied_count: 1,
198            diff: String::new(),
199            original_content: "original content".into(),
200            new_content: "new content".into(),
201        }];
202
203        // Simulate applying the transform.
204        fs::write(&test_file, "new content").unwrap();
205
206        // Save rollback.
207        let patch_path = manager.save_rollback(&results).unwrap();
208        assert!(patch_path.exists());
209
210        // List rollbacks.
211        let entries = manager.list_rollbacks().unwrap();
212        assert_eq!(entries.len(), 1);
213        assert_eq!(entries[0].file_count, 1);
214
215        // Apply rollback.
216        let restored = manager.apply_rollback(&patch_path).unwrap();
217        assert_eq!(restored, 1);
218        assert_eq!(fs::read_to_string(&test_file).unwrap(), "original content");
219
220        // Cleanup.
221        let _ = fs::remove_dir_all(&tmp);
222    }
223}