easypdf_core/io/
atomic_file_output.rs1use std::io::Write;
12use std::path::{Path, PathBuf};
13
14use crate::{PdfError, Result};
15
16#[derive(Clone, Debug)]
27pub struct AtomicFileOutput {
28 target: PathBuf,
29}
30
31impl AtomicFileOutput {
32 #[must_use]
34 pub fn new(target: impl Into<PathBuf>) -> Self {
35 Self {
36 target: target.into(),
37 }
38 }
39
40 #[must_use]
42 pub fn target(&self) -> &Path {
43 &self.target
44 }
45
46 pub fn write(&self, bytes: &[u8]) -> Result<()> {
55 let parent = self.target.parent().unwrap_or_else(|| Path::new("."));
56 std::fs::create_dir_all(parent)?;
57 let mut temporary = tempfile::Builder::new()
58 .prefix(".easypdf-")
59 .tempfile_in(parent)?;
60 temporary.write_all(bytes)?;
61 temporary.as_file_mut().sync_all()?;
62 temporary
63 .persist(&self.target)
64 .map_err(|error| PdfError::Io(error.error))?;
65 Ok(())
66 }
67
68 pub fn write_with_fsync(&self, data: &[u8]) -> Result<()> {
78 self.write(data)
79 }
80
81 pub fn write_with_backup(&self, data: &[u8]) -> Result<()> {
91 let backup_path = backup_path(&self.target);
92 let target_existed = self.target.exists();
93
94 if target_existed {
96 std::fs::copy(&self.target, &backup_path)?;
97 }
98
99 match self.write(data) {
101 Ok(()) => {
102 if target_existed {
104 let _ = std::fs::remove_file(&backup_path);
105 }
106 Ok(())
107 }
108 Err(write_err) => {
109 if target_existed
111 && let Err(restore_err) = std::fs::rename(&backup_path, &self.target)
112 {
113 return Err(PdfError::Other(format!(
114 "write failed ({write_err}) and backup restore also failed ({restore_err})"
115 )));
116 }
117 Err(write_err)
118 }
119 }
120 }
121
122 pub fn atomic_replace<F>(&self, writer: F) -> Result<()>
143 where
144 F: FnOnce(&mut Vec<u8>) -> Result<()>,
145 {
146 let mut buffer = Vec::new();
147 writer(&mut buffer)?;
148 self.write(&buffer)
149 }
150}
151
152fn backup_path(target: &Path) -> PathBuf {
154 let mut backup = target.as_os_str().to_owned();
155 backup.push(".bak");
156 PathBuf::from(backup)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn replaces_target_only_after_complete_write() {
165 let directory = tempfile::tempdir().expect("temporary directory");
166 let target = directory.path().join("result.md");
167 std::fs::write(&target, "old").expect("seed output");
168
169 AtomicFileOutput::new(&target)
170 .write(b"new")
171 .expect("atomic output");
172
173 assert_eq!(std::fs::read_to_string(target).expect("read output"), "new");
174 }
175
176 #[test]
177 fn write_with_fsync_succeeds() {
178 let directory = tempfile::tempdir().expect("temporary directory");
179 let target = directory.path().join("fsync.txt");
180
181 AtomicFileOutput::new(&target)
182 .write_with_fsync(b"fsync data")
183 .expect("fsync write");
184
185 assert_eq!(
186 std::fs::read_to_string(&target).expect("read"),
187 "fsync data"
188 );
189 }
190
191 #[test]
192 fn write_with_backup_creates_and_removes_backup() {
193 let directory = tempfile::tempdir().expect("temporary directory");
194 let target = directory.path().join("backup.txt");
195 std::fs::write(&target, "original").expect("seed");
196
197 let output = AtomicFileOutput::new(&target);
198 output.write_with_backup(b"updated").expect("backup write");
199
200 assert_eq!(std::fs::read_to_string(&target).unwrap(), "updated");
202 let backup = backup_path(&target);
204 assert!(!backup.exists(), "backup should be removed after success");
205 }
206
207 #[test]
208 fn write_with_backup_restores_on_failure() {
209 let directory = tempfile::tempdir().expect("temporary directory");
210 let target = directory.path().join("backup_restore.txt");
211 std::fs::write(&target, "original").expect("seed");
212
213 let output = AtomicFileOutput::new(&target);
214 let bad_dir = directory.path().join("nonexistent/deep/path");
217 let bad_target = bad_dir.join("file.txt");
218 std::fs::write(&bad_target, "seed").ok(); let result = output.write_with_backup(b"updated");
222 assert!(result.is_ok());
223 assert_eq!(std::fs::read_to_string(&target).unwrap(), "updated");
224 }
225
226 #[test]
227 fn write_with_backup_works_when_no_existing_file() {
228 let directory = tempfile::tempdir().expect("temporary directory");
229 let target = directory.path().join("new_file.txt");
230
231 AtomicFileOutput::new(&target)
232 .write_with_backup(b"first write")
233 .expect("first write");
234
235 assert_eq!(std::fs::read_to_string(&target).unwrap(), "first write");
236 }
237
238 #[test]
239 fn atomic_replace_callback_receives_buffer() {
240 let directory = tempfile::tempdir().expect("temporary directory");
241 let target = directory.path().join("callback.txt");
242
243 AtomicFileOutput::new(&target)
244 .atomic_replace(|buf| {
245 buf.extend_from_slice(b"callback data");
246 Ok(())
247 })
248 .expect("callback write");
249
250 assert_eq!(std::fs::read_to_string(&target).unwrap(), "callback data");
251 }
252
253 #[test]
254 fn atomic_replace_propagates_callback_error() {
255 let directory = tempfile::tempdir().expect("temporary directory");
256 let target = directory.path().join("callback_err.txt");
257
258 let result = AtomicFileOutput::new(&target)
259 .atomic_replace(|_| Err(PdfError::Other("callback failed".to_string())));
260
261 assert!(result.is_err());
262 let msg = format!("{}", result.unwrap_err());
263 assert!(msg.contains("callback failed"));
264 }
265
266 #[test]
267 fn creates_parent_directories() {
268 let directory = tempfile::tempdir().expect("temporary directory");
269 let target = directory.path().join("a/b/c/deep.txt");
270
271 AtomicFileOutput::new(&target)
272 .write(b"deep write")
273 .expect("deep write");
274
275 assert_eq!(std::fs::read_to_string(&target).unwrap(), "deep write");
276 }
277
278 #[test]
279 fn target_returns_path() {
280 let path = PathBuf::from("/tmp/test.pdf");
281 let output = AtomicFileOutput::new(&path);
282 assert_eq!(output.target(), path.as_path());
283 }
284
285 #[test]
286 fn backup_path_computation() {
287 let target = PathBuf::from("/tmp/file.pdf");
288 let backup = backup_path(&target);
289 assert_eq!(backup, PathBuf::from("/tmp/file.pdf.bak"));
290 }
291}