use std::io::{BufWriter, Write};
use std::path::Path;
use anyhow::Context as _;
pub(crate) const SCOPE: &str = "semver-checks";
pub(crate) fn atomic_write(
destination: impl AsRef<Path>,
write_contents: impl FnOnce(&mut dyn Write) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let destination = destination.as_ref();
let file_name = destination
.file_name()
.with_context(|| format!("destination is not a file path: {}", destination.display()))?
.to_string_lossy();
let parent = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.with_context(|| {
format!(
"destination has no parent directory: {}",
destination.display()
)
})?;
let parent = fs_err::canonicalize(parent)
.with_context(|| format!("failed to canonicalize {}", parent.display()))?;
let mut created_temp_file = None;
for _ in 0..4 {
let temp_path = parent.join(format!(
".{file_name}.tmp-{}-{:032x}",
std::process::id(),
rand::random::<u128>(),
));
let mut options = fs_err::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use fs_err::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
match options.open(&temp_path) {
Ok(file) => {
created_temp_file = Some((temp_path, file));
break;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(error)
.with_context(|| format!("failed to create {}", temp_path.display()));
}
}
}
let (temp_path, temp_file) = created_temp_file.with_context(|| {
format!(
"failed to create a unique temporary file for {}",
destination.display()
)
})?;
let mut temp_file = BufWriter::new(temp_file);
if let Err(error) = write_contents(&mut temp_file) {
drop(temp_file);
let _ = fs_err::remove_file(&temp_path);
return Err(error);
}
if let Err(error) = temp_file.flush() {
drop(temp_file);
let _ = fs_err::remove_file(&temp_path);
return Err(error).with_context(|| format!("failed to flush {}", temp_path.display()));
}
let temp_file = match temp_file.into_inner() {
Ok(file) => file,
Err(error) => {
let error = error.into_error();
let _ = fs_err::remove_file(&temp_path);
return Err(error).with_context(|| format!("failed to flush {}", temp_path.display()));
}
};
drop(temp_file);
match fs_err::rename(&temp_path, destination) {
Ok(()) => Ok(()),
Err(rename_error) => {
let fallback_result = (|| -> anyhow::Result<()> {
let mut temp_file = fs_err::File::open(&temp_path).with_context(|| {
format!(
"failed to reopen temporary file {} after atomic rename failed",
temp_path.display()
)
})?;
let destination_file = fs_err::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(destination)
.with_context(|| {
format!(
"failed to open {} for direct write fallback",
destination.display()
)
})?;
let mut destination_file = BufWriter::new(destination_file);
std::io::copy(&mut temp_file, &mut destination_file).with_context(|| {
format!(
"failed to copy {} into {}",
temp_path.display(),
destination.display()
)
})?;
destination_file
.flush()
.with_context(|| format!("failed to flush {}", destination.display()))?;
Ok(())
})();
let result = fallback_result.with_context(|| {
format!(
"atomic rename from {} to {} failed: {rename_error}",
temp_path.display(),
destination.display()
)
});
let _ = fs_err::remove_file(&temp_path);
result
}
}
}
pub(crate) fn slugify(value: &str) -> String {
value
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::atomic_write;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"cargo-semver-checks-{name}-{}-{:032x}",
std::process::id(),
rand::random::<u128>(),
));
fs_err::create_dir(&path).expect("failed to create test temp dir");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs_err::remove_dir_all(&self.path);
}
}
#[test]
fn atomic_write_commits_successful_closure() -> anyhow::Result<()> {
let temp_dir = TestDir::new("atomic-write-success");
let destination = temp_dir.path().join("output.txt");
atomic_write(&destination, |writer| {
writer.write_all(b"existing contents")?;
Ok(())
})?;
atomic_write(&destination, |writer| {
writer.write_all(b"complete contents")?;
Ok(())
})?;
assert_eq!(fs_err::read_to_string(&destination)?, "complete contents");
Ok(())
}
#[test]
fn atomic_write_aborts_when_closure_errors() -> anyhow::Result<()> {
let temp_dir = TestDir::new("atomic-write-error");
let destination = temp_dir.path().join("output.txt");
atomic_write(&destination, |writer| {
writer.write_all(b"existing contents")?;
Ok(())
})?;
let error = atomic_write(&destination, |writer| {
writer.write_all(b"partial contents")?;
anyhow::bail!("closure failed");
})
.expect_err("closure error should abort the write");
assert_eq!(error.to_string(), "closure failed");
assert_eq!(fs_err::read_to_string(&destination)?, "existing contents");
assert_eq!(
fs_err::read_dir(temp_dir.path())?.count(),
1,
"temporary file should be removed after closure error"
);
Ok(())
}
}