use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::error::AftError;
use sha2::{Digest, Sha256};
const MAX_UNDO_DEPTH: usize = 20;
const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone)]
pub struct BackupEntry {
pub backup_id: String,
pub content: String,
pub timestamp: u64,
pub description: String,
}
#[derive(Debug)]
pub struct BackupStore {
entries: HashMap<String, HashMap<PathBuf, Vec<BackupEntry>>>,
disk_index: HashMap<String, HashMap<PathBuf, DiskMeta>>,
session_meta: HashMap<String, SessionMeta>,
counter: AtomicU64,
storage_dir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct DiskMeta {
dir: PathBuf,
count: usize,
}
#[derive(Debug, Clone, Default)]
struct SessionMeta {
last_accessed: u64,
}
impl BackupStore {
pub fn new() -> Self {
BackupStore {
entries: HashMap::new(),
disk_index: HashMap::new(),
session_meta: HashMap::new(),
counter: AtomicU64::new(0),
storage_dir: None,
}
}
pub fn set_storage_dir(&mut self, dir: PathBuf, ttl_hours: u32) {
self.storage_dir = Some(dir);
self.gc_stale_sessions(ttl_hours);
self.migrate_legacy_layout_if_needed();
self.load_disk_index();
}
pub fn snapshot(
&mut self,
session: &str,
path: &Path,
description: &str,
) -> Result<String, AftError> {
let content = std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
path: path.display().to_string(),
})?;
let key = canonicalize_key(path);
let id = self.next_id();
let entry = BackupEntry {
backup_id: id.clone(),
content,
timestamp: current_timestamp(),
description: description.to_string(),
};
let session_entries = self.entries.entry(session.to_string()).or_default();
let stack = session_entries.entry(key.clone()).or_default();
if stack.len() >= MAX_UNDO_DEPTH {
stack.remove(0);
}
stack.push(entry);
let stack_clone = stack.clone();
self.write_snapshot_to_disk(session, &key, &stack_clone);
self.touch_session(session);
Ok(id)
}
pub fn restore_latest(
&mut self,
session: &str,
path: &Path,
) -> Result<(BackupEntry, Option<String>), AftError> {
let key = canonicalize_key(path);
let in_memory = self
.entries
.get(session)
.and_then(|s| s.get(&key))
.map_or(false, |s| !s.is_empty());
if in_memory {
let result = self.do_restore(session, &key, path);
if result.is_ok() {
self.touch_session(session);
}
return result;
}
if self.load_from_disk_if_needed(session, &key) {
let warning = self.check_external_modification(session, &key, path);
let (entry, _) = self.do_restore(session, &key, path)?;
self.touch_session(session);
return Ok((entry, warning));
}
Err(AftError::NoUndoHistory {
path: path.display().to_string(),
})
}
pub fn history(&self, session: &str, path: &Path) -> Vec<BackupEntry> {
let key = canonicalize_key(path);
self.entries
.get(session)
.and_then(|s| s.get(&key))
.cloned()
.unwrap_or_default()
}
pub fn disk_history_count(&self, session: &str, path: &Path) -> usize {
let key = canonicalize_key(path);
self.disk_index
.get(session)
.and_then(|s| s.get(&key))
.map(|m| m.count)
.unwrap_or(0)
}
pub fn tracked_files(&self, session: &str) -> Vec<PathBuf> {
let mut files: std::collections::HashSet<PathBuf> = self
.entries
.get(session)
.map(|s| s.keys().cloned().collect())
.unwrap_or_default();
if let Some(disk) = self.disk_index.get(session) {
for key in disk.keys() {
files.insert(key.clone());
}
}
files.into_iter().collect()
}
pub fn sessions_with_backups(&self) -> Vec<String> {
let mut sessions: std::collections::HashSet<String> =
self.entries.keys().cloned().collect();
for s in self.disk_index.keys() {
sessions.insert(s.clone());
}
sessions.into_iter().collect()
}
pub fn total_disk_bytes(&self) -> u64 {
let mut total = 0u64;
for session_dirs in self.disk_index.values() {
for meta in session_dirs.values() {
if let Ok(read_dir) = std::fs::read_dir(&meta.dir) {
for entry in read_dir.flatten() {
if let Ok(m) = entry.metadata() {
if m.is_file() {
total += m.len();
}
}
}
}
}
}
total
}
fn next_id(&self) -> String {
let n = self.counter.fetch_add(1, Ordering::Relaxed);
format!("backup-{}", n)
}
fn touch_session(&mut self, session: &str) {
let now = current_timestamp();
self.session_meta
.entry(session.to_string())
.or_default()
.last_accessed = now;
self.write_session_marker(session, now);
}
fn do_restore(
&mut self,
session: &str,
key: &Path,
path: &Path,
) -> Result<(BackupEntry, Option<String>), AftError> {
let session_entries =
self.entries
.get_mut(session)
.ok_or_else(|| AftError::NoUndoHistory {
path: path.display().to_string(),
})?;
let stack = session_entries
.get_mut(key)
.ok_or_else(|| AftError::NoUndoHistory {
path: path.display().to_string(),
})?;
let entry = stack
.last()
.cloned()
.ok_or_else(|| AftError::NoUndoHistory {
path: path.display().to_string(),
})?;
std::fs::write(path, &entry.content).map_err(|e| AftError::IoError {
path: path.display().to_string(),
message: e.to_string(),
})?;
stack.pop();
if stack.is_empty() {
session_entries.remove(key);
if session_entries.is_empty() {
self.entries.remove(session);
}
self.remove_disk_backups(session, key);
} else {
let stack_clone = self
.entries
.get(session)
.and_then(|s| s.get(key))
.cloned()
.unwrap_or_default();
self.write_snapshot_to_disk(session, key, &stack_clone);
}
Ok((entry, None))
}
fn check_external_modification(
&self,
session: &str,
key: &Path,
path: &Path,
) -> Option<String> {
if let (Some(stack), Ok(current)) = (
self.entries.get(session).and_then(|s| s.get(key)),
std::fs::read_to_string(path),
) {
if let Some(latest) = stack.last() {
if latest.content != current {
return Some("file was modified externally since last backup".to_string());
}
}
}
None
}
fn backups_dir(&self) -> Option<PathBuf> {
self.storage_dir.as_ref().map(|d| d.join("backups"))
}
fn session_dir(&self, session: &str) -> Option<PathBuf> {
self.backups_dir()
.map(|d| d.join(Self::session_hash(session)))
}
fn session_hash(session: &str) -> String {
hash_session(session)
}
fn path_hash(key: &Path) -> String {
stable_hash_16(key.to_string_lossy().as_bytes())
}
fn write_session_marker(&self, session: &str, last_accessed: u64) {
let Some(session_dir) = self.session_dir(session) else {
return;
};
if let Err(e) = std::fs::create_dir_all(&session_dir) {
log::warn!("[aft] failed to create session dir: {}", e);
return;
}
let marker = session_dir.join("session.json");
let json = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"session_id": session,
"last_accessed": last_accessed,
});
if let Ok(s) = serde_json::to_string_pretty(&json) {
let tmp = session_dir.join("session.json.tmp");
if std::fs::write(&tmp, s).is_ok() {
let _ = std::fs::rename(&tmp, marker);
}
}
}
fn gc_stale_sessions(&mut self, ttl_hours: u32) {
let backups_dir = match self.backups_dir() {
Some(d) if d.exists() => d,
_ => return,
};
let ttl_secs = u64::from(if ttl_hours == 0 { 72 } else { ttl_hours }) * 60 * 60;
let cutoff = current_timestamp().saturating_sub(ttl_secs);
let entries = match std::fs::read_dir(&backups_dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let session_dir = entry.path();
if !session_dir.is_dir() || session_dir.join("meta.json").exists() {
continue;
}
let Some(last_accessed) = Self::read_session_last_accessed(&session_dir) else {
continue;
};
if last_accessed >= cutoff {
continue;
}
if let Err(e) = std::fs::remove_dir_all(&session_dir) {
log::warn!(
"[aft] failed to remove stale backup session {}: {}",
session_dir.display(),
e
);
} else {
log::warn!(
"[aft] removed stale backup session {} (last_accessed={})",
session_dir.display(),
last_accessed
);
}
}
}
fn migrate_legacy_layout_if_needed(&mut self) {
let backups_dir = match self.backups_dir() {
Some(d) if d.exists() => d,
_ => return,
};
let default_session_dir =
backups_dir.join(Self::session_hash(crate::protocol::DEFAULT_SESSION_ID));
let entries = match std::fs::read_dir(&backups_dir) {
Ok(e) => e,
Err(_) => return,
};
let mut migrated = 0usize;
for entry in entries.flatten() {
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
if entry_path == default_session_dir {
continue;
}
let meta_path = entry_path.join("meta.json");
if !meta_path.exists() {
continue; }
if let Err(e) = std::fs::create_dir_all(&default_session_dir) {
log::warn!("[aft] failed to create default session dir: {}", e);
return;
}
let leaf = match entry_path.file_name() {
Some(n) => n,
None => continue,
};
let target = default_session_dir.join(leaf);
if target.exists() {
continue;
}
match std::fs::rename(&entry_path, &target) {
Ok(()) => {
Self::upgrade_meta_file(
&target.join("meta.json"),
crate::protocol::DEFAULT_SESSION_ID,
);
migrated += 1;
}
Err(e) => {
log::warn!(
"[aft] failed to migrate legacy backup {}: {}",
entry_path.display(),
e
);
}
}
}
if migrated > 0 {
log::info!(
"[aft] migrated {} legacy backup entries into default session namespace",
migrated
);
let marker = default_session_dir.join("session.json");
let json = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"session_id": crate::protocol::DEFAULT_SESSION_ID,
"last_accessed": current_timestamp(),
});
if let Ok(s) = serde_json::to_string_pretty(&json) {
let _ = std::fs::write(&marker, s);
}
}
}
fn upgrade_meta_file(meta_path: &Path, session_id: &str) {
let content = match std::fs::read_to_string(meta_path) {
Ok(c) => c,
Err(_) => return,
};
let mut parsed: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return,
};
if let Some(obj) = parsed.as_object_mut() {
obj.entry("schema_version")
.or_insert(serde_json::json!(SCHEMA_VERSION));
obj.insert("session_id".to_string(), serde_json::json!(session_id));
}
if let Ok(s) = serde_json::to_string_pretty(&parsed) {
let tmp = meta_path.with_extension("json.tmp");
if std::fs::write(&tmp, &s).is_ok() {
let _ = std::fs::rename(&tmp, meta_path);
}
}
}
fn load_disk_index(&mut self) {
let backups_dir = match self.backups_dir() {
Some(d) if d.exists() => d,
_ => return,
};
let session_dirs = match std::fs::read_dir(&backups_dir) {
Ok(e) => e,
Err(_) => return,
};
let mut total_entries = 0usize;
for session_entry in session_dirs.flatten() {
let session_dir = session_entry.path();
if !session_dir.is_dir() {
continue;
}
let session_id = Self::read_session_marker(&session_dir)
.unwrap_or_else(|| crate::protocol::DEFAULT_SESSION_ID.to_string());
let path_dirs = match std::fs::read_dir(&session_dir) {
Ok(e) => e,
Err(_) => continue,
};
let per_session = self.disk_index.entry(session_id.clone()).or_default();
for path_entry in path_dirs.flatten() {
let path_dir = path_entry.path();
if !path_dir.is_dir() {
continue;
}
let meta_path = path_dir.join("meta.json");
if let Ok(content) = std::fs::read_to_string(&meta_path) {
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
if let (Some(path_str), Some(count)) = (
meta.get("path").and_then(|v| v.as_str()),
meta.get("count").and_then(|v| v.as_u64()),
) {
per_session.insert(
PathBuf::from(path_str),
DiskMeta {
dir: path_dir.clone(),
count: count as usize,
},
);
total_entries += 1;
}
}
}
}
}
if total_entries > 0 {
log::info!(
"[aft] loaded {} backup entries across {} session(s) from disk",
total_entries,
self.disk_index.len()
);
}
}
fn read_session_marker(session_dir: &Path) -> Option<String> {
let marker = session_dir.join("session.json");
let content = std::fs::read_to_string(&marker).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
parsed
.get("session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn read_session_last_accessed(session_dir: &Path) -> Option<u64> {
let marker = session_dir.join("session.json");
let content = std::fs::read_to_string(&marker).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
parsed.get("last_accessed").and_then(|v| v.as_u64())
}
fn load_from_disk_if_needed(&mut self, session: &str, key: &Path) -> bool {
let meta = match self
.disk_index
.get(session)
.and_then(|s| s.get(key))
.cloned()
{
Some(m) if m.count > 0 => m,
_ => return false,
};
let mut entries = Vec::new();
for i in 0..meta.count {
let bak_path = meta.dir.join(format!("{}.bak", i));
if let Ok(content) = std::fs::read_to_string(&bak_path) {
entries.push(BackupEntry {
backup_id: format!("disk-{}", i),
content,
timestamp: 0,
description: "restored from disk".to_string(),
});
}
}
if entries.is_empty() {
return false;
}
self.entries
.entry(session.to_string())
.or_default()
.insert(key.to_path_buf(), entries);
true
}
fn write_snapshot_to_disk(&mut self, session: &str, key: &Path, stack: &[BackupEntry]) {
let session_dir = match self.session_dir(session) {
Some(d) => d,
None => return,
};
if let Err(e) = std::fs::create_dir_all(&session_dir) {
log::warn!("[aft] failed to create session dir: {}", e);
return;
}
let marker = session_dir.join("session.json");
if !marker.exists() {
let json = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"session_id": session,
"last_accessed": current_timestamp(),
});
if let Ok(s) = serde_json::to_string_pretty(&json) {
let _ = std::fs::write(&marker, s);
}
}
let hash = Self::path_hash(key);
let dir = session_dir.join(&hash);
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("[aft] failed to create backup dir: {}", e);
return;
}
for (i, entry) in stack.iter().enumerate() {
let bak_path = dir.join(format!("{}.bak", i));
let tmp_path = dir.join(format!("{}.bak.tmp", i));
if std::fs::write(&tmp_path, &entry.content).is_ok() {
let _ = std::fs::rename(&tmp_path, &bak_path);
}
}
for i in stack.len()..MAX_UNDO_DEPTH {
let old = dir.join(format!("{}.bak", i));
if old.exists() {
let _ = std::fs::remove_file(&old);
}
}
let meta = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"session_id": session,
"path": key.display().to_string(),
"count": stack.len(),
});
let meta_path = dir.join("meta.json");
let meta_tmp = dir.join("meta.json.tmp");
if let Ok(content) = serde_json::to_string_pretty(&meta) {
if std::fs::write(&meta_tmp, &content).is_ok() {
let _ = std::fs::rename(&meta_tmp, &meta_path);
}
}
self.disk_index
.entry(session.to_string())
.or_default()
.insert(
key.to_path_buf(),
DiskMeta {
dir,
count: stack.len(),
},
);
}
fn remove_disk_backups(&mut self, session: &str, key: &Path) {
let removed = self.disk_index.get_mut(session).and_then(|s| s.remove(key));
if let Some(meta) = removed {
let _ = std::fs::remove_dir_all(&meta.dir);
} else if let Some(session_dir) = self.session_dir(session) {
let hash = Self::path_hash(key);
let dir = session_dir.join(&hash);
if dir.exists() {
let _ = std::fs::remove_dir_all(&dir);
}
}
let empty = self
.disk_index
.get(session)
.map(|s| s.is_empty())
.unwrap_or(false);
if empty {
self.disk_index.remove(session);
}
}
}
pub fn hash_session(session: &str) -> String {
stable_hash_16(session.as_bytes())
}
fn canonicalize_key(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|err| {
log::debug!(
"backup canonicalize_key fallback for {}: {}",
path.display(),
err
);
path.to_path_buf()
})
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn stable_hash_16(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest[..8]
.iter()
.map(|byte| format!("{:02x}", byte))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::DEFAULT_SESSION_ID;
use std::fs;
fn temp_file(name: &str, content: &str) -> PathBuf {
let dir = std::env::temp_dir().join("aft_backup_tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
fs::write(&path, content).unwrap();
path
}
#[test]
fn snapshot_and_restore_round_trip() {
let path = temp_file("round_trip.txt", "original");
let mut store = BackupStore::new();
let id = store
.snapshot(DEFAULT_SESSION_ID, &path, "before edit")
.unwrap();
assert!(id.starts_with("backup-"));
fs::write(&path, "modified").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "modified");
let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
assert_eq!(entry.content, "original");
assert_eq!(fs::read_to_string(&path).unwrap(), "original");
}
#[test]
fn multiple_snapshots_preserve_order() {
let path = temp_file("order.txt", "v1");
let mut store = BackupStore::new();
store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
fs::write(&path, "v2").unwrap();
store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
fs::write(&path, "v3").unwrap();
store.snapshot(DEFAULT_SESSION_ID, &path, "third").unwrap();
let history = store.history(DEFAULT_SESSION_ID, &path);
assert_eq!(history.len(), 3);
assert_eq!(history[0].content, "v1");
assert_eq!(history[1].content, "v2");
assert_eq!(history[2].content, "v3");
}
#[test]
fn restore_pops_from_stack() {
let path = temp_file("pop.txt", "v1");
let mut store = BackupStore::new();
store.snapshot(DEFAULT_SESSION_ID, &path, "first").unwrap();
fs::write(&path, "v2").unwrap();
store.snapshot(DEFAULT_SESSION_ID, &path, "second").unwrap();
let (entry, _) = store.restore_latest(DEFAULT_SESSION_ID, &path).unwrap();
assert_eq!(entry.description, "second");
assert_eq!(entry.content, "v2");
let history = store.history(DEFAULT_SESSION_ID, &path);
assert_eq!(history.len(), 1);
}
#[test]
fn empty_history_returns_empty_vec() {
let store = BackupStore::new();
let path = Path::new("/tmp/aft_backup_tests/nonexistent_history.txt");
assert!(store.history(DEFAULT_SESSION_ID, path).is_empty());
}
#[test]
fn snapshot_nonexistent_file_returns_error() {
let mut store = BackupStore::new();
let path = Path::new("/tmp/aft_backup_tests/absolutely_does_not_exist.txt");
assert!(store.snapshot(DEFAULT_SESSION_ID, path, "test").is_err());
}
#[test]
fn tracked_files_lists_snapshotted_paths() {
let path1 = temp_file("tracked1.txt", "a");
let path2 = temp_file("tracked2.txt", "b");
let mut store = BackupStore::new();
store.snapshot(DEFAULT_SESSION_ID, &path1, "snap1").unwrap();
store.snapshot(DEFAULT_SESSION_ID, &path2, "snap2").unwrap();
assert_eq!(store.tracked_files(DEFAULT_SESSION_ID).len(), 2);
}
#[test]
fn sessions_are_isolated() {
let path = temp_file("isolated.txt", "original");
let mut store = BackupStore::new();
store.snapshot("session_a", &path, "a's snapshot").unwrap();
assert!(store.history("session_b", &path).is_empty());
assert_eq!(store.tracked_files("session_b").len(), 0);
let err = store.restore_latest("session_b", &path);
assert!(matches!(err, Err(AftError::NoUndoHistory { .. })));
assert_eq!(store.history("session_a", &path).len(), 1);
assert_eq!(store.tracked_files("session_a").len(), 1);
}
#[test]
fn per_session_per_file_cap_is_independent() {
let path = temp_file("cap_indep.txt", "v0");
let mut store = BackupStore::new();
for i in 0..(MAX_UNDO_DEPTH + 5) {
fs::write(&path, format!("a{}", i)).unwrap();
store.snapshot("session_a", &path, "a").unwrap();
}
fs::write(&path, "b_initial").unwrap();
store.snapshot("session_b", &path, "b").unwrap();
assert_eq!(store.history("session_a", &path).len(), MAX_UNDO_DEPTH);
assert_eq!(store.history("session_b", &path).len(), 1);
}
#[test]
fn sessions_with_backups_lists_all_namespaces() {
let path_a = temp_file("sessions_list_a.txt", "a");
let path_b = temp_file("sessions_list_b.txt", "b");
let mut store = BackupStore::new();
store.snapshot("alice", &path_a, "from alice").unwrap();
store.snapshot("bob", &path_b, "from bob").unwrap();
let sessions = store.sessions_with_backups();
assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|s| s == "alice"));
assert!(sessions.iter().any(|s| s == "bob"));
}
#[test]
fn disk_persistence_survives_reload() {
let dir = std::env::temp_dir().join("aft_backup_disk_test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file_path = temp_file("disk_persist.txt", "original");
{
let mut store = BackupStore::new();
store.set_storage_dir(dir.clone(), 72);
store
.snapshot(DEFAULT_SESSION_ID, &file_path, "before edit")
.unwrap();
}
fs::write(&file_path, "externally modified").unwrap();
let mut store2 = BackupStore::new();
store2.set_storage_dir(dir.clone(), 72);
let (entry, warning) = store2
.restore_latest(DEFAULT_SESSION_ID, &file_path)
.unwrap();
assert_eq!(entry.content, "original");
assert!(warning.is_some()); assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn legacy_flat_layout_migrates_to_default_session() {
let dir = std::env::temp_dir().join("aft_backup_migration_test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let backups = dir.join("backups");
fs::create_dir_all(&backups).unwrap();
let legacy_hash = "deadbeefcafebabe";
let legacy_dir = backups.join(legacy_hash);
fs::create_dir_all(&legacy_dir).unwrap();
fs::write(legacy_dir.join("0.bak"), "original content").unwrap();
let legacy_meta = serde_json::json!({
"path": "/tmp/migrated_file.txt",
"count": 1,
});
fs::write(
legacy_dir.join("meta.json"),
serde_json::to_string_pretty(&legacy_meta).unwrap(),
)
.unwrap();
let mut store = BackupStore::new();
store.set_storage_dir(dir.clone(), 72);
let default_session_dir = backups.join(BackupStore::session_hash(DEFAULT_SESSION_ID));
assert!(default_session_dir.exists());
assert!(default_session_dir.join(legacy_hash).exists());
assert!(!backups.join(legacy_hash).exists());
let meta_content =
fs::read_to_string(default_session_dir.join(legacy_hash).join("meta.json")).unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
assert_eq!(meta["session_id"], DEFAULT_SESSION_ID);
assert_eq!(meta["schema_version"], SCHEMA_VERSION);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn set_storage_dir_removes_stale_backup_sessions() {
let dir = std::env::temp_dir().join("aft_backup_gc_test");
let _ = fs::remove_dir_all(&dir);
let backups = dir.join("backups");
fs::create_dir_all(&backups).unwrap();
let stale_session_dir = backups.join("stale-session");
fs::create_dir_all(&stale_session_dir).unwrap();
let stale_marker = serde_json::json!({
"schema_version": SCHEMA_VERSION,
"session_id": "stale",
"last_accessed": 1,
});
fs::write(
stale_session_dir.join("session.json"),
serde_json::to_string_pretty(&stale_marker).unwrap(),
)
.unwrap();
let mut store = BackupStore::new();
store.set_storage_dir(dir.clone(), 1);
assert!(!stale_session_dir.exists());
let _ = fs::remove_dir_all(&dir);
}
}