Skip to main content

souk_core/ops/
atomic.rs

1//! RAII-based atomic file guard for safe marketplace mutations.
2//!
3//! [`AtomicGuard`] creates a timestamped backup of a file before mutation begins.
4//! If the operation succeeds, call [`AtomicGuard::commit`] to remove the backup.
5//! If the guard is dropped without committing (e.g., due to an early return or
6//! error propagation), the original file is automatically restored from the backup.
7//!
8//! # Examples
9//!
10//! ```no_run
11//! use souk_core::ops::AtomicGuard;
12//! use std::path::Path;
13//!
14//! fn update_marketplace(path: &Path) -> Result<(), souk_core::SoukError> {
15//!     let guard = AtomicGuard::new(path)?;
16//!
17//!     // ... modify the file at `path` ...
18//!
19//!     // Success: remove the backup.
20//!     guard.commit()?;
21//!     Ok(())
22//! }
23//! ```
24
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use crate::SoukError;
30
31/// An RAII guard that backs up a file before mutation and restores it on drop
32/// unless explicitly committed.
33///
34/// The backup file is named `{original}.bak.{epoch_nanos}.{pid}` and lives alongside
35/// the original. This mirrors the pattern used by the shell-based atomic helpers
36/// in `temp-reference-scripts/lib/atomic.sh`.
37///
38/// # Behavior
39///
40/// - **`new(path)`**: Creates a backup copy of the file. If the file does not
41///   exist, no backup is created and the guard is a no-op on drop.
42/// - **`commit(self)`**: Removes the backup file and consumes the guard so that
43///   `Drop` does not run the restore logic.
44/// - **`Drop`**: If the guard was not committed and a backup exists, restores
45///   the original file from the backup.
46pub struct AtomicGuard {
47    /// Path to the original file being guarded.
48    original_path: PathBuf,
49    /// Path to the backup file, or `None` if no backup was created
50    /// (e.g., the original file did not exist).
51    backup_path: Option<PathBuf>,
52    /// Whether [`commit`](AtomicGuard::commit) has been called.
53    committed: bool,
54}
55
56impl AtomicGuard {
57    /// Creates a new `AtomicGuard` for the file at `path`.
58    ///
59    /// If the file exists, a timestamped backup is created immediately. If
60    /// the file does not exist (e.g., it will be created fresh by the
61    /// operation), no backup is made and the guard becomes a no-op on drop.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`SoukError::Io`] if the file exists but cannot be copied.
66    pub fn new(path: &Path) -> Result<Self, SoukError> {
67        let original_path = path.to_path_buf();
68
69        let backup_path = if original_path.exists() {
70            let nanos = SystemTime::now()
71                .duration_since(UNIX_EPOCH)
72                .expect("system clock is before UNIX epoch")
73                .as_nanos();
74            let pid = std::process::id();
75
76            let backup = original_path.with_extension(format!(
77                "{}.bak.{}.{}",
78                original_path
79                    .extension()
80                    .and_then(|e| e.to_str())
81                    .unwrap_or(""),
82                nanos,
83                pid
84            ));
85
86            fs::copy(&original_path, &backup)?;
87            Some(backup)
88        } else {
89            None
90        };
91
92        Ok(Self {
93            original_path,
94            backup_path,
95            committed: false,
96        })
97    }
98
99    /// Returns the path to the backup file, if one was created.
100    pub fn backup_path(&self) -> Option<&Path> {
101        self.backup_path.as_deref()
102    }
103
104    /// Returns the path to the original file being guarded.
105    pub fn original_path(&self) -> &Path {
106        &self.original_path
107    }
108
109    /// Commits the operation, removing the backup file.
110    ///
111    /// This consumes the guard so that `Drop` will not attempt to restore
112    /// the original file. Call this after the mutation has been verified
113    /// as successful.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`SoukError::Io`] if the backup file exists but cannot be
118    /// removed. Even on error, the guard is marked as committed so that
119    /// `Drop` will not attempt a restore (the mutation already succeeded).
120    pub fn commit(mut self) -> Result<(), SoukError> {
121        self.committed = true;
122        if let Some(ref backup) = self.backup_path {
123            if backup.exists() {
124                fs::remove_file(backup)?;
125            }
126        }
127        Ok(())
128    }
129}
130
131impl Drop for AtomicGuard {
132    fn drop(&mut self) {
133        if self.committed {
134            return;
135        }
136
137        if let Some(ref backup) = self.backup_path {
138            if backup.exists() {
139                if let Err(e) = fs::copy(backup, &self.original_path) {
140                    eprintln!(
141                        "Warning: failed to restore {} from backup {}: {}",
142                        self.original_path.display(),
143                        backup.display(),
144                        e
145                    );
146                    return; // Don't remove backup if restore failed
147                }
148                if let Err(e) = fs::remove_file(backup) {
149                    eprintln!(
150                        "Warning: failed to remove backup file {}: {}",
151                        backup.display(),
152                        e
153                    );
154                }
155            }
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::fs;
164    use tempfile::TempDir;
165
166    /// Helper to create a temp directory with a file containing the given content.
167    fn setup_file(content: &str) -> (TempDir, PathBuf) {
168        let dir = TempDir::new().expect("failed to create temp dir");
169        let file_path = dir.path().join("marketplace.json");
170        fs::write(&file_path, content).expect("failed to write test file");
171        (dir, file_path)
172    }
173
174    #[test]
175    fn backup_is_created_on_new() {
176        let (_dir, file_path) = setup_file(r#"{"version":"1.0.0"}"#);
177
178        let guard = AtomicGuard::new(&file_path).expect("guard creation failed");
179
180        // A backup file should exist.
181        let backup = guard.backup_path().expect("expected a backup path");
182        assert!(backup.exists(), "backup file should exist on disk");
183
184        // Backup should contain the same content as the original.
185        let backup_content = fs::read_to_string(backup).unwrap();
186        assert_eq!(backup_content, r#"{"version":"1.0.0"}"#);
187
188        // Clean up by committing.
189        guard.commit().unwrap();
190    }
191
192    #[test]
193    fn commit_removes_backup() {
194        let (_dir, file_path) = setup_file(r#"{"version":"1.0.0"}"#);
195
196        let guard = AtomicGuard::new(&file_path).expect("guard creation failed");
197        let backup = guard
198            .backup_path()
199            .expect("expected a backup path")
200            .to_path_buf();
201
202        assert!(backup.exists(), "backup should exist before commit");
203
204        guard.commit().unwrap();
205
206        assert!(!backup.exists(), "backup should be removed after commit");
207
208        // Original should still be intact.
209        let content = fs::read_to_string(&file_path).unwrap();
210        assert_eq!(content, r#"{"version":"1.0.0"}"#);
211    }
212
213    #[test]
214    fn drop_restores_original_on_failure() {
215        let (_dir, file_path) = setup_file(r#"{"version":"1.0.0"}"#);
216
217        {
218            let _guard = AtomicGuard::new(&file_path).expect("guard creation failed");
219
220            // Simulate a mutation that corrupts the file.
221            fs::write(&file_path, r#"{"CORRUPTED":true}"#).unwrap();
222
223            // Guard drops here without commit -- should restore the original.
224        }
225
226        let restored = fs::read_to_string(&file_path).unwrap();
227        assert_eq!(
228            restored, r#"{"version":"1.0.0"}"#,
229            "original file should be restored after drop"
230        );
231    }
232
233    #[test]
234    fn drop_after_commit_does_not_restore() {
235        let (_dir, file_path) = setup_file(r#"{"version":"1.0.0"}"#);
236
237        {
238            let guard = AtomicGuard::new(&file_path).expect("guard creation failed");
239
240            // Mutate the file (legitimate update).
241            fs::write(&file_path, r#"{"version":"2.0.0"}"#).unwrap();
242
243            guard.commit().unwrap();
244            // Guard drops here, but committed -- should NOT restore.
245        }
246
247        let content = fs::read_to_string(&file_path).unwrap();
248        assert_eq!(
249            content, r#"{"version":"2.0.0"}"#,
250            "committed mutation should persist"
251        );
252    }
253
254    #[test]
255    fn guard_on_nonexistent_file_is_noop() {
256        let dir = TempDir::new().expect("failed to create temp dir");
257        let file_path = dir.path().join("does_not_exist.json");
258
259        assert!(!file_path.exists());
260
261        let guard = AtomicGuard::new(&file_path).expect("guard creation should succeed");
262        assert!(
263            guard.backup_path().is_none(),
264            "no backup should be created for non-existent file"
265        );
266
267        // Dropping without commit should be safe.
268        drop(guard);
269
270        // File still should not exist (guard didn't create it).
271        assert!(!file_path.exists());
272    }
273
274    #[test]
275    fn guard_on_nonexistent_file_commit_is_noop() {
276        let dir = TempDir::new().expect("failed to create temp dir");
277        let file_path = dir.path().join("does_not_exist.json");
278
279        let guard = AtomicGuard::new(&file_path).expect("guard creation should succeed");
280        // Committing a guard with no backup should succeed silently.
281        guard.commit().unwrap();
282    }
283
284    #[test]
285    fn drop_cleans_up_backup_file() {
286        let (_dir, file_path) = setup_file(r#"{"version":"1.0.0"}"#);
287        let backup_path;
288
289        {
290            let guard = AtomicGuard::new(&file_path).expect("guard creation failed");
291            backup_path = guard.backup_path().unwrap().to_path_buf();
292            assert!(backup_path.exists());
293
294            // Mutate the file.
295            fs::write(&file_path, r#"{"CORRUPTED":true}"#).unwrap();
296
297            // Drop without commit -- restore + cleanup.
298        }
299
300        assert!(
301            !backup_path.exists(),
302            "backup file should be removed after drop restores"
303        );
304    }
305
306    #[test]
307    fn multiple_guards_on_different_files() {
308        let dir = TempDir::new().expect("failed to create temp dir");
309        let file_a = dir.path().join("a.json");
310        let file_b = dir.path().join("b.json");
311        fs::write(&file_a, "aaa").unwrap();
312        fs::write(&file_b, "bbb").unwrap();
313
314        let guard_a = AtomicGuard::new(&file_a).unwrap();
315        let guard_b = AtomicGuard::new(&file_b).unwrap();
316
317        // Mutate both.
318        fs::write(&file_a, "AAA").unwrap();
319        fs::write(&file_b, "BBB").unwrap();
320
321        // Commit A, drop B (restore).
322        guard_a.commit().unwrap();
323        drop(guard_b);
324
325        assert_eq!(fs::read_to_string(&file_a).unwrap(), "AAA");
326        assert_eq!(fs::read_to_string(&file_b).unwrap(), "bbb");
327    }
328
329    #[test]
330    fn backup_path_includes_original_extension() {
331        let (_dir, file_path) = setup_file("test");
332
333        let guard = AtomicGuard::new(&file_path).unwrap();
334        let backup = guard.backup_path().unwrap();
335
336        let backup_name = backup.file_name().unwrap().to_str().unwrap();
337        assert!(
338            backup_name.contains("json.bak."),
339            "backup name '{backup_name}' should contain 'json.bak.'"
340        );
341        // Also verify PID is appended
342        let pid = std::process::id().to_string();
343        assert!(
344            backup_name.ends_with(&pid),
345            "backup name '{backup_name}' should end with PID '{pid}'"
346        );
347
348        guard.commit().unwrap();
349    }
350
351    #[test]
352    fn rapid_guards_produce_unique_backups() {
353        let dir = TempDir::new().expect("failed to create temp dir");
354        let file_path = dir.path().join("marketplace.json");
355        fs::write(&file_path, "original").unwrap();
356
357        let guard1 = AtomicGuard::new(&file_path).unwrap();
358        let guard2 = AtomicGuard::new(&file_path).unwrap();
359
360        let bp1 = guard1.backup_path().unwrap().to_path_buf();
361        let bp2 = guard2.backup_path().unwrap().to_path_buf();
362
363        assert_ne!(
364            bp1, bp2,
365            "two guards created rapidly should have different backup paths"
366        );
367
368        // Both backups should exist and contain the original content
369        assert!(bp1.exists());
370        assert!(bp2.exists());
371        assert_eq!(fs::read_to_string(&bp1).unwrap(), "original");
372        assert_eq!(fs::read_to_string(&bp2).unwrap(), "original");
373
374        guard1.commit().unwrap();
375        guard2.commit().unwrap();
376    }
377}