use std::path::{Path, PathBuf};
use worktrunk::path::format_path_for_display;
fn generate_backup_path(path: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
let file_name = path.file_name().ok_or_else(|| {
anyhow::anyhow!(
"Cannot generate backup path for {}",
format_path_for_display(path)
)
})?;
if path.extension().is_none() {
Ok(path.with_file_name(format!("{}.bak.{suffix}", file_name.to_string_lossy())))
} else {
Ok(path.with_extension(format!(
"{}.bak.{suffix}",
path.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default()
)))
}
}
fn back_up_clobbered_path(blocking_path: &Path, base_suffix: &str) -> anyhow::Result<PathBuf> {
let mut n: u64 = 1;
loop {
let suffix = if n == 1 {
base_suffix.to_string()
} else {
format!("{base_suffix}-{n}")
};
let candidate = generate_backup_path(blocking_path, &suffix)?;
match renamore::rename_exclusive(blocking_path, &candidate) {
Ok(()) => return Ok(candidate),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
Err(err) => {
return Err(anyhow::Error::new(err).context(format!(
"Failed to move {} to {}",
format_path_for_display(blocking_path),
format_path_for_display(&candidate),
)));
}
}
}
}
pub(crate) fn back_up_clobbered_path_now(blocking_path: &Path) -> anyhow::Result<PathBuf> {
let timestamp_secs = worktrunk::utils::epoch_now() as i64;
let datetime =
chrono::DateTime::from_timestamp(timestamp_secs, 0).unwrap_or_else(chrono::Utc::now);
let base_suffix = datetime.format("%Y%m%d-%H%M%S").to_string();
back_up_clobbered_path(blocking_path, &base_suffix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_backup_path_with_extension() {
let path = PathBuf::from("/tmp/repo.feature");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(
backup,
PathBuf::from("/tmp/repo.feature.bak.20250101-000000")
);
let path = PathBuf::from("/tmp/file.txt");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(backup, PathBuf::from("/tmp/file.txt.bak.20250101-000000"));
}
#[test]
fn test_generate_backup_path_without_extension() {
let path = PathBuf::from("/tmp/repo/feature");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(
backup,
PathBuf::from("/tmp/repo/feature.bak.20250101-000000")
);
let path = PathBuf::from("/tmp/mydir");
let backup = generate_backup_path(&path, "20250101-000000").unwrap();
assert_eq!(backup, PathBuf::from("/tmp/mydir.bak.20250101-000000"));
}
#[test]
fn test_generate_backup_path_unusual_paths() {
let path = PathBuf::from("/");
assert!(generate_backup_path(&path, "20250101-000000").is_err());
let path = PathBuf::from("..");
assert!(generate_backup_path(&path, "20250101-000000").is_err());
}
#[test]
fn test_back_up_clobbered_path_moves_to_fresh_suffix() {
let temp = tempfile::tempdir().unwrap();
let stale = temp.path().join("feature");
std::fs::create_dir(&stale).unwrap();
std::fs::write(stale.join("file"), "content").unwrap();
let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap();
assert_eq!(used, temp.path().join("feature.bak.20250101-000000"));
assert!(!stale.exists(), "stale path should be moved away");
assert_eq!(
std::fs::read_to_string(used.join("file")).unwrap(),
"content"
);
}
#[test]
fn test_back_up_clobbered_path_falls_back_when_suffix_taken() {
let temp = tempfile::tempdir().unwrap();
let stale = temp.path().join("feature");
std::fs::create_dir(&stale).unwrap();
let taken = temp.path().join("feature.bak.20250101-000000");
std::fs::create_dir(&taken).unwrap();
std::fs::write(taken.join("keep"), "pre-existing").unwrap();
std::fs::create_dir(temp.path().join("feature.bak.20250101-000000-2")).unwrap();
let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap();
assert_eq!(used, temp.path().join("feature.bak.20250101-000000-3"));
assert!(!stale.exists());
assert_eq!(
std::fs::read_to_string(taken.join("keep")).unwrap(),
"pre-existing"
);
}
#[test]
fn test_back_up_clobbered_path_errors_when_source_missing() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("does-not-exist");
let err = back_up_clobbered_path(&missing, "20250101-000000").unwrap_err();
assert!(
err.to_string().contains("Failed to move"),
"expected wrapped error, got: {err}"
);
}
#[test]
fn test_back_up_clobbered_path_keeps_incrementing_past_many_collisions() {
let temp = tempfile::tempdir().unwrap();
let stale = temp.path().join("feature");
std::fs::create_dir(&stale).unwrap();
std::fs::create_dir(temp.path().join("feature.bak.S")).unwrap();
for n in 2..=50 {
std::fs::create_dir(temp.path().join(format!("feature.bak.S-{n}"))).unwrap();
}
let used = back_up_clobbered_path(&stale, "S").unwrap();
assert_eq!(used, temp.path().join("feature.bak.S-51"));
assert!(!stale.exists(), "stale path should be moved away");
}
#[test]
fn test_back_up_clobbered_path_now_uses_timestamped_suffix() {
let temp = tempfile::tempdir().unwrap();
let stale = temp.path().join("feature");
std::fs::create_dir(&stale).unwrap();
let used = back_up_clobbered_path_now(&stale).unwrap();
let name = used.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("feature.bak."),
"expected timestamped backup name, got: {name}"
);
assert!(!stale.exists(), "stale path should be moved away");
}
}