1use std::fs;
26use std::path::{Path, PathBuf};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use crate::SoukError;
30
31pub struct AtomicGuard {
47 original_path: PathBuf,
49 backup_path: Option<PathBuf>,
52 committed: bool,
54}
55
56impl AtomicGuard {
57 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 pub fn backup_path(&self) -> Option<&Path> {
101 self.backup_path.as_deref()
102 }
103
104 pub fn original_path(&self) -> &Path {
106 &self.original_path
107 }
108
109 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; }
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 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 let backup = guard.backup_path().expect("expected a backup path");
182 assert!(backup.exists(), "backup file should exist on disk");
183
184 let backup_content = fs::read_to_string(backup).unwrap();
186 assert_eq!(backup_content, r#"{"version":"1.0.0"}"#);
187
188 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 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 fs::write(&file_path, r#"{"CORRUPTED":true}"#).unwrap();
222
223 }
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 fs::write(&file_path, r#"{"version":"2.0.0"}"#).unwrap();
242
243 guard.commit().unwrap();
244 }
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 drop(guard);
269
270 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 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 fs::write(&file_path, r#"{"CORRUPTED":true}"#).unwrap();
296
297 }
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 fs::write(&file_a, "AAA").unwrap();
319 fs::write(&file_b, "BBB").unwrap();
320
321 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 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 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}