upstream-rs 2.4.1

Fetch package updates directly from the source.
Documentation
use anyhow::{Context, Result};
use std::{
    fs, io,
    path::{Path, PathBuf},
};

/// Move a file or directory, falling back to copy+delete
/// if the source and dest are on different filesystems.
pub fn move_file_or_dir(src: &Path, dst: &Path) -> Result<()> {
    match fs::rename(src, dst) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == io::ErrorKind::CrossesDevices => move_via_copy(src, dst),
        Err(err) => Err(err).context(format!(
            "Failed to move '{}' to '{}'",
            src.display(),
            dst.display()
        )),
    }
}

/// Copy the source to destination and remove the source when rename cannot be used.
fn move_via_copy(src: &Path, dst: &Path) -> Result<()> {
    let metadata = fs::metadata(src)
        .with_context(|| format!("Failed to read metadata for '{}'", src.display()))?;

    if metadata.is_dir() {
        copy_dir_recursive(src, dst)
            .with_context(|| format!("Failed to copy directory to '{}'", dst.display()))?;
        fs::remove_dir_all(src)
            .with_context(|| format!("Failed to remove source directory '{}'", src.display()))?;
        return Ok(());
    }

    fs::copy(src, dst).with_context(|| {
        format!(
            "Failed to copy file from '{}' to '{}'",
            src.display(),
            dst.display()
        )
    })?;
    fs::set_permissions(dst, metadata.permissions())
        .with_context(|| format!("Failed to preserve file permissions on '{}'", dst.display()))?;
    fs::remove_file(src)
        .with_context(|| format!("Failed to remove source file '{}'", src.display()))?;
    Ok(())
}

/// Recursively copy a directory while preserving permissions and symlinks.
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
    if dst.exists() {
        return Err(io::Error::new(
            io::ErrorKind::AlreadyExists,
            format!("Destination already exists: '{}'", dst.display()),
        ));
    }

    fs::create_dir_all(dst)?;
    let src_metadata = fs::metadata(src)?;
    fs::set_permissions(dst, src_metadata.permissions())?;

    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let entry_path = entry.path();
        let target_path: PathBuf = dst.join(entry.file_name());
        let file_type = entry.file_type()?;

        if file_type.is_dir() {
            copy_dir_recursive(&entry_path, &target_path)?;
        } else if file_type.is_file() {
            fs::copy(&entry_path, &target_path)?;
            let source_permissions = fs::metadata(&entry_path)?.permissions();
            fs::set_permissions(&target_path, source_permissions)?;
        } else if file_type.is_symlink() {
            copy_symlink(&entry_path, &target_path)?;
        } else {
            return Err(io::Error::other(format!(
                "Unsupported entry type while moving directory: '{}'",
                entry_path.display()
            )));
        }
    }

    Ok(())
}

/// Recreate a symlink at `dst` with the same link target as `src`.
fn copy_symlink(src: &Path, dst: &Path) -> io::Result<()> {
    let link_target = fs::read_link(src)?;

    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(link_target, dst)
    }

    #[cfg(windows)]
    {
        if src.metadata()?.is_dir() {
            return std::os::windows::fs::symlink_dir(link_target, dst);
        }
        return std::os::windows::fs::symlink_file(link_target, dst);
    }
}

#[cfg(test)]
mod tests {
    use super::{move_file_or_dir, move_via_copy};
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_root(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        std::env::temp_dir().join(format!("upstream-fs-move-test-{name}-{nanos}"))
    }

    #[test]
    fn move_file_or_dir_moves_file_with_rename_path() {
        let root = temp_root("rename");
        fs::create_dir_all(&root).expect("create root");
        let src = root.join("source.bin");
        let dst = root.join("dest.bin");
        fs::write(&src, b"content").expect("write source");

        move_file_or_dir(&src, &dst).expect("rename move");

        assert!(!src.exists());
        assert_eq!(fs::read(&dst).expect("read destination"), b"content");

        fs::remove_dir_all(root).expect("cleanup");
    }

    #[test]
    fn fallback_move_copies_and_removes_source_file() {
        let root = temp_root("fallback-file");
        fs::create_dir_all(&root).expect("create root");
        let src = root.join("source.txt");
        let dst = root.join("dest.txt");
        fs::write(&src, b"hello").expect("write source");

        move_via_copy(&src, &dst).expect("fallback move");

        assert!(!src.exists());
        assert_eq!(fs::read(&dst).expect("read destination"), b"hello");

        fs::remove_dir_all(root).expect("cleanup");
    }

    #[test]
    fn fallback_move_handles_directories_recursively() {
        let root = temp_root("fallback-dir");
        let src = root.join("src");
        let dst = root.join("dst");
        fs::create_dir_all(src.join("nested")).expect("create nested src");
        fs::write(src.join("nested/file.txt"), b"nested-data").expect("write nested file");

        move_via_copy(&src, &dst).expect("fallback dir move");

        assert!(!src.exists());
        assert_eq!(
            fs::read(dst.join("nested/file.txt")).expect("read moved file"),
            b"nested-data"
        );

        fs::remove_dir_all(root).expect("cleanup");
    }
}