use std::path::{Path, PathBuf};
const SNAPSHOTS_DIR: &str = "snapshots";
const CURRENT_FILE: &str = "CURRENT";
pub struct Snapshot {
id: String,
dir: PathBuf,
committed: bool,
}
impl Snapshot {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
}
impl Drop for Snapshot {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
}
pub struct SnapshotStore {
dir: PathBuf,
current_id: Option<String>,
}
impl SnapshotStore {
pub fn open(dir: &Path) -> crate::Result<Self> {
let current_path = dir.join(CURRENT_FILE);
let current_id = if current_path.exists() {
Some(read_current(¤t_path)?)
} else {
None
};
Ok(Self {
dir: dir.to_path_buf(),
current_id,
})
}
#[must_use]
pub fn current_id(&self) -> Option<&str> {
self.current_id.as_deref()
}
#[must_use]
pub fn current_dir(&self) -> Option<PathBuf> {
self.current_id
.as_deref()
.map(|id| self.dir.join(SNAPSHOTS_DIR).join(id))
}
pub fn begin(&self) -> crate::Result<Snapshot> {
let snapshots_dir = self.dir.join(SNAPSHOTS_DIR);
std::fs::create_dir_all(&snapshots_dir)?;
let id = snapshot_id();
let tmp_dir = snapshots_dir.join(format!("tmp-{id}"));
std::fs::create_dir_all(&tmp_dir)?;
Ok(Snapshot {
id,
dir: tmp_dir,
committed: false,
})
}
pub fn commit(&mut self, mut snapshot: Snapshot) -> crate::Result<()> {
let snapshots_dir = self.dir.join(SNAPSHOTS_DIR);
let final_dir = snapshots_dir.join(&snapshot.id);
std::fs::rename(&snapshot.dir, &final_dir)?;
snapshot.committed = true;
let current_path = self.dir.join(CURRENT_FILE);
write_atomic(¤t_path, &snapshot.id)?;
let old_current = self.current_id.replace(snapshot.id.clone());
let mut keep: Vec<&str> = vec![&snapshot.id];
if let Some(ref old_id) = old_current {
keep.push(old_id.as_str());
}
gc_snapshots(&snapshots_dir, &keep)?;
Ok(())
}
}
fn snapshot_id() -> String {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{:010x}-{:08x}", d.as_secs(), d.subsec_nanos())
}
fn read_current(path: &Path) -> crate::Result<String> {
let raw = std::fs::read_to_string(path)?;
Ok(raw.trim().to_string())
}
fn write_atomic(path: &Path, contents: &str) -> crate::Result<()> {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn gc_snapshots(snapshots_dir: &Path, keep: &[&str]) -> crate::Result<()> {
let Ok(entries) = std::fs::read_dir(snapshots_dir) else {
return Ok(());
};
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("tmp-") || !keep.iter().any(|k| *k == name_str.as_ref()) {
let _ = std::fs::remove_dir_all(entry.path());
}
}
Ok(())
}
pub fn copy_dir_contents(src: &Path, dst: &Path) -> crate::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ft = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if ft.is_dir() {
copy_dir_contents(&entry.path(), &dest_path)?;
} else {
std::fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn begin_commit_creates_current() {
let tmp = TempDir::new().expect("create temp dir");
let mut store = SnapshotStore::open(tmp.path()).expect("open store");
assert!(store.current_id().is_none());
let snapshot = store.begin().expect("begin snapshot");
let id = snapshot.id().to_string();
std::fs::write(snapshot.dir().join("data.txt"), "hello").expect("write data");
store.commit(snapshot).expect("commit snapshot");
assert_eq!(store.current_id(), Some(id.as_str()));
assert!(store.current_dir().expect("has dir").exists());
assert!(store.current_dir().unwrap().join("data.txt").exists());
}
#[test]
fn drop_without_commit_cleans_tmp() {
let tmp = TempDir::new().expect("create temp dir");
let store = SnapshotStore::open(tmp.path()).expect("open store");
let snapshot = store.begin().expect("begin snapshot");
let tmp_dir = snapshot.dir().to_path_buf();
assert!(tmp_dir.exists());
drop(snapshot);
assert!(!tmp_dir.exists());
}
#[test]
fn gc_removes_stale_temp_dirs() {
let tmp = TempDir::new().expect("create temp dir");
let snapshots_dir = tmp.path().join(SNAPSHOTS_DIR);
std::fs::create_dir_all(&snapshots_dir).expect("create snapshots dir");
std::fs::create_dir_all(snapshots_dir.join("tmp-stale")).expect("create stale tmp");
std::fs::create_dir_all(snapshots_dir.join("0000000000000001")).expect("create snapshot");
gc_snapshots(&snapshots_dir, &["0000000000000001"]).expect("gc");
assert!(!snapshots_dir.join("tmp-stale").exists());
assert!(snapshots_dir.join("0000000000000001").exists());
}
#[test]
fn commit_gc_keeps_current_and_previous() {
let tmp = TempDir::new().expect("create temp dir");
let mut store = SnapshotStore::open(tmp.path()).expect("open store");
let s1 = store.begin().expect("begin");
let id1 = s1.id().to_string();
store.commit(s1).expect("commit");
let s2 = store.begin().expect("begin");
let id2 = s2.id().to_string();
store.commit(s2).expect("commit");
let snapshots_dir = tmp.path().join(SNAPSHOTS_DIR);
assert!(snapshots_dir.join(&id2).exists(), "current should exist");
assert!(snapshots_dir.join(&id1).exists(), "previous should exist");
let s3 = store.begin().expect("begin");
let id3 = s3.id().to_string();
store.commit(s3).expect("commit");
assert!(snapshots_dir.join(&id3).exists(), "current should exist");
assert!(snapshots_dir.join(&id2).exists(), "previous should exist");
assert!(
!snapshots_dir.join(&id1).exists(),
"oldest should be cleaned"
);
}
}