use crate::CoreError;
use camino::Utf8Path;
pub(crate) fn write_atomically(
path: &Utf8Path,
contents: impl AsRef<[u8]>,
) -> Result<(), CoreError> {
let failed = |source: std::io::Error| CoreError::Io {
path: path.to_owned(),
source,
};
let target = path.canonicalize_utf8().unwrap_or_else(|_| path.to_owned());
if let Some(parent) = target.parent().filter(|parent| !parent.as_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(failed)?;
}
let name = target.file_name().unwrap_or("file");
let temp = target.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
let written = (|| -> std::io::Result<()> {
let file = std::fs::File::create(&temp)?;
{
use std::io::Write as _;
let mut file = &file;
file.write_all(contents.as_ref())?;
file.flush()?;
}
file.sync_all()?;
drop(file);
if let Ok(metadata) = std::fs::metadata(&target) {
std::fs::set_permissions(&temp, metadata.permissions())?;
}
rename_replace(&temp, &target)
})();
if let Err(source) = written {
let _ = std::fs::remove_file(&temp);
return Err(failed(source));
}
Ok(())
}
pub(crate) fn rename_replace(from: &Utf8Path, to: &Utf8Path) -> std::io::Result<()> {
#[cfg(target_os = "android")]
{
std::fs::copy(from, to)?;
std::fs::remove_file(from)
}
#[cfg(not(target_os = "android"))]
{
std::fs::rename(from, to)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
fn names_in(dir: &Utf8Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir.as_std_path())
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn writes_bytes_and_leaves_nothing_behind() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
let target = base.join(".shopping-list");
write_atomically(&target, b"./Soup\n").expect("writes");
assert_eq!(
std::fs::read_to_string(target.as_std_path()).unwrap(),
"./Soup\n"
);
assert_eq!(
names_in(&base),
[".shopping-list"],
"no temporary file left"
);
}
#[test]
fn replaces_an_existing_file_wholesale() {
let dir = tempfile::TempDir::new().unwrap();
let target = base(&dir).join(".shopping-list");
std::fs::write(target.as_std_path(), "a much longer previous list\n").unwrap();
write_atomically(&target, b"./Soup\n").expect("writes");
assert_eq!(
std::fs::read_to_string(target.as_std_path()).unwrap(),
"./Soup\n"
);
}
#[test]
fn a_failure_after_the_temporary_file_exists_still_removes_it() {
let dir = tempfile::TempDir::new().unwrap();
let base = base(&dir);
let target = base.join("a-directory");
std::fs::create_dir_all(target.join("child").as_std_path()).unwrap();
match write_atomically(&target, b"contents") {
Err(CoreError::Io { path, .. }) => assert_eq!(path, target),
other => panic!("expected Io, got {other:?}"),
}
assert_eq!(names_in(&base), ["a-directory"], "no temporary file left");
}
}