1use std::fs;
6use std::io::Write;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use fs2::FileExt;
11
12pub 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
38pub fn atomic_write_str(path: &Path, content: &str) -> Result<()> {
40 atomic_write(path, content.as_bytes())
41}
42
43pub struct FileLock {
48 _file: fs::File,
49}
50
51impl FileLock {
52 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 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 }
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 assert!(dir.path().join("state.lock").exists());
153 }
154 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 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 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}