use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub fn set_version_link(link: &Path, target: &Path) -> Result<()> {
let tmp_link = tmp_link_path(link);
let _ = remove_link_if_exists(&tmp_link);
create_link(&tmp_link, target)?;
if let Err(e) = remove_link_if_exists(link) {
let _ = remove_link_if_exists(&tmp_link);
return Err(e);
}
std::fs::rename(&tmp_link, link).with_context(|| {
format!(
"Failed to activate new link at {} (staged at {})",
link.display(),
tmp_link.display()
)
})
}
fn tmp_link_path(link: &Path) -> PathBuf {
let file_name = link.file_name().unwrap_or_default().to_string_lossy();
link.with_file_name(format!("{file_name}.tmp-{}", std::process::id()))
}
#[cfg(windows)]
fn remove_link_if_exists(link: &Path) -> Result<()> {
match std::fs::symlink_metadata(link) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => {
Err(anyhow::Error::from(e)).with_context(|| format!("Cannot stat {}", link.display()))
}
Ok(_) => std::fs::remove_dir(link)
.with_context(|| format!("Failed to remove {}", link.display())),
}
}
#[cfg(not(windows))]
fn remove_link_if_exists(link: &Path) -> Result<()> {
match std::fs::symlink_metadata(link) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => {
Err(anyhow::Error::from(e)).with_context(|| format!("Cannot stat {}", link.display()))
}
Ok(_) => std::fs::remove_file(link)
.with_context(|| format!("Failed to remove {}", link.display())),
}
}
#[cfg(windows)]
fn create_link(link: &Path, target: &Path) -> Result<()> {
let link_arg = windows_backslash_path(link);
let target_arg = windows_backslash_path(target);
let out = std::process::Command::new("cmd")
.arg("/c")
.arg("mklink")
.arg("/J")
.arg(&link_arg)
.arg(&target_arg)
.output()
.context("Failed to run cmd for mklink")?;
if !out.status.success() {
let msg = String::from_utf8_lossy(&out.stderr);
return Err(anyhow::anyhow!(
"Failed to create junction {} -> {}: {}",
link.display(),
target.display(),
msg.trim()
));
}
Ok(())
}
#[cfg(windows)]
fn windows_backslash_path(path: &Path) -> String {
path.display().to_string().replace('/', "\\")
}
#[cfg(not(windows))]
fn create_link(link: &Path, target: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link).with_context(|| {
format!(
"Failed to create symlink {} -> {}",
link.display(),
target.display()
)
})
}
pub fn move_dir(src: &Path, dst: &Path) -> Result<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(_) => {
copy_dir_all(src, dst).with_context(|| {
format!("Failed to copy {} to {}", src.display(), dst.display())
})?;
std::fs::remove_dir_all(src)
.with_context(|| format!("Failed to remove {}", src.display()))
}
}
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &dst_path)?;
} else {
std::fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn move_dir_relocates_contents_via_rename() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
std::fs::create_dir_all(src.join("nested")).unwrap();
std::fs::write(src.join("file.txt"), b"hello").unwrap();
std::fs::write(src.join("nested").join("inner.txt"), b"world").unwrap();
move_dir(&src, &dst).unwrap();
assert!(!src.exists());
assert_eq!(
std::fs::read_to_string(dst.join("file.txt")).unwrap(),
"hello"
);
assert_eq!(
std::fs::read_to_string(dst.join("nested").join("inner.txt")).unwrap(),
"world"
);
}
#[test]
fn move_dir_errors_when_source_missing() {
let root = tempdir().unwrap();
let src = root.path().join("does-not-exist");
let dst = root.path().join("dst");
assert!(move_dir(&src, &dst).is_err());
}
#[test]
fn copy_dir_all_recreates_full_tree() {
let root = tempdir().unwrap();
let src = root.path().join("src");
let dst = root.path().join("dst");
std::fs::create_dir_all(src.join("a").join("b")).unwrap();
std::fs::write(src.join("top.txt"), b"top").unwrap();
std::fs::write(src.join("a").join("b").join("deep.txt"), b"deep").unwrap();
copy_dir_all(&src, &dst).unwrap();
assert!(src.exists());
assert_eq!(std::fs::read_to_string(dst.join("top.txt")).unwrap(), "top");
assert_eq!(
std::fs::read_to_string(dst.join("a").join("b").join("deep.txt")).unwrap(),
"deep"
);
}
#[test]
fn set_version_link_creates_and_replaces() {
let root = tempdir().unwrap();
let target_a = root.path().join("version-a");
let target_b = root.path().join("version-b");
std::fs::create_dir_all(&target_a).unwrap();
std::fs::create_dir_all(&target_b).unwrap();
std::fs::write(target_a.join("marker.txt"), b"a").unwrap();
std::fs::write(target_b.join("marker.txt"), b"b").unwrap();
let link = root.path().join("current");
set_version_link(&link, &target_a).unwrap();
assert_eq!(
std::fs::read_to_string(link.join("marker.txt")).unwrap(),
"a"
);
set_version_link(&link, &target_b).unwrap();
assert_eq!(
std::fs::read_to_string(link.join("marker.txt")).unwrap(),
"b"
);
}
#[test]
fn remove_link_if_exists_is_noop_when_absent() {
let root = tempdir().unwrap();
let link = root.path().join("nonexistent-link");
assert!(remove_link_if_exists(&link).is_ok());
}
#[cfg(windows)]
#[test]
fn windows_backslash_path_converts_forward_slashes() {
let path = Path::new("C:/Users/x/.gvsn/current");
assert_eq!(windows_backslash_path(path), r"C:\Users\x\.gvsn\current");
}
#[cfg(windows)]
#[test]
fn set_version_link_succeeds_with_forward_slash_paths() {
let root = tempdir().unwrap();
let target = root.path().join("version-a");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("marker.txt"), b"a").unwrap();
let link = root.path().join("current");
let link_fwd = Path::new(&link.display().to_string().replace('\\', "/")).to_path_buf();
let target_fwd = Path::new(&target.display().to_string().replace('\\', "/")).to_path_buf();
set_version_link(&link_fwd, &target_fwd).unwrap();
assert_eq!(
std::fs::read_to_string(link.join("marker.txt")).unwrap(),
"a"
);
}
}