summaryrefslogtreecommitdiff
path: root/rola-utils
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-06-18 00:34:39 +0800
committer魏曹先生 <1992414357@qq.com>2026-06-18 00:34:39 +0800
commitdd9fa4b2104f7a19b4a364cf46b96b974c442cd1 (patch)
treeaf2488af4dadd9872a18c945ea0960cd5860d03d /rola-utils
parent7ea51858189338cbda1a21dc5724d1a8ce3aedb9 (diff)
refactor: split copy_with_temp_rename into sync and async modules
Diffstat (limited to 'rola-utils')
-rw-r--r--rola-utils/functions/src/copy_with_temp_rename.rs404
-rw-r--r--rola-utils/functions/src/copy_with_temp_rename/async.rs222
-rw-r--r--rola-utils/functions/src/copy_with_temp_rename/sync.rs209
-rw-r--r--rola-utils/functions/src/lib.rs3
4 files changed, 636 insertions, 202 deletions
diff --git a/rola-utils/functions/src/copy_with_temp_rename.rs b/rola-utils/functions/src/copy_with_temp_rename.rs
index cdbe3db..ede73f6 100644
--- a/rola-utils/functions/src/copy_with_temp_rename.rs
+++ b/rola-utils/functions/src/copy_with_temp_rename.rs
@@ -1,209 +1,209 @@
-use std::borrow::Cow;
-use std::io;
-use std::path::{Path, PathBuf};
+mod r#async;
+mod sync;
-/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination.
-pub fn copy_with_temp_rename(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
- internal_copy_with_temp_rename(src.as_ref(), dst.as_ref())
-}
+pub use r#async::*;
+pub use sync::*;
-fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> {
- // Check if source file exists to avoid later failures
- if !src.exists() {
- return Err(io::Error::new(
- io::ErrorKind::NotFound,
- format!("source file does not exist: {}", src.display()),
- ));
- }
-
- // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy
- // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison
- let src_canonical = std::fs::canonicalize(src);
- if dst.exists() {
- let dst_canonical = std::fs::canonicalize(dst);
- match (src_canonical, dst_canonical) {
- (Ok(src_c), Ok(dst_c)) => {
- if src_c == dst_c {
- return Ok(());
- }
- }
- (Err(e), _) | (_, Err(e)) => {
- return Err(io::Error::new(
- io::ErrorKind::Other,
- format!(
- "failed to canonicalize paths (src: {}, dst: {}): {}",
- src.display(),
- dst.display(),
- e
- ),
- ));
- }
- }
- } else {
- // dst doesn't exist yet, so it cannot be the same file as src
- // If src canonicalization fails, propagate the error
- let _ = src_canonical.map_err(|e| {
- io::Error::new(
- io::ErrorKind::Other,
- format!(
- "failed to canonicalize source path (src: {}): {}",
- src.display(),
- e
- ),
- )
- })?;
- }
-
- // Ensure both source and destination are regular files, preventing accidental operations on directories
- {
- let src_meta = src.metadata().map_err(|e| {
- io::Error::new(
- e.kind(),
- format!("failed to get source metadata for {}: {}", src.display(), e),
- )
- })?;
- if !src_meta.is_file() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!("source is not a regular file: {}", src.display()),
- ));
- }
-
- if dst.exists() {
- let dst_meta = dst.metadata().map_err(|e| {
- io::Error::new(
- e.kind(),
- format!(
- "failed to get destination metadata for {}: {}",
- dst.display(),
- e
- ),
- )
- })?;
- if !dst_meta.is_file() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!(
- "destination exists and is not a regular file: {}",
- dst.display()
- ),
- ));
- }
- }
- }
-
- // Ensure the parent directory of the destination exists, creating it if necessary
- if let Some(parent) = dst.parent() {
- std::fs::create_dir_all(parent)?;
- }
-
- // Create a temporary file in the destination directory as an intermediate write target
- let temp_dir = dst.parent().unwrap_or_else(|| Path::new("."));
- let base = src
- .file_name()
- .map(|f| f.to_string_lossy())
- .unwrap_or_else(|| Cow::Borrowed("temp"));
- let temp_path = atomic_create_temp(temp_dir, &base)?;
-
- // Copy source file contents to the temporary file; clean up on failure
- if let Err(e) = std::fs::copy(src, &temp_path) {
- let _ = std::fs::remove_file(&temp_path);
- return Err(e);
- }
-
- // Atomically replace the destination file; clean up temporary file on failure
- if let Err(e) = atomic_rename(&temp_path, dst) {
- let _ = std::fs::remove_file(&temp_path);
- return Err(e);
- }
-
- // Copy source file permissions to the destination
- let _ = std::fs::set_permissions(dst, src.metadata()?.permissions());
-
- Ok(())
-}
+#[cfg(test)]
+mod async_tests {
+ use tokio::io;
+
+ use super::*;
+ use crate::rola_test_sandbox;
+
+ #[tokio::test]
+ async fn copy_to_new_location() {
+ let sandbox = rola_test_sandbox("async_copy_to_new_location");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ tokio::fs::write(&src, b"hello").await.unwrap();
+
+ copy_with_temp_rename_async(&src, &dst).await.unwrap();
+ let content = tokio::fs::read_to_string(&dst).await.unwrap();
+ assert_eq!(content, "hello");
+ }
+
+ #[tokio::test]
+ async fn overwrite_existing_destination() {
+ let sandbox = rola_test_sandbox("async_overwrite_existing_dst");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ tokio::fs::write(&src, b"new content").await.unwrap();
+ tokio::fs::write(&dst, b"old content").await.unwrap();
+
+ copy_with_temp_rename_async(&src, &dst).await.unwrap();
+ let content = tokio::fs::read_to_string(&dst).await.unwrap();
+ assert_eq!(content, "new content");
+ }
+
+ #[tokio::test]
+ async fn source_not_found() {
+ let sandbox = rola_test_sandbox("async_source_not_found");
+ let src = sandbox.join("nonexistent.txt");
+ let dst = sandbox.join("dst.txt");
+
+ let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::NotFound);
+ }
+
+ #[tokio::test]
+ async fn copy_same_file() {
+ let sandbox = rola_test_sandbox("async_copy_same_file");
+ let file = sandbox.join("file.txt");
+ tokio::fs::write(&file, b"content").await.unwrap();
+
+ copy_with_temp_rename_async(&file, &file).await.unwrap();
+ let content = tokio::fs::read_to_string(&file).await.unwrap();
+ assert_eq!(content, "content");
+ }
+
+ #[tokio::test]
+ async fn source_is_directory() {
+ let sandbox = rola_test_sandbox("async_source_is_directory");
+ let src = sandbox.join("a_dir");
+ let dst = sandbox.join("dst.txt");
+ tokio::fs::create_dir(&src).await.unwrap();
+
+ let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
+ }
+
+ #[tokio::test]
+ async fn destination_is_directory() {
+ let sandbox = rola_test_sandbox("async_dest_is_directory");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("a_dir");
+ tokio::fs::write(&src, b"content").await.unwrap();
+ tokio::fs::create_dir(&dst).await.unwrap();
+
+ let err = copy_with_temp_rename_async(&src, &dst).await.unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
+ }
+
+ #[tokio::test]
+ async fn create_parent_directory() {
+ let sandbox = rola_test_sandbox("async_create_parent_dir");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("sub/deep/dst.txt");
+ tokio::fs::write(&src, b"hello").await.unwrap();
+
+ copy_with_temp_rename_async(&src, &dst).await.unwrap();
+ assert!(tokio::fs::try_exists(&dst).await.unwrap());
+ let content = tokio::fs::read_to_string(&dst).await.unwrap();
+ assert_eq!(content, "hello");
+ }
+
+ #[tokio::test]
+ async fn content_is_atomic() {
+ let sandbox = rola_test_sandbox("async_content_is_atomic");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ tokio::fs::write(&src, b"final content").await.unwrap();
+ tokio::fs::write(&dst, b"initial").await.unwrap();
+
+ copy_with_temp_rename_async(&src, &dst).await.unwrap();
-/// Atomically renames a path, replacing the destination if it exists.
-fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
- #[cfg(unix)]
- {
- std::fs::rename(src, dst)
- }
-
- #[cfg(not(unix))]
- {
- use std::ffi::OsStr;
- use std::os::windows::ffi::OsStrExt;
-
- // Convert OsStr to a null-terminated UTF-16 vector
- fn to_wide_null(os_str: &OsStr) -> Vec<u16> {
- os_str.encode_wide().chain(Some(0)).collect()
- }
-
- let src_wide = to_wide_null(src.as_os_str());
- let dst_wide = to_wide_null(dst.as_os_str());
-
- // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s,
- // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated.
- // Declares the Windows API MoveFileExW for atomic renaming.
- unsafe extern "system" {
- /// MOVEFILE_REPLACE_EXISTING = 1
- fn MoveFileExW(
- lpExistingFileName: *const u16,
- lpNewFileName: *const u16,
- dwFlags: u32,
- ) -> i32;
- }
-
- const MOVEFILE_REPLACE_EXISTING: u32 = 1;
-
- // SAFETY:
- // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime.
- // 2. `MoveFileExW` is a Windows system API declared via extern "system",
- // with correct calling convention and ABI.
- // 3. Return value is checked; errors are handled via `last_os_error()`.
- let ret = unsafe {
- MoveFileExW(
- src_wide.as_ptr(),
- dst_wide.as_ptr(),
- MOVEFILE_REPLACE_EXISTING,
- )
- };
-
- if ret == 0 {
- Err(io::Error::last_os_error())
- } else {
- Ok(())
- }
+ let content = tokio::fs::read_to_string(&dst).await.unwrap();
+ assert_eq!(content, "final content");
}
}
-fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> {
- use std::fs::OpenOptions;
-
- // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts
- let make_name = |i: usize| {
- let name = if i == 0 {
- format!(".temp_{}", base)
- } else {
- format!(".temp_{}_{}", base, i)
- };
- dir.join(name)
- };
-
- const MAX_ATTEMPTS: usize = 10000;
- // Loop to create a new file; create_new ensures the file does not already exist
- for i in 0..MAX_ATTEMPTS {
- let path = make_name(i);
- match OpenOptions::new().create_new(true).write(true).open(&path) {
- Ok(_) => return Ok(path),
- Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(e) => return Err(e),
- }
- }
-
- Err(io::Error::new(
- io::ErrorKind::Other,
- "exceeded max attempts to create temp file",
- ))
+#[cfg(test)]
+mod sync_tests {
+ use super::*;
+ use crate::rola_test_sandbox;
+ use std::{fs, io};
+
+ #[test]
+ fn copy_to_new_location() {
+ let sandbox = rola_test_sandbox("copy_to_new_location");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ fs::write(&src, b"hello").unwrap();
+
+ copy_with_temp_rename(&src, &dst).unwrap();
+ assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
+ }
+
+ #[test]
+ fn overwrite_existing_destination() {
+ let sandbox = rola_test_sandbox("overwrite_existing_dst");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ fs::write(&src, b"new content").unwrap();
+ fs::write(&dst, b"old content").unwrap();
+
+ copy_with_temp_rename(&src, &dst).unwrap();
+ assert_eq!(fs::read_to_string(&dst).unwrap(), "new content");
+ }
+
+ #[test]
+ fn source_not_found() {
+ let sandbox = rola_test_sandbox("source_not_found");
+ let src = sandbox.join("nonexistent.txt");
+ let dst = sandbox.join("dst.txt");
+
+ let err = copy_with_temp_rename(&src, &dst).unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::NotFound);
+ }
+
+ #[test]
+ fn copy_same_file() {
+ let sandbox = rola_test_sandbox("copy_same_file");
+ let file = sandbox.join("file.txt");
+ fs::write(&file, b"content").unwrap();
+
+ copy_with_temp_rename(&file, &file).unwrap();
+ assert_eq!(fs::read_to_string(&file).unwrap(), "content");
+ }
+
+ #[test]
+ fn source_is_directory() {
+ let sandbox = rola_test_sandbox("source_is_directory");
+ let src = sandbox.join("a_dir");
+ let dst = sandbox.join("dst.txt");
+ fs::create_dir(&src).unwrap();
+
+ let err = copy_with_temp_rename(&src, &dst).unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
+ }
+
+ #[test]
+ fn destination_is_directory() {
+ let sandbox = rola_test_sandbox("dest_is_directory");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("a_dir");
+ fs::write(&src, b"content").unwrap();
+ fs::create_dir(&dst).unwrap();
+
+ let err = copy_with_temp_rename(&src, &dst).unwrap_err();
+ assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
+ }
+
+ #[test]
+ fn create_parent_directory() {
+ let sandbox = rola_test_sandbox("create_parent_dir");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("sub/deep/dst.txt");
+ fs::write(&src, b"hello").unwrap();
+
+ copy_with_temp_rename(&src, &dst).unwrap();
+ assert!(dst.exists());
+ assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
+ }
+
+ #[test]
+ fn content_is_atomic() {
+ let sandbox = rola_test_sandbox("content_is_atomic");
+ let src = sandbox.join("src.txt");
+ let dst = sandbox.join("dst.txt");
+ fs::write(&src, b"final content").unwrap();
+ fs::write(&dst, b"initial").unwrap();
+
+ copy_with_temp_rename(&src, &dst).unwrap();
+
+ // After the operation, dst must contain exactly the source content
+ // (never partial/empty, thanks to temp+rename)
+ assert_eq!(fs::read_to_string(&dst).unwrap(), "final content");
+ }
}
diff --git a/rola-utils/functions/src/copy_with_temp_rename/async.rs b/rola-utils/functions/src/copy_with_temp_rename/async.rs
new file mode 100644
index 0000000..83275a1
--- /dev/null
+++ b/rola-utils/functions/src/copy_with_temp_rename/async.rs
@@ -0,0 +1,222 @@
+use std::borrow::Cow;
+use std::path::{Path, PathBuf};
+use tokio::io;
+
+/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination.
+pub async fn copy_with_temp_rename_async(
+ src: impl AsRef<Path>,
+ dst: impl AsRef<Path>,
+) -> io::Result<()> {
+ internal_copy_with_temp_rename(src.as_ref(), dst.as_ref()).await
+}
+
+async fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> {
+ // Check if source file exists to avoid later failures
+ if !tokio::fs::try_exists(src)
+ .await
+ .map_err(|e| io::Error::new(e.kind(), format!("failed to check source existence: {}", e)))?
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("source file does not exist: {}", src.display()),
+ ));
+ }
+
+ // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy
+ // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison
+ let src_canonical = tokio::fs::canonicalize(src).await;
+ let dst_exists = tokio::fs::try_exists(dst).await.unwrap_or(false);
+ if dst_exists {
+ let dst_canonical = tokio::fs::canonicalize(dst).await;
+ match (src_canonical, dst_canonical) {
+ (Ok(src_c), Ok(dst_c)) => {
+ if src_c == dst_c {
+ return Ok(());
+ }
+ }
+ (Err(e), _) | (_, Err(e)) => {
+ return Err(io::Error::new(
+ io::ErrorKind::Other,
+ format!(
+ "failed to canonicalize paths (src: {}, dst: {}): {}",
+ src.display(),
+ dst.display(),
+ e
+ ),
+ ));
+ }
+ }
+ } else {
+ // dst doesn't exist yet, so it cannot be the same file as src
+ // If src canonicalization fails, propagate the error
+ let _ = src_canonical.map_err(|e| {
+ io::Error::new(
+ io::ErrorKind::Other,
+ format!(
+ "failed to canonicalize source path (src: {}): {}",
+ src.display(),
+ e
+ ),
+ )
+ })?;
+ }
+
+ // Ensure both source and destination are regular files, preventing accidental operations on directories
+ {
+ let src_meta = tokio::fs::metadata(src).await.map_err(|e| {
+ io::Error::new(
+ e.kind(),
+ format!("failed to get source metadata for {}: {}", src.display(), e),
+ )
+ })?;
+ if !src_meta.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("source is not a regular file: {}", src.display()),
+ ));
+ }
+
+ if dst_exists {
+ let dst_meta = tokio::fs::metadata(dst).await.map_err(|e| {
+ io::Error::new(
+ e.kind(),
+ format!(
+ "failed to get destination metadata for {}: {}",
+ dst.display(),
+ e
+ ),
+ )
+ })?;
+ if !dst_meta.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "destination exists and is not a regular file: {}",
+ dst.display()
+ ),
+ ));
+ }
+ }
+ }
+
+ // Ensure the parent directory of the destination exists, creating it if necessary
+ if let Some(parent) = dst.parent() {
+ tokio::fs::create_dir_all(parent).await?;
+ }
+
+ // Create a temporary file in the destination directory as an intermediate write target
+ let temp_dir = dst.parent().unwrap_or_else(|| Path::new("."));
+ let base = src
+ .file_name()
+ .map(|f| f.to_string_lossy())
+ .unwrap_or_else(|| Cow::Borrowed("temp"));
+ let temp_path = atomic_create_temp(temp_dir, &base).await?;
+
+ // Copy source file contents to the temporary file; clean up on failure
+ if let Err(e) = tokio::fs::copy(src, &temp_path).await {
+ let _ = tokio::fs::remove_file(&temp_path).await;
+ return Err(e);
+ }
+
+ // Atomically replace the destination file; clean up temporary file on failure
+ if let Err(e) = atomic_rename(&temp_path, dst).await {
+ let _ = tokio::fs::remove_file(&temp_path).await;
+ return Err(e);
+ }
+
+ // Copy source file permissions to the destination
+ let src_meta = tokio::fs::metadata(src).await?;
+ let _ = tokio::fs::set_permissions(dst, src_meta.permissions()).await;
+
+ Ok(())
+}
+
+/// Atomically renames a path, replacing the destination if it exists.
+async fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ tokio::fs::rename(src, dst).await
+ }
+
+ #[cfg(not(unix))]
+ {
+ use std::ffi::OsStr;
+ use std::os::windows::ffi::OsStrExt;
+
+ // Convert OsStr to a null-terminated UTF-16 vector
+ fn to_wide_null(os_str: &OsStr) -> Vec<u16> {
+ os_str.encode_wide().chain(Some(0)).collect()
+ }
+
+ let src_wide = to_wide_null(src.as_os_str());
+ let dst_wide = to_wide_null(dst.as_os_str());
+
+ // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s,
+ // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated.
+ // Declares the Windows API MoveFileExW for atomic renaming.
+ unsafe extern "system" {
+ /// MOVEFILE_REPLACE_EXISTING = 1
+ fn MoveFileExW(
+ lpExistingFileName: *const u16,
+ lpNewFileName: *const u16,
+ dwFlags: u32,
+ ) -> i32;
+ }
+
+ const MOVEFILE_REPLACE_EXISTING: u32 = 1;
+
+ // SAFETY:
+ // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime.
+ // 2. `MoveFileExW` is a Windows system API declared via extern "system",
+ // with correct calling convention and ABI.
+ // 3. Return value is checked; errors are handled via `last_os_error()`.
+ let ret = unsafe {
+ MoveFileExW(
+ src_wide.as_ptr(),
+ dst_wide.as_ptr(),
+ MOVEFILE_REPLACE_EXISTING,
+ )
+ };
+
+ if ret == 0 {
+ Err(io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+async fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> {
+ use tokio::fs::OpenOptions;
+
+ // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts
+ let make_name = |i: usize| {
+ let name = if i == 0 {
+ format!(".temp_{}", base)
+ } else {
+ format!(".temp_{}_{}", base, i)
+ };
+ dir.join(name)
+ };
+
+ const MAX_ATTEMPTS: usize = 10000;
+ // Loop to create a new file; create_new ensures the file does not already exist
+ for i in 0..MAX_ATTEMPTS {
+ let path = make_name(i);
+ match OpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&path)
+ .await
+ {
+ Ok(_) => return Ok(path),
+ Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(e) => return Err(e),
+ }
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::Other,
+ "exceeded max attempts to create temp file",
+ ))
+}
diff --git a/rola-utils/functions/src/copy_with_temp_rename/sync.rs b/rola-utils/functions/src/copy_with_temp_rename/sync.rs
new file mode 100644
index 0000000..cdbe3db
--- /dev/null
+++ b/rola-utils/functions/src/copy_with_temp_rename/sync.rs
@@ -0,0 +1,209 @@
+use std::borrow::Cow;
+use std::io;
+use std::path::{Path, PathBuf};
+
+/// Safely copies a file: first copies to a temporary file, then atomically replaces the destination.
+pub fn copy_with_temp_rename(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
+ internal_copy_with_temp_rename(src.as_ref(), dst.as_ref())
+}
+
+fn internal_copy_with_temp_rename(src: &Path, dst: &Path) -> io::Result<()> {
+ // Check if source file exists to avoid later failures
+ if !src.exists() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ format!("source file does not exist: {}", src.display()),
+ ));
+ }
+
+ // Canonicalize paths and compare; if same file, return early to avoid unnecessary copy
+ // Only canonicalize destination if it exists; if it doesn't exist, skip the comparison
+ let src_canonical = std::fs::canonicalize(src);
+ if dst.exists() {
+ let dst_canonical = std::fs::canonicalize(dst);
+ match (src_canonical, dst_canonical) {
+ (Ok(src_c), Ok(dst_c)) => {
+ if src_c == dst_c {
+ return Ok(());
+ }
+ }
+ (Err(e), _) | (_, Err(e)) => {
+ return Err(io::Error::new(
+ io::ErrorKind::Other,
+ format!(
+ "failed to canonicalize paths (src: {}, dst: {}): {}",
+ src.display(),
+ dst.display(),
+ e
+ ),
+ ));
+ }
+ }
+ } else {
+ // dst doesn't exist yet, so it cannot be the same file as src
+ // If src canonicalization fails, propagate the error
+ let _ = src_canonical.map_err(|e| {
+ io::Error::new(
+ io::ErrorKind::Other,
+ format!(
+ "failed to canonicalize source path (src: {}): {}",
+ src.display(),
+ e
+ ),
+ )
+ })?;
+ }
+
+ // Ensure both source and destination are regular files, preventing accidental operations on directories
+ {
+ let src_meta = src.metadata().map_err(|e| {
+ io::Error::new(
+ e.kind(),
+ format!("failed to get source metadata for {}: {}", src.display(), e),
+ )
+ })?;
+ if !src_meta.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("source is not a regular file: {}", src.display()),
+ ));
+ }
+
+ if dst.exists() {
+ let dst_meta = dst.metadata().map_err(|e| {
+ io::Error::new(
+ e.kind(),
+ format!(
+ "failed to get destination metadata for {}: {}",
+ dst.display(),
+ e
+ ),
+ )
+ })?;
+ if !dst_meta.is_file() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!(
+ "destination exists and is not a regular file: {}",
+ dst.display()
+ ),
+ ));
+ }
+ }
+ }
+
+ // Ensure the parent directory of the destination exists, creating it if necessary
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ // Create a temporary file in the destination directory as an intermediate write target
+ let temp_dir = dst.parent().unwrap_or_else(|| Path::new("."));
+ let base = src
+ .file_name()
+ .map(|f| f.to_string_lossy())
+ .unwrap_or_else(|| Cow::Borrowed("temp"));
+ let temp_path = atomic_create_temp(temp_dir, &base)?;
+
+ // Copy source file contents to the temporary file; clean up on failure
+ if let Err(e) = std::fs::copy(src, &temp_path) {
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(e);
+ }
+
+ // Atomically replace the destination file; clean up temporary file on failure
+ if let Err(e) = atomic_rename(&temp_path, dst) {
+ let _ = std::fs::remove_file(&temp_path);
+ return Err(e);
+ }
+
+ // Copy source file permissions to the destination
+ let _ = std::fs::set_permissions(dst, src.metadata()?.permissions());
+
+ Ok(())
+}
+
+/// Atomically renames a path, replacing the destination if it exists.
+fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
+ #[cfg(unix)]
+ {
+ std::fs::rename(src, dst)
+ }
+
+ #[cfg(not(unix))]
+ {
+ use std::ffi::OsStr;
+ use std::os::windows::ffi::OsStrExt;
+
+ // Convert OsStr to a null-terminated UTF-16 vector
+ fn to_wide_null(os_str: &OsStr) -> Vec<u16> {
+ os_str.encode_wide().chain(Some(0)).collect()
+ }
+
+ let src_wide = to_wide_null(src.as_os_str());
+ let dst_wide = to_wide_null(dst.as_os_str());
+
+ // SAFETY: `src_wide` and `dst_wide` are valid, owned `Vec<u16>`s,
+ // the pointers returned by `as_ptr()` are valid for their lifetime and are null-terminated.
+ // Declares the Windows API MoveFileExW for atomic renaming.
+ unsafe extern "system" {
+ /// MOVEFILE_REPLACE_EXISTING = 1
+ fn MoveFileExW(
+ lpExistingFileName: *const u16,
+ lpNewFileName: *const u16,
+ dwFlags: u32,
+ ) -> i32;
+ }
+
+ const MOVEFILE_REPLACE_EXISTING: u32 = 1;
+
+ // SAFETY:
+ // 1. `src_wide` and `dst_wide` are null-terminated UTF-16 strings, valid for their lifetime.
+ // 2. `MoveFileExW` is a Windows system API declared via extern "system",
+ // with correct calling convention and ABI.
+ // 3. Return value is checked; errors are handled via `last_os_error()`.
+ let ret = unsafe {
+ MoveFileExW(
+ src_wide.as_ptr(),
+ dst_wide.as_ptr(),
+ MOVEFILE_REPLACE_EXISTING,
+ )
+ };
+
+ if ret == 0 {
+ Err(io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+ }
+}
+
+fn atomic_create_temp(dir: &Path, base: &str) -> io::Result<PathBuf> {
+ use std::fs::OpenOptions;
+
+ // Generate temporary file names; first attempt without index, then incrementing to avoid conflicts
+ let make_name = |i: usize| {
+ let name = if i == 0 {
+ format!(".temp_{}", base)
+ } else {
+ format!(".temp_{}_{}", base, i)
+ };
+ dir.join(name)
+ };
+
+ const MAX_ATTEMPTS: usize = 10000;
+ // Loop to create a new file; create_new ensures the file does not already exist
+ for i in 0..MAX_ATTEMPTS {
+ let path = make_name(i);
+ match OpenOptions::new().create_new(true).write(true).open(&path) {
+ Ok(_) => return Ok(path),
+ Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(e) => return Err(e),
+ }
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::Other,
+ "exceeded max attempts to create temp file",
+ ))
+}
diff --git a/rola-utils/functions/src/lib.rs b/rola-utils/functions/src/lib.rs
index 3ba2507..72d5b9c 100644
--- a/rola-utils/functions/src/lib.rs
+++ b/rola-utils/functions/src/lib.rs
@@ -1,3 +1,6 @@
+mod copy_with_temp_rename;
+pub use copy_with_temp_rename::*;
+
mod levenshtein_distance;
pub use levenshtein_distance::*;