Skip to main content

forge_agent/
transaction.rs

1//! Transaction management - File snapshotting and atomic rollback.
2//!
3//! This module implements transaction management with file snapshotting
4//! and automatic rollback on failure. Transactions provide atomicity -
5//! either all changes commit successfully, or the system rolls back
6//! to the original state.
7
8use crate::AgentError;
9use std::path::{Path, PathBuf};
10use uuid::Uuid;
11
12/// Snapshot of a file's original content.
13///
14/// Each snapshot stores the file path, original content, and a checksum
15/// for verification during rollback.
16#[derive(Clone, Debug)]
17pub struct FileSnapshot {
18    /// Path to the file
19    pub path: PathBuf,
20    /// Original content before mutation
21    pub original_content: String,
22    /// Checksum for verification (content length for v0.3)
23    pub checksum: String,
24}
25
26impl FileSnapshot {
27    /// Creates a new file snapshot.
28    fn new(path: PathBuf, original_content: String) -> Self {
29        let checksum = format!("{}", original_content.len());
30        Self {
31            path,
32            original_content,
33            checksum,
34        }
35    }
36
37    /// Creates an empty snapshot for files that don't exist yet.
38    /// During rollback, this indicates the file should be deleted.
39    fn new_empty(path: PathBuf) -> Self {
40        Self {
41            path,
42            original_content: String::new(),
43            checksum: "0".to_string(),
44        }
45    }
46}
47
48/// State of a transaction.
49#[derive(Clone, Debug, PartialEq)]
50pub enum TransactionState {
51    /// Transaction is active and accepting snapshots
52    Active,
53    /// Transaction was rolled back
54    RolledBack,
55    /// Transaction was committed with the given ID
56    Committed(String),
57}
58
59/// Transaction for atomic file operations.
60///
61/// The Transaction manages file snapshots and provides rollback capability.
62/// Files are snapshot before mutation, and can be restored to their
63/// original state if the transaction fails.
64#[derive(Clone)]
65pub struct Transaction {
66    /// Unique transaction ID
67    id: Uuid,
68    /// File snapshots for rollback
69    snapshots: Vec<FileSnapshot>,
70    /// Current transaction state
71    state: TransactionState,
72}
73
74impl Transaction {
75    /// Begins a new transaction with a unique ID.
76    ///
77    /// Creates a fresh transaction with no snapshots and Active state.
78    pub async fn begin() -> Result<Self, AgentError> {
79        Ok(Self {
80            id: Uuid::new_v4(),
81            snapshots: Vec::new(),
82            state: TransactionState::Active,
83        })
84    }
85
86    /// Snapshots a file before mutation.
87    ///
88    /// If the file exists, stores its content for rollback.
89    /// If the file doesn't exist, stores an empty snapshot (indicating
90    /// the file should be deleted on rollback).
91    ///
92    /// # Arguments
93    ///
94    /// * `path` - Path to the file to snapshot
95    pub async fn snapshot_file(&mut self, path: &Path) -> Result<(), AgentError> {
96        if self.state != TransactionState::Active {
97            return Err(AgentError::MutationFailed(format!(
98                "Cannot snapshot file: transaction is {:?}",
99                self.state
100            )));
101        }
102
103        let path_buf = path.to_path_buf();
104
105        // Try to read the file content
106        match tokio::fs::read_to_string(path).await {
107            Ok(content) => {
108                // File exists - snapshot the content
109                self.snapshots.push(FileSnapshot::new(path_buf, content));
110            }
111            Err(_) => {
112                // File doesn't exist - store empty snapshot
113                // On rollback, we'll delete the file if it was created
114                self.snapshots.push(FileSnapshot::new_empty(path_buf));
115            }
116        }
117
118        Ok(())
119    }
120
121    /// Rolls back the transaction, restoring all files to original state.
122    ///
123    /// Iterates through snapshots in reverse order and restores each file.
124    /// Files that didn't exist before are deleted.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the transaction is not in Active state.
129    pub async fn rollback(mut self) -> Result<(), AgentError> {
130        if self.state != TransactionState::Active {
131            return Err(AgentError::MutationFailed(format!(
132                "Cannot rollback: transaction is {:?}",
133                self.state
134            )));
135        }
136
137        // Rollback in reverse order (last modified first)
138        for snapshot in self.snapshots.iter().rev() {
139            // Check if file was created during transaction (empty original content)
140            if snapshot.checksum == "0" && snapshot.original_content.is_empty() {
141                // File didn't exist before - delete it if it exists now
142                if snapshot.path.exists() {
143                    tokio::fs::remove_file(&snapshot.path).await.map_err(|e| {
144                        AgentError::MutationFailed(format!(
145                            "Failed to remove file {}: {}",
146                            snapshot.path.display(),
147                            e
148                        ))
149                    })?;
150                }
151            } else {
152                // File existed before - restore original content
153                tokio::fs::write(&snapshot.path, &snapshot.original_content).await.map_err(
154                    |e| {
155                        AgentError::MutationFailed(format!(
156                            "Failed to restore file {}: {}",
157                            snapshot.path.display(),
158                            e
159                        ))
160                    },
161                )?;
162            }
163        }
164
165        self.state = TransactionState::RolledBack;
166        Ok(())
167    }
168
169    /// Commits the transaction, generating a commit ID.
170    ///
171    /// # Returns
172    ///
173    /// The commit ID (UUID).
174    pub async fn commit(mut self) -> Result<Uuid, AgentError> {
175        if self.state != TransactionState::Active {
176            return Err(AgentError::MutationFailed(format!(
177                "Cannot commit: transaction is {:?}",
178                self.state
179            )));
180        }
181
182        let commit_id = Uuid::new_v4();
183        self.state = TransactionState::Committed(commit_id.to_string());
184        Ok(commit_id)
185    }
186
187    /// Returns the transaction ID.
188    pub fn id(&self) -> Uuid {
189        self.id
190    }
191
192    /// Returns the current transaction state.
193    pub fn state(&self) -> &TransactionState {
194        &self.state
195    }
196
197    /// Returns the number of file snapshots.
198    #[cfg(test)]
199    pub fn snapshot_count(&self) -> usize {
200        self.snapshots.len()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use tempfile::TempDir;
208
209    #[tokio::test]
210    async fn test_transaction_begin() {
211        let tx = Transaction::begin().await.unwrap();
212
213        assert_ne!(tx.id(), Uuid::default());
214        assert_eq!(tx.state(), &TransactionState::Active);
215        assert_eq!(tx.snapshot_count(), 0);
216    }
217
218    #[tokio::test]
219    async fn test_snapshot_existing_file() {
220        let temp_dir = TempDir::new().unwrap();
221        let file_path = temp_dir.path().join("test.rs");
222        tokio::fs::write(&file_path, "original content").await.unwrap();
223
224        let mut tx = Transaction::begin().await.unwrap();
225        tx.snapshot_file(&file_path).await.unwrap();
226
227        assert_eq!(tx.snapshot_count(), 1);
228    }
229
230    #[tokio::test]
231    async fn test_snapshot_nonexistent_file() {
232        let temp_dir = TempDir::new().unwrap();
233        let file_path = temp_dir.path().join("nonexistent.rs");
234
235        let mut tx = Transaction::begin().await.unwrap();
236        tx.snapshot_file(&file_path).await.unwrap();
237
238        // Should store empty snapshot for nonexistent file
239        assert_eq!(tx.snapshot_count(), 1);
240    }
241
242    #[tokio::test]
243    async fn test_rollback_restores_original_content() {
244        let temp_dir = TempDir::new().unwrap();
245        let file_path = temp_dir.path().join("test.rs");
246        tokio::fs::write(&file_path, "original content").await.unwrap();
247
248        // Snapshot and modify
249        let mut tx = Transaction::begin().await.unwrap();
250        tx.snapshot_file(&file_path).await.unwrap();
251        tokio::fs::write(&file_path, "modified content").await.unwrap();
252
253        // Rollback
254        tx.rollback().await.unwrap();
255
256        // Verify original content restored
257        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
258        assert_eq!(content, "original content");
259    }
260
261    #[tokio::test]
262    async fn test_rollback_deletes_created_file() {
263        let temp_dir = TempDir::new().unwrap();
264        let file_path = temp_dir.path().join("new_file.rs");
265
266        // Snapshot nonexistent file, then create it
267        let mut tx = Transaction::begin().await.unwrap();
268        tx.snapshot_file(&file_path).await.unwrap();
269        tokio::fs::write(&file_path, "new content").await.unwrap();
270
271        // Rollback
272        tx.rollback().await.unwrap();
273
274        // Verify file was deleted
275        assert!(!file_path.exists());
276    }
277
278    #[tokio::test]
279    async fn test_rollback_multiple_files() {
280        let temp_dir = TempDir::new().unwrap();
281        let file1 = temp_dir.path().join("file1.rs");
282        let file2 = temp_dir.path().join("file2.rs");
283
284        tokio::fs::write(&file1, "content1").await.unwrap();
285        tokio::fs::write(&file2, "content2").await.unwrap();
286
287        // Snapshot both files and modify
288        let mut tx = Transaction::begin().await.unwrap();
289        tx.snapshot_file(&file1).await.unwrap();
290        tx.snapshot_file(&file2).await.unwrap();
291        tokio::fs::write(&file1, "modified1").await.unwrap();
292        tokio::fs::write(&file2, "modified2").await.unwrap();
293
294        // Rollback
295        tx.rollback().await.unwrap();
296
297        // Verify both files restored
298        assert_eq!(
299            tokio::fs::read_to_string(&file1).await.unwrap(),
300            "content1"
301        );
302        assert_eq!(
303            tokio::fs::read_to_string(&file2).await.unwrap(),
304            "content2"
305        );
306    }
307
308    #[tokio::test]
309    async fn test_commit_generates_id() {
310        let tx = Transaction::begin().await.unwrap();
311        let commit_id = tx.commit().await.unwrap();
312
313        assert_ne!(commit_id, Uuid::default());
314    }
315
316    #[tokio::test]
317    async fn test_commit_updates_state() {
318        let tx = Transaction::begin().await.unwrap();
319        let commit_id = tx.commit().await.unwrap();
320        let _expected_state = TransactionState::Committed(commit_id.to_string());
321
322        // Note: we can't directly check state after commit because tx was moved
323        // But we can verify commit succeeded
324        assert_ne!(commit_id, Uuid::default());
325    }
326
327    #[tokio::test]
328    async fn test_rollback_after_commit_fails() {
329        let tx = Transaction::begin().await.unwrap();
330        let _commit_id = tx.commit().await.unwrap();
331
332        // Transaction was consumed by commit, can't rollback
333        // This is expected behavior - transaction is consumed on commit
334    }
335
336    #[tokio::test]
337    async fn test_snapshot_after_rollback_fails() {
338        let temp_dir = TempDir::new().unwrap();
339        let file_path = temp_dir.path().join("test.rs");
340
341        let mut tx = Transaction::begin().await.unwrap();
342        tx.snapshot_file(&file_path).await.unwrap();
343        tx.rollback().await.unwrap();
344
345        // Can't snapshot after rollback - transaction was consumed
346        // This is expected behavior
347    }
348}