1use std::fs;
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16
17pub fn write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
25 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
26 if let Some(parent) = parent {
27 fs::create_dir_all(parent)?;
28 }
29 let name = path
30 .file_name()
31 .ok_or_else(|| std::io::Error::other(format!("no file name in {}", path.display())))?;
32 let mut tmp_name = std::ffi::OsString::from(format!(".{}", std::process::id()));
33 tmp_name.push(".rk-tmp.");
34 tmp_name.push(name);
35 let tmp = path.with_file_name(tmp_name);
36 let written = fs::File::create(&tmp)
37 .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()))
38 .and_then(|()| fs::rename(&tmp, path));
39 if written.is_err() {
40 let _ = fs::remove_file(&tmp);
41 }
42 written
43}
44
45#[derive(Debug, Default)]
49pub struct Transaction {
50 staged: Vec<(PathBuf, PathBuf)>,
51}
52
53#[derive(Debug)]
55pub struct Interrupted {
56 pub landed: Vec<PathBuf>,
58 pub failed: PathBuf,
60 pub error: std::io::Error,
62}
63
64impl Transaction {
65 #[must_use]
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn stage(&mut self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
79 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
80 if let Some(parent) = parent {
81 fs::create_dir_all(parent)?;
82 }
83 let name = path
84 .file_name()
85 .ok_or_else(|| std::io::Error::other(format!("no file name in {}", path.display())))?;
86 let mut tmp_name = std::ffi::OsString::from(format!(".{}", std::process::id()));
87 tmp_name.push(format!(".rk-txn-{}.", self.staged.len()));
88 tmp_name.push(name);
89 let tmp = path.with_file_name(tmp_name);
90 let written = fs::File::create(&tmp)
91 .and_then(|mut file| file.write_all(bytes).and_then(|()| file.sync_all()));
92 if written.is_err() {
93 let _ = fs::remove_file(&tmp);
94 return written;
95 }
96 self.staged.push((tmp, path.to_path_buf()));
97 Ok(())
98 }
99
100 #[must_use]
102 pub const fn len(&self) -> usize {
103 self.staged.len()
104 }
105
106 #[must_use]
108 pub const fn is_empty(&self) -> bool {
109 self.staged.is_empty()
110 }
111
112 pub fn commit(self) -> Result<Vec<PathBuf>, Interrupted> {
120 self.commit_stopping_at(None)
121 }
122
123 pub fn commit_stopping_at(mut self, stop: Option<&Path>) -> Result<Vec<PathBuf>, Interrupted> {
133 let mut landed = Vec::new();
134 let staged = std::mem::take(&mut self.staged);
135 let mut pending = staged.into_iter();
136 for (tmp, dest) in pending.by_ref() {
137 let renamed = if stop.is_some_and(|stop| dest.ends_with(stop)) {
138 Err(std::io::Error::other(
139 "the commit was stopped here for the proof",
140 ))
141 } else {
142 fs::rename(&tmp, &dest)
143 };
144 if let Err(error) = renamed {
145 let _ = fs::remove_file(&tmp);
146 for (rest, _) in pending {
147 let _ = fs::remove_file(rest);
148 }
149 return Err(Interrupted {
150 landed,
151 failed: dest,
152 error,
153 });
154 }
155 landed.push(dest);
156 }
157 Ok(landed)
158 }
159}
160
161impl Drop for Transaction {
162 fn drop(&mut self) {
163 for (tmp, _) in self.staged.drain(..) {
164 let _ = fs::remove_file(tmp);
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::{Transaction, write};
172
173 #[test]
174 fn a_transaction_lands_every_write_in_order_and_leaves_no_temp() {
175 let dir = tempfile::tempdir().expect("a scratch dir exists");
176 let one = dir.path().join("a/one.txt");
177 let two = dir.path().join("two.txt");
178 let mut txn = Transaction::new();
179 txn.stage(&one, b"one").expect("stages");
180 txn.stage(&two, b"two").expect("stages");
181 assert!(
182 !one.exists() && !two.exists(),
183 "staging touches no destination"
184 );
185 let landed = txn.commit().expect("commits");
186 assert_eq!(landed, vec![one.clone(), two.clone()]);
187 assert_eq!(std::fs::read(&one).expect("reads"), b"one");
188 assert_eq!(std::fs::read(&two).expect("reads"), b"two");
189 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
190 .expect("the dir reads")
191 .map(|entry| entry.expect("an entry").file_name())
192 .filter(|name| name != "a" && name != "two.txt")
193 .collect();
194 assert!(
195 leftovers.is_empty(),
196 "temp files left behind: {leftovers:?}"
197 );
198 }
199
200 #[test]
201 fn a_dropped_transaction_removes_what_it_staged() {
202 let dir = tempfile::tempdir().expect("a scratch dir exists");
203 let dest = dir.path().join("kept.txt");
204 std::fs::write(&dest, b"before").expect("writes");
205 {
206 let mut txn = Transaction::new();
207 txn.stage(&dest, b"after").expect("stages");
208 }
209 assert_eq!(std::fs::read(&dest).expect("reads"), b"before");
210 assert_eq!(std::fs::read_dir(dir.path()).expect("reads").count(), 1);
211 }
212
213 #[test]
214 fn an_interrupted_commit_names_what_landed_and_leaves_the_rest_whole() {
215 let dir = tempfile::tempdir().expect("a scratch dir exists");
216 let first = dir.path().join("first.txt");
217 let blocked = dir.path().join("blocked");
218 std::fs::create_dir(&blocked).expect("the blocking dir creates");
219 let third = dir.path().join("third.txt");
220 std::fs::write(&third, b"before").expect("writes");
221 let mut txn = Transaction::new();
222 txn.stage(&first, b"first").expect("stages");
223 txn.stage(&blocked, b"bytes")
224 .expect("stages beside a directory");
225 txn.stage(&third, b"after").expect("stages");
226 let interrupted = txn.commit().expect_err("the directory blocks the rename");
227 assert_eq!(interrupted.landed, vec![first.clone()]);
228 assert_eq!(interrupted.failed, blocked);
229 assert_eq!(std::fs::read(&first).expect("reads"), b"first");
230 assert_eq!(std::fs::read(&third).expect("reads"), b"before");
231 assert_eq!(std::fs::read_dir(dir.path()).expect("reads").count(), 3);
232 }
233
234 #[test]
235 fn a_write_creates_parents_lands_whole_and_leaves_no_temp() {
236 let dir = tempfile::tempdir().expect("a scratch dir exists");
237 let path = dir.path().join("deep/nested/file.txt");
238 write(&path, b"first").expect("the write lands");
239 assert_eq!(std::fs::read(&path).expect("the file reads"), b"first");
240 write(&path, b"second").expect("the overwrite lands");
241 assert_eq!(std::fs::read(&path).expect("the file reads"), b"second");
242 let leftovers: Vec<_> = std::fs::read_dir(path.parent().expect("a parent"))
243 .expect("the dir reads")
244 .map(|entry| entry.expect("an entry").file_name())
245 .filter(|name| name != "file.txt")
246 .collect();
247 assert!(
248 leftovers.is_empty(),
249 "temp files left behind: {leftovers:?}"
250 );
251 }
252
253 #[test]
254 fn a_failed_write_leaves_the_destination_alone() {
255 let dir = tempfile::tempdir().expect("a scratch dir exists");
256 let path = dir.path().join("blocked");
258 std::fs::create_dir(&path).expect("the blocking dir creates");
259 assert!(write(&path, b"bytes").is_err());
260 assert!(path.is_dir(), "the destination must be untouched");
261 }
262}