Skip to main content

mvm_core/
atomic_io.rs

1//! Atomic file I/O and file-based locking utilities.
2//!
3//! Prevents partial writes (crash-safe) and concurrent mutation of state files.
4
5use std::fs;
6use std::io::Write;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use fs2::FileExt;
11
12/// Write `data` to `path` atomically: write to a temp file in the same
13/// directory, flush + sync, then rename into place.
14///
15/// On crash or power loss, the file either has the old content or the new
16/// content — never a partial write.
17pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
18    let parent = path
19        .parent()
20        .with_context(|| format!("path has no parent: {}", path.display()))?;
21    fs::create_dir_all(parent)
22        .with_context(|| format!("failed to create parent dir: {}", parent.display()))?;
23
24    let mut tmp = tempfile::NamedTempFile::new_in(parent)
25        .with_context(|| format!("failed to create temp file in {}", parent.display()))?;
26
27    tmp.write_all(data)
28        .with_context(|| format!("failed to write temp file for {}", path.display()))?;
29    tmp.flush()?;
30    tmp.as_file().sync_all()?;
31
32    tmp.persist(path)
33        .with_context(|| format!("failed to persist temp file to {}", path.display()))?;
34
35    Ok(())
36}
37
38/// Write a string to `path` atomically.
39pub fn atomic_write_str(path: &Path, content: &str) -> Result<()> {
40    atomic_write(path, content.as_bytes())
41}
42
43/// RAII file lock using `flock(2)`.
44///
45/// Acquires an exclusive lock on a `.lock` file adjacent to the target path.
46/// The lock is released when the guard is dropped.
47pub struct FileLock {
48    _file: fs::File,
49}
50
51impl FileLock {
52    /// Acquire an exclusive lock for operations on `path`.
53    ///
54    /// Creates `<path>.lock` if it doesn't exist, then acquires an exclusive
55    /// flock. Blocks until the lock is available.
56    pub fn acquire(path: &Path) -> Result<Self> {
57        let lock_path = path.with_extension("lock");
58        if let Some(parent) = lock_path.parent() {
59            fs::create_dir_all(parent).ok();
60        }
61        let file = fs::OpenOptions::new()
62            .create(true)
63            .truncate(false)
64            .write(true)
65            .open(&lock_path)
66            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
67        file.lock_exclusive()
68            .with_context(|| format!("failed to acquire lock: {}", lock_path.display()))?;
69        Ok(Self { _file: file })
70    }
71
72    /// Try to acquire the lock without blocking.
73    ///
74    /// Returns `None` if another process holds the lock.
75    pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
76        let lock_path = path.with_extension("lock");
77        if let Some(parent) = lock_path.parent() {
78            fs::create_dir_all(parent).ok();
79        }
80        let file = fs::OpenOptions::new()
81            .create(true)
82            .truncate(false)
83            .write(true)
84            .open(&lock_path)
85            .with_context(|| format!("failed to open lock file: {}", lock_path.display()))?;
86        match file.try_lock_exclusive() {
87            Ok(()) => Ok(Some(Self { _file: file })),
88            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
89            Err(e) => {
90                Err(e).with_context(|| format!("failed to try lock: {}", lock_path.display()))
91            }
92        }
93    }
94}
95
96impl Drop for FileLock {
97    fn drop(&mut self) {
98        // flock is released when the file descriptor is closed (automatic)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::path::PathBuf;
106
107    #[test]
108    fn test_atomic_write_creates_file() {
109        let dir = tempfile::tempdir().expect("tempdir");
110        let path = dir.path().join("state.json");
111        atomic_write(&path, b"hello world").expect("write");
112        let content = fs::read_to_string(&path).expect("read");
113        assert_eq!(content, "hello world");
114    }
115
116    #[test]
117    fn test_atomic_write_overwrites_existing() {
118        let dir = tempfile::tempdir().expect("tempdir");
119        let path = dir.path().join("state.json");
120        fs::write(&path, b"old content").expect("seed");
121        atomic_write(&path, b"new content").expect("write");
122        let content = fs::read_to_string(&path).expect("read");
123        assert_eq!(content, "new content");
124    }
125
126    #[test]
127    fn test_atomic_write_creates_parent_dirs() {
128        let dir = tempfile::tempdir().expect("tempdir");
129        let path = dir.path().join("a/b/c/state.json");
130        atomic_write(&path, b"nested").expect("write");
131        let content = fs::read_to_string(&path).expect("read");
132        assert_eq!(content, "nested");
133    }
134
135    #[test]
136    fn test_atomic_write_str() {
137        let dir = tempfile::tempdir().expect("tempdir");
138        let path = dir.path().join("test.txt");
139        atomic_write_str(&path, "hello").expect("write");
140        assert_eq!(fs::read_to_string(&path).expect("read"), "hello");
141    }
142
143    #[test]
144    fn test_file_lock_acquire_and_drop() {
145        let dir = tempfile::tempdir().expect("tempdir");
146        let path = dir.path().join("state.json");
147        fs::write(&path, b"data").expect("seed");
148
149        {
150            let _lock = FileLock::acquire(&path).expect("lock");
151            // Lock file should exist
152            assert!(dir.path().join("state.lock").exists());
153        }
154        // Lock released on drop — should be acquirable again
155        let _lock2 = FileLock::acquire(&path).expect("lock again");
156    }
157
158    #[test]
159    fn test_file_lock_try_acquire() {
160        let dir = tempfile::tempdir().expect("tempdir");
161        let path = dir.path().join("state.json");
162
163        let lock1 = FileLock::try_acquire(&path)
164            .expect("try_acquire")
165            .expect("got lock");
166        // Second try should return None (lock is held)
167        let lock2 = FileLock::try_acquire(&path).expect("try_acquire");
168        assert!(lock2.is_none(), "should not get lock while held");
169
170        drop(lock1);
171        // Now should succeed
172        let lock3 = FileLock::try_acquire(&path)
173            .expect("try_acquire")
174            .expect("got lock after drop");
175        drop(lock3);
176    }
177
178    #[test]
179    fn test_file_lock_nonexistent_parent() {
180        let dir = tempfile::tempdir().expect("tempdir");
181        let path: PathBuf = dir.path().join("sub/dir/state.json");
182        let _lock = FileLock::acquire(&path).expect("lock with nested path");
183    }
184}