use camino::Utf8PathBuf;
use std::io;
use std::path::Path;
pub fn rename_replace(from: &Path, to: &Path) -> io::Result<()> {
#[cfg(target_os = "android")]
{
std::fs::copy(from, to)?;
std::fs::remove_file(from)?;
Ok(())
}
#[cfg(not(target_os = "android"))]
{
std::fs::rename(from, to)
}
}
pub async fn rename_replace_async(from: Utf8PathBuf, to: Utf8PathBuf) -> io::Result<()> {
tokio::task::spawn_blocking(move || rename_replace(from.as_std_path(), to.as_std_path()))
.await
.map_err(io::Error::other)?
}
pub fn move_no_replace(from: &Path, to: &Path) -> io::Result<()> {
#[cfg(not(target_os = "android"))]
match std::fs::hard_link(from, to) {
Ok(()) => {
return std::fs::remove_file(from).inspect_err(|_| {
let _ = std::fs::remove_file(to);
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(e),
Err(e) => {
tracing::debug!("hard link {} failed ({e}), copying instead", to.display());
}
}
copy_no_replace(from, to)?;
std::fs::remove_file(from).inspect_err(|_| {
let _ = std::fs::remove_file(to);
})
}
fn copy_no_replace(from: &Path, to: &Path) -> io::Result<()> {
let mut source = std::fs::File::open(from)?;
let metadata = source.metadata()?;
let mut target = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(to)?;
let copied = (|| {
io::copy(&mut source, &mut target)?;
target.set_permissions(metadata.permissions())?;
if let Ok(modified) = metadata.modified() {
target.set_modified(modified)?;
}
target.sync_all()
})();
if copied.is_err() {
drop(target);
let _ = std::fs::remove_file(to);
}
copied
}
pub fn move_file(from: &Path, to: &Path) -> io::Result<()> {
if !same_file::is_same_file(from, to).unwrap_or(false) {
return move_no_replace(from, to);
}
let temporary = hidden_sibling(to, "rename");
move_no_replace(from, &temporary)?;
move_no_replace(&temporary, to).inspect_err(|_| {
let _ = move_no_replace(&temporary, from);
})
}
pub async fn move_file_async(from: Utf8PathBuf, to: Utf8PathBuf) -> io::Result<()> {
tokio::task::spawn_blocking(move || move_file(from.as_std_path(), to.as_std_path()))
.await
.map_err(io::Error::other)?
}
pub fn write_replace(path: &Path, contents: &[u8]) -> io::Result<()> {
use std::io::Write;
let temporary = hidden_sibling(path, "tmp");
let written = (|| {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
if let Ok(metadata) = std::fs::metadata(path) {
file.set_permissions(metadata.permissions())?;
}
file.write_all(contents)?;
file.sync_all()
})();
written
.and_then(|()| rename_replace(&temporary, path))
.inspect_err(|_| {
let _ = std::fs::remove_file(&temporary);
})
}
pub async fn write_replace_async(path: Utf8PathBuf, contents: Vec<u8>) -> io::Result<()> {
tokio::task::spawn_blocking(move || write_replace(path.as_std_path(), &contents))
.await
.map_err(io::Error::other)?
}
fn hidden_sibling(path: &Path, suffix: &str) -> std::path::PathBuf {
let name = path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
path.with_file_name(format!(".{name}.{:016x}.{suffix}", fastrand::u64(..)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn replaces_destination_and_removes_source() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("from.tmp");
let to = dir.path().join("to.cook");
fs::write(&from, b"new contents").unwrap();
fs::write(&to, b"old contents").unwrap();
rename_replace(&from, &to).unwrap();
assert_eq!(fs::read_to_string(&to).unwrap(), "new contents");
assert!(!from.exists(), "source temp file should be gone");
}
#[test]
fn creates_destination_when_absent() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("from.tmp");
let to = dir.path().join("to.cook");
fs::write(&from, b"contents").unwrap();
rename_replace(&from, &to).unwrap();
assert_eq!(fs::read_to_string(&to).unwrap(), "contents");
assert!(!from.exists());
}
#[tokio::test]
async fn async_wrapper_replaces_destination() {
let dir = TempDir::new().unwrap();
let from = Utf8PathBuf::from_path_buf(dir.path().join("from.tmp")).unwrap();
let to = Utf8PathBuf::from_path_buf(dir.path().join("to.cook")).unwrap();
fs::write(&from, b"async contents").unwrap();
rename_replace_async(from.clone(), to.clone())
.await
.unwrap();
assert_eq!(fs::read_to_string(&to).unwrap(), "async contents");
assert!(!from.exists());
}
#[test]
fn move_no_replace_moves_and_keeps_the_contents() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("Pasta.cook");
let to = dir.path().join("Noodles.cook");
fs::write(&from, b"pasta").unwrap();
move_no_replace(&from, &to).unwrap();
assert_eq!(fs::read_to_string(&to).unwrap(), "pasta");
assert!(!from.exists());
}
#[test]
fn move_no_replace_never_overwrites() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("Pasta.cook");
let to = dir.path().join("Soup.cook");
fs::write(&from, b"pasta").unwrap();
fs::write(&to, b"soup").unwrap();
let err = move_no_replace(&from, &to).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&from).unwrap(), "pasta");
assert_eq!(fs::read_to_string(&to).unwrap(), "soup");
}
#[test]
fn the_copy_fallback_never_overwrites_and_keeps_the_mtime() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("Pasta.cook");
let to = dir.path().join("Noodles.cook");
let taken = dir.path().join("Soup.cook");
fs::write(&from, b"pasta").unwrap();
fs::write(&taken, b"soup").unwrap();
let mtime = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
fs::File::options()
.write(true)
.open(&from)
.unwrap()
.set_modified(mtime)
.unwrap();
let err = copy_no_replace(&from, &taken).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(fs::read_to_string(&taken).unwrap(), "soup");
copy_no_replace(&from, &to).unwrap();
assert_eq!(fs::read_to_string(&to).unwrap(), "pasta");
assert_eq!(fs::metadata(&to).unwrap().modified().unwrap(), mtime);
}
#[test]
fn move_file_changes_only_the_case_of_a_name() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("pasta.cook");
let to = dir.path().join("Pasta.cook");
fs::write(&from, b"pasta").unwrap();
move_file(&from, &to).unwrap();
let names: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, ["Pasta.cook"], "no temporary file is left behind");
assert_eq!(fs::read_to_string(&to).unwrap(), "pasta");
}
#[test]
fn move_file_refuses_a_second_name_of_the_same_file() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("Pasta.cook");
let to = dir.path().join("Linked.cook");
fs::write(&from, b"pasta").unwrap();
if fs::hard_link(&from, &to).is_err() {
return; }
assert!(move_file(&from, &to).is_err());
assert_eq!(fs::read_to_string(&from).unwrap(), "pasta");
assert_eq!(fs::read_to_string(&to).unwrap(), "pasta");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[test]
fn write_replace_swaps_the_contents_and_leaves_no_temporary_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("Plan.menu");
fs::write(&path, b"old").unwrap();
write_replace(&path, b"new").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
}