forge_agent/
transaction.rs1use crate::AgentError;
9use std::path::{Path, PathBuf};
10use uuid::Uuid;
11
12#[derive(Clone, Debug)]
17pub struct FileSnapshot {
18 pub path: PathBuf,
20 pub original_content: String,
22 pub checksum: String,
24}
25
26impl FileSnapshot {
27 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 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#[derive(Clone, Debug, PartialEq)]
50pub enum TransactionState {
51 Active,
53 RolledBack,
55 Committed(String),
57}
58
59#[derive(Clone)]
65pub struct Transaction {
66 id: Uuid,
68 snapshots: Vec<FileSnapshot>,
70 state: TransactionState,
72}
73
74impl Transaction {
75 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 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 match tokio::fs::read_to_string(path).await {
107 Ok(content) => {
108 self.snapshots.push(FileSnapshot::new(path_buf, content));
110 }
111 Err(_) => {
112 self.snapshots.push(FileSnapshot::new_empty(path_buf));
115 }
116 }
117
118 Ok(())
119 }
120
121 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 for snapshot in self.snapshots.iter().rev() {
139 if snapshot.checksum == "0" && snapshot.original_content.is_empty() {
141 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 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 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 pub fn id(&self) -> Uuid {
189 self.id
190 }
191
192 pub fn state(&self) -> &TransactionState {
194 &self.state
195 }
196
197 #[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 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 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 tx.rollback().await.unwrap();
255
256 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 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 tx.rollback().await.unwrap();
273
274 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 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 tx.rollback().await.unwrap();
296
297 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 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 }
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 }
348}