Skip to main content

forge_agent/
mutate.rs

1//! Mutation engine - Transaction-based code mutation.
2//!
3//! This module implements the mutation phase, applying changes through
4//! the edit module with transaction support.
5
6use crate::{AgentError, Result};
7use crate::transaction::Transaction;
8use std::path::Path;
9use uuid::Uuid;
10use tokio::fs;
11
12/// Mutator for transaction-based code changes.
13///
14/// The Mutator uses the Transaction module to apply changes atomically,
15/// snapshotting files before mutation and providing rollback capability.
16#[derive(Clone, Default)]
17pub struct Mutator {
18    /// Current transaction state
19    transaction: Option<Transaction>,
20}
21
22impl Mutator {
23    /// Creates a new mutator.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Begins a new transaction.
29    pub async fn begin_transaction(&mut self) -> Result<()> {
30        if self.transaction.is_some() {
31            return Err(AgentError::MutationFailed(
32                "Transaction already in progress".to_string(),
33            ));
34        }
35
36        self.transaction = Some(Transaction::begin().await?);
37        Ok(())
38    }
39
40    /// Applies a single step in the current transaction.
41    ///
42    /// Snapshots each file before mutation for rollback capability.
43    pub async fn apply_step(&mut self, step: &crate::planner::PlanStep) -> Result<()> {
44        let transaction = self
45            .transaction
46            .as_mut()
47            .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))?;
48
49        match &step.operation {
50            crate::planner::PlanOperation::Rename { old, new: _ } => {
51                // Snapshot the old file for rollback
52                let old_path = Path::new(old);
53                let _ = transaction.snapshot_file(old_path).await;
54
55                // Apply rename (placeholder for v0.3 - actual rename logic would go here)
56                // For now, just record that the operation was processed
57            }
58            crate::planner::PlanOperation::Delete { name } => {
59                // Snapshot the file for rollback (restore on rollback)
60                let name_path = Path::new(name);
61                transaction.snapshot_file(name_path).await?;
62
63                // Apply delete (placeholder for v0.3)
64            }
65            crate::planner::PlanOperation::Create { path, content } => {
66                // Snapshot for rollback (will delete on rollback if file didn't exist)
67                let p = Path::new(path);
68                let _ = transaction.snapshot_file(p).await; // Ignore error if file doesn't exist
69
70                // Write new content
71                fs::write(path, content).await.map_err(|e| {
72                    AgentError::MutationFailed(format!("Failed to write {}: {}", path, e))
73                })?;
74            }
75            crate::planner::PlanOperation::Inspect { .. } => {
76                // No snapshot needed for read-only operations
77            }
78            crate::planner::PlanOperation::Modify { file, .. } => {
79                // Snapshot the file for rollback
80                let file_path = Path::new(file);
81                transaction.snapshot_file(file_path).await?;
82
83                // Apply modification (placeholder for v0.3 - actual edit logic would go here)
84            }
85        }
86
87        Ok(())
88    }
89
90    /// Rolls back the current transaction.
91    ///
92    /// Takes the transaction, rolls back all changes, and returns Ok.
93    pub async fn rollback(&mut self) -> Result<()> {
94        let transaction = self
95            .transaction
96            .take()
97            .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))?;
98
99        transaction.rollback().await?;
100        Ok(())
101    }
102
103    /// Commits the current transaction.
104    ///
105    /// Takes the transaction, commits it, and returns the commit ID.
106    pub async fn commit_transaction(mut self) -> Result<Uuid> {
107        let transaction = self
108            .transaction
109            .take()
110            .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))?;
111
112        transaction.commit().await
113    }
114
115    /// Extracts the transaction from the mutator.
116    ///
117    /// This is used when transferring the transaction to another component
118    /// (e.g., from Mutator to AgentLoop).
119    pub fn into_transaction(mut self) -> Result<Transaction> {
120        self.transaction
121            .take()
122            .ok_or_else(|| AgentError::MutationFailed("No active transaction".to_string()))
123    }
124
125    /// Returns preview of changes without applying.
126    pub async fn preview(&self, steps: &[crate::planner::PlanStep]) -> Result<Vec<String>> {
127        let mut previews = Vec::new();
128
129        for step in steps {
130            match &step.operation {
131                crate::planner::PlanOperation::Create { path, content } => {
132                    previews.push(format!("Create {}:\n{}", path, content));
133                }
134                crate::planner::PlanOperation::Delete { name } => {
135                    previews.push(format!("Delete {}", name));
136                }
137                crate::planner::PlanOperation::Rename { old, new } => {
138                    previews.push(format!("Rename {} to {}", old, new));
139                }
140                _ => {}
141            }
142        }
143
144        Ok(previews)
145    }
146
147    /// Returns a reference to the current transaction (for testing).
148    #[cfg(test)]
149    pub fn transaction(&self) -> Option<&Transaction> {
150        self.transaction.as_ref()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use tempfile::TempDir;
158
159    #[tokio::test]
160    async fn test_mutator_creation() {
161        let mutator = Mutator::new();
162
163        assert!(mutator.transaction().is_none());
164    }
165
166    #[tokio::test]
167    async fn test_begin_transaction() {
168        let mut mutator = Mutator::new();
169
170        mutator.begin_transaction().await.unwrap();
171        assert!(mutator.transaction().is_some());
172
173        // Second begin should fail
174        assert!(mutator.begin_transaction().await.is_err());
175    }
176
177    #[tokio::test]
178    async fn test_rollback() {
179        let mut mutator = Mutator::new();
180
181        mutator.begin_transaction().await.unwrap();
182        assert!(mutator.transaction().is_some());
183
184        mutator.rollback().await.unwrap();
185        assert!(mutator.transaction().is_none());
186    }
187
188    #[tokio::test]
189    async fn test_apply_step_create_snapshots_file() {
190        let temp_dir = TempDir::new().unwrap();
191        let file_path = temp_dir.path().join("test.rs");
192
193        let mut mutator = Mutator::new();
194        mutator.begin_transaction().await.unwrap();
195
196        let step = crate::planner::PlanStep {
197            description: "Create test file".to_string(),
198            operation: crate::planner::PlanOperation::Create {
199                path: file_path.to_string_lossy().to_string(),
200                content: "fn test() {}".to_string(),
201            },
202        };
203
204        mutator.apply_step(&step).await.unwrap();
205
206        // File should be created
207        assert!(file_path.exists());
208        // Transaction should have snapshot
209        assert!(mutator.transaction().is_some());
210
211        // Cleanup
212        mutator.rollback().await.unwrap();
213    }
214
215    #[tokio::test]
216    async fn test_apply_step_modify_snapshots_file() {
217        let temp_dir = TempDir::new().unwrap();
218        let file_path = temp_dir.path().join("test.rs");
219        tokio::fs::write(&file_path, "original content").await.unwrap();
220
221        let mut mutator = Mutator::new();
222        mutator.begin_transaction().await.unwrap();
223
224        let step = crate::planner::PlanStep {
225            description: "Modify test file".to_string(),
226            operation: crate::planner::PlanOperation::Modify {
227                file: file_path.to_string_lossy().to_string(),
228                start: 0,
229                end: 8,
230            },
231        };
232
233        mutator.apply_step(&step).await.unwrap();
234
235        // Transaction should have snapshot
236        assert!(mutator.transaction().is_some());
237
238        // Cleanup
239        mutator.rollback().await.unwrap();
240        // File should still exist with original content after rollback
241        assert!(file_path.exists());
242        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
243        assert_eq!(content, "original content");
244    }
245
246    #[tokio::test]
247    async fn test_commit_transaction() {
248        let mut mutator = Mutator::new();
249        mutator.begin_transaction().await.unwrap();
250
251        let commit_id = mutator.commit_transaction().await.unwrap();
252
253        // commit_transaction consumes self, so we can't check mutator after
254        // But we can verify the commit ID is valid
255        assert_ne!(commit_id, Uuid::default());
256    }
257
258    #[tokio::test]
259    async fn test_into_transaction() {
260        let mut mutator = Mutator::new();
261        mutator.begin_transaction().await.unwrap();
262
263        let transaction = mutator.into_transaction().unwrap();
264
265        // into_transaction consumes self, so we can't check mutator after
266        // But we can verify the transaction is in Active state
267        assert_eq!(transaction.state(), &crate::transaction::TransactionState::Active);
268    }
269
270    #[tokio::test]
271    async fn test_into_transaction_without_active_tx() {
272        let mutator = Mutator::new();
273
274        let result = mutator.into_transaction();
275
276        assert!(result.is_err());
277    }
278
279    #[tokio::test]
280    async fn test_rollback_restores_file_content() {
281        let temp_dir = TempDir::new().unwrap();
282        let file_path = temp_dir.path().join("test.rs");
283        tokio::fs::write(&file_path, "original content").await.unwrap();
284
285        let mut mutator = Mutator::new();
286        mutator.begin_transaction().await.unwrap();
287
288        // Modify the file
289        let step = crate::planner::PlanStep {
290            description: "Create test file".to_string(),
291            operation: crate::planner::PlanOperation::Create {
292                path: file_path.to_string_lossy().to_string(),
293                content: "modified content".to_string(),
294            },
295        };
296
297        mutator.apply_step(&step).await.unwrap();
298
299        // File should be modified
300        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
301        assert_eq!(content, "modified content");
302
303        // Rollback
304        mutator.rollback().await.unwrap();
305
306        // File should be restored
307        let content = tokio::fs::read_to_string(&file_path).await.unwrap();
308        assert_eq!(content, "original content");
309    }
310
311    #[tokio::test]
312    async fn test_preview() {
313        let mutator = Mutator::new();
314
315        let steps = vec![
316            crate::planner::PlanStep {
317                description: "Create file".to_string(),
318                operation: crate::planner::PlanOperation::Create {
319                    path: "/tmp/test.rs".to_string(),
320                    content: "fn test() {}".to_string(),
321                },
322            },
323            crate::planner::PlanStep {
324                description: "Delete file".to_string(),
325                operation: crate::planner::PlanOperation::Delete {
326                    name: "old.rs".to_string(),
327                },
328            },
329        ];
330
331        let previews = mutator.preview(&steps).await.unwrap();
332
333        assert_eq!(previews.len(), 2);
334        assert!(previews[0].contains("Create"));
335        assert!(previews[0].contains("fn test() {}"));
336        assert!(previews[1].contains("Delete"));
337    }
338}