use std::path::{Path, PathBuf};
pub const N_BACKUPS: usize = 3;
fn backup_path(target: &Path, idx: usize) -> PathBuf {
let mut name = target
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
name.push_str(&format!(".bak.{idx}"));
target.with_file_name(name)
}
pub fn rotate_backups(target: &Path) {
if !target.is_file() {
return;
}
for i in (1..N_BACKUPS).rev() {
let src = backup_path(target, i - 1);
let dst = backup_path(target, i);
let _ = std::fs::rename(&src, &dst);
}
let _ = std::fs::rename(target, backup_path(target, 0));
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::TempDir;
use std::fs;
fn write(path: &Path, contents: &str) {
fs::write(path, contents).unwrap();
}
fn read(path: &Path) -> Option<String> {
fs::read_to_string(path).ok()
}
#[test]
fn no_op_when_target_missing() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("state.ron");
rotate_backups(&target);
assert!(read(&backup_path(&target, 0)).is_none());
}
#[test]
fn first_rotation_moves_target_to_bak_0() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("state.ron");
write(&target, "v1");
rotate_backups(&target);
assert_eq!(None, read(&target));
assert_eq!(Some("v1".to_string()), read(&backup_path(&target, 0)));
}
#[test]
fn second_rotation_pushes_bak_0_to_bak_1() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("state.ron");
write(&target, "v1");
rotate_backups(&target);
write(&target, "v2");
rotate_backups(&target);
assert_eq!(None, read(&target));
assert_eq!(Some("v2".to_string()), read(&backup_path(&target, 0)));
assert_eq!(Some("v1".to_string()), read(&backup_path(&target, 1)));
}
#[test]
fn oldest_rotation_is_dropped_at_n_backups() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("state.ron");
for i in 1..=(N_BACKUPS + 2) {
write(&target, &format!("v{i}"));
rotate_backups(&target);
}
let total = N_BACKUPS + 2;
for slot in 0..N_BACKUPS {
let expected = format!("v{}", total - slot);
assert_eq!(Some(expected), read(&backup_path(&target, slot)));
}
assert_eq!(None, read(&backup_path(&target, N_BACKUPS)));
}
#[test]
fn missing_intermediate_slot_is_tolerated() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("state.ron");
write(&target, "v1");
rotate_backups(&target); fs::remove_file(backup_path(&target, 0)).unwrap();
write(&target, "v2");
rotate_backups(&target); assert_eq!(Some("v2".to_string()), read(&backup_path(&target, 0)));
}
}