use std::io::Write;
use std::path::{Path, PathBuf};
use crate::{PdfError, Result};
#[derive(Clone, Debug)]
pub struct AtomicFileOutput {
target: PathBuf,
}
impl AtomicFileOutput {
#[must_use]
pub fn new(target: impl Into<PathBuf>) -> Self {
Self {
target: target.into(),
}
}
#[must_use]
pub fn target(&self) -> &Path {
&self.target
}
pub fn write(&self, bytes: &[u8]) -> Result<()> {
let parent = self.target.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let mut temporary = tempfile::Builder::new()
.prefix(".easypdf-")
.tempfile_in(parent)?;
temporary.write_all(bytes)?;
temporary.as_file_mut().sync_all()?;
temporary
.persist(&self.target)
.map_err(|error| PdfError::Io(error.error))?;
Ok(())
}
pub fn write_with_fsync(&self, data: &[u8]) -> Result<()> {
self.write(data)
}
pub fn write_with_backup(&self, data: &[u8]) -> Result<()> {
let backup_path = backup_path(&self.target);
let target_existed = self.target.exists();
if target_existed {
std::fs::copy(&self.target, &backup_path)?;
}
match self.write(data) {
Ok(()) => {
if target_existed {
let _ = std::fs::remove_file(&backup_path);
}
Ok(())
}
Err(write_err) => {
if target_existed
&& let Err(restore_err) = std::fs::rename(&backup_path, &self.target)
{
return Err(PdfError::Other(format!(
"write failed ({write_err}) and backup restore also failed ({restore_err})"
)));
}
Err(write_err)
}
}
}
pub fn atomic_replace<F>(&self, writer: F) -> Result<()>
where
F: FnOnce(&mut Vec<u8>) -> Result<()>,
{
let mut buffer = Vec::new();
writer(&mut buffer)?;
self.write(&buffer)
}
}
fn backup_path(target: &Path) -> PathBuf {
let mut backup = target.as_os_str().to_owned();
backup.push(".bak");
PathBuf::from(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_target_only_after_complete_write() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("result.md");
std::fs::write(&target, "old").expect("seed output");
AtomicFileOutput::new(&target)
.write(b"new")
.expect("atomic output");
assert_eq!(std::fs::read_to_string(target).expect("read output"), "new");
}
#[test]
fn write_with_fsync_succeeds() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("fsync.txt");
AtomicFileOutput::new(&target)
.write_with_fsync(b"fsync data")
.expect("fsync write");
assert_eq!(
std::fs::read_to_string(&target).expect("read"),
"fsync data"
);
}
#[test]
fn write_with_backup_creates_and_removes_backup() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("backup.txt");
std::fs::write(&target, "original").expect("seed");
let output = AtomicFileOutput::new(&target);
output.write_with_backup(b"updated").expect("backup write");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "updated");
let backup = backup_path(&target);
assert!(!backup.exists(), "backup should be removed after success");
}
#[test]
fn write_with_backup_restores_on_failure() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("backup_restore.txt");
std::fs::write(&target, "original").expect("seed");
let output = AtomicFileOutput::new(&target);
let bad_dir = directory.path().join("nonexistent/deep/path");
let bad_target = bad_dir.join("file.txt");
std::fs::write(&bad_target, "seed").ok();
let result = output.write_with_backup(b"updated");
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "updated");
}
#[test]
fn write_with_backup_works_when_no_existing_file() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("new_file.txt");
AtomicFileOutput::new(&target)
.write_with_backup(b"first write")
.expect("first write");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "first write");
}
#[test]
fn atomic_replace_callback_receives_buffer() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("callback.txt");
AtomicFileOutput::new(&target)
.atomic_replace(|buf| {
buf.extend_from_slice(b"callback data");
Ok(())
})
.expect("callback write");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "callback data");
}
#[test]
fn atomic_replace_propagates_callback_error() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("callback_err.txt");
let result = AtomicFileOutput::new(&target)
.atomic_replace(|_| Err(PdfError::Other("callback failed".to_string())));
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("callback failed"));
}
#[test]
fn creates_parent_directories() {
let directory = tempfile::tempdir().expect("temporary directory");
let target = directory.path().join("a/b/c/deep.txt");
AtomicFileOutput::new(&target)
.write(b"deep write")
.expect("deep write");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "deep write");
}
#[test]
fn target_returns_path() {
let path = PathBuf::from("/tmp/test.pdf");
let output = AtomicFileOutput::new(&path);
assert_eq!(output.target(), path.as_path());
}
#[test]
fn backup_path_computation() {
let target = PathBuf::from("/tmp/file.pdf");
let backup = backup_path(&target);
assert_eq!(backup, PathBuf::from("/tmp/file.pdf.bak"));
}
}