use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct BackupPaths {
pub workload: PathBuf,
pub bak: PathBuf,
pub bak_prev: PathBuf,
pub temp: PathBuf,
}
impl BackupPaths {
pub fn for_workload(workload_path: &Path) -> Self {
let workload = workload_path.to_path_buf();
let bak = path_with_suffix(&workload, ".bak");
let bak_prev = path_with_suffix(&workload, ".bak.prev");
let temp = path_with_suffix(&workload, ".tmp");
Self {
workload,
bak,
bak_prev,
temp,
}
}
}
fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
let mut s = p.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
pub fn rotate(workload_path: &Path) -> io::Result<BackupPaths> {
let paths = BackupPaths::for_workload(workload_path);
if !paths.workload.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"workload '{}' does not exist; cannot create backup",
paths.workload.display()
),
));
}
if paths.bak.exists() {
let _ = fs::remove_file(&paths.bak_prev);
fs::rename(&paths.bak, &paths.bak_prev).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"backup rotate {} → {}: {e}",
paths.bak.display(),
paths.bak_prev.display()
),
)
})?;
}
fs::copy(&paths.workload, &paths.bak).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"backup copy {} → {}: {e}",
paths.workload.display(),
paths.bak.display()
),
)
})?;
Ok(paths)
}
pub fn commit_temp(paths: &BackupPaths) -> io::Result<()> {
fs::rename(&paths.temp, &paths.workload).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"commit {} → {}: {e}",
paths.temp.display(),
paths.workload.display()
),
)
})
}
pub fn rollback(paths: &BackupPaths) -> io::Result<()> {
let _ = fs::remove_file(&paths.temp);
if paths.bak_prev.exists() {
let _ = fs::remove_file(&paths.bak);
fs::rename(&paths.bak_prev, &paths.bak).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"backup rollback {} → {}: {e}",
paths.bak_prev.display(),
paths.bak.display()
),
)
})?;
} else {
let _ = fs::remove_file(&paths.bak);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn fresh_dir(label: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let p = std::env::temp_dir().join(format!(
"nmbrs-edit-backup-{label}-{}-{n}",
std::process::id(),
));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn first_edit_creates_bak_only() {
let dir = fresh_dir("first_edit");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let paths = rotate(&workload).expect("rotate");
assert!(paths.bak.exists(), ".bak should exist");
assert!(
!paths.bak_prev.exists(),
".bak.prev should not exist on first edit"
);
assert_eq!(fs::read(&paths.bak).unwrap(), b"v1\n");
}
#[test]
fn second_edit_promotes_bak_to_bak_prev() {
let dir = fresh_dir("second_edit");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let paths = rotate(&workload).expect("rotate v1");
fs::write(&workload, b"v2\n").unwrap();
let paths2 = rotate(&workload).expect("rotate v2");
assert!(paths2.bak.exists());
assert!(paths2.bak_prev.exists());
assert_eq!(
fs::read(&paths2.bak).unwrap(),
b"v2\n",
".bak should hold the just-prior content (v2)"
);
assert_eq!(
fs::read(&paths2.bak_prev).unwrap(),
b"v1\n",
".bak.prev should hold the one-before-prior content (v1)"
);
let _ = paths;
}
#[test]
fn third_edit_drops_oldest() {
let dir = fresh_dir("third_edit");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let _ = rotate(&workload).expect("rotate v1");
fs::write(&workload, b"v2\n").unwrap();
let _ = rotate(&workload).expect("rotate v2");
fs::write(&workload, b"v3\n").unwrap();
let paths = rotate(&workload).expect("rotate v3");
assert_eq!(fs::read(&paths.bak).unwrap(), b"v3\n");
assert_eq!(fs::read(&paths.bak_prev).unwrap(), b"v2\n");
}
#[test]
fn commit_temp_renames_atomically() {
let dir = fresh_dir("commit_temp");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let paths = rotate(&workload).expect("rotate");
fs::write(&paths.temp, b"v2 new content\n").unwrap();
commit_temp(&paths).expect("commit");
assert_eq!(fs::read(&workload).unwrap(), b"v2 new content\n");
assert!(!paths.temp.exists(), "temp consumed by rename");
assert_eq!(
fs::read(&paths.bak).unwrap(),
b"v1\n",
".bak still holds pre-edit content"
);
}
#[test]
fn rollback_after_first_edit_restores_no_history_state() {
let dir = fresh_dir("rollback_first");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let paths = rotate(&workload).expect("rotate");
rollback(&paths).expect("rollback");
assert!(
!paths.bak.exists(),
".bak should be dropped on rollback of first edit"
);
assert!(!paths.bak_prev.exists());
assert_eq!(
fs::read(&workload).unwrap(),
b"v1\n",
"workload itself untouched"
);
}
#[test]
fn rollback_after_second_edit_restores_pre_rotate_state() {
let dir = fresh_dir("rollback_second");
let workload = dir.join("w.yaml");
fs::write(&workload, b"v1\n").unwrap();
let _ = rotate(&workload).expect("first rotate");
fs::write(&workload, b"v2\n").unwrap();
let paths2 = rotate(&workload).expect("second rotate");
rollback(&paths2).expect("rollback");
assert_eq!(
fs::read(&paths2.bak).unwrap(),
b"v1\n",
".bak restored from .bak.prev"
);
assert!(!paths2.bak_prev.exists(), ".bak.prev consumed by rollback");
assert_eq!(
fs::read(&workload).unwrap(),
b"v2\n",
"workload still at v2 (rollback doesn't touch workload — that's the temp's job)"
);
}
#[test]
fn rotate_errors_when_workload_missing() {
let dir = fresh_dir("missing_workload");
let workload = dir.join("missing.yaml");
let err = rotate(&workload).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(err.to_string().contains("does not exist"), "got: {err}");
}
}