use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::backup::BackupStore;
use crate::error::AftError;
#[derive(Debug, Clone)]
pub struct CheckpointInfo {
pub name: String,
pub file_count: usize,
pub created_at: u64,
pub skipped: Vec<(PathBuf, String)>,
}
#[derive(Debug, Clone)]
struct Checkpoint {
name: String,
file_contents: HashMap<PathBuf, String>,
created_at: u64,
}
#[derive(Debug)]
pub struct CheckpointStore {
checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
}
impl CheckpointStore {
pub fn new() -> Self {
CheckpointStore {
checkpoints: HashMap::new(),
}
}
pub fn create(
&mut self,
session: &str,
name: &str,
files: Vec<PathBuf>,
backup_store: &BackupStore,
) -> Result<CheckpointInfo, AftError> {
let explicit_request = !files.is_empty();
let file_list = if files.is_empty() {
backup_store.tracked_files(session)
} else {
files
};
let mut file_contents = HashMap::new();
let mut skipped: Vec<(PathBuf, String)> = Vec::new();
for path in &file_list {
match std::fs::read_to_string(path) {
Ok(content) => {
file_contents.insert(path.clone(), content);
}
Err(e) => {
crate::slog_warn!(
"checkpoint {}: skipping unreadable file {}: {}",
name,
path.display(),
e
);
skipped.push((path.clone(), e.to_string()));
}
}
}
if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
let (path, err) = &skipped[0];
return Err(AftError::FileNotFound {
path: format!("{}: {}", path.display(), err),
});
}
let created_at = current_timestamp();
let file_count = file_contents.len();
let checkpoint = Checkpoint {
name: name.to_string(),
file_contents,
created_at,
};
self.checkpoints
.entry(session.to_string())
.or_default()
.insert(name.to_string(), checkpoint);
if skipped.is_empty() {
crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
} else {
crate::slog_info!(
"checkpoint created: {} ({} files, {} skipped)",
name,
file_count,
skipped.len()
);
}
Ok(CheckpointInfo {
name: name.to_string(),
file_count,
created_at,
skipped,
})
}
pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
let checkpoint = self.get(session, name)?;
let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
paths.sort();
restore_paths_atomically(checkpoint, &paths)?;
crate::slog_info!("checkpoint restored: {}", name);
Ok(CheckpointInfo {
name: checkpoint.name.clone(),
file_count: checkpoint.file_contents.len(),
created_at: checkpoint.created_at,
skipped: Vec::new(),
})
}
pub fn restore_validated(
&self,
session: &str,
name: &str,
validated_paths: &[PathBuf],
) -> Result<CheckpointInfo, AftError> {
let checkpoint = self.get(session, name)?;
for path in validated_paths {
checkpoint
.file_contents
.get(path)
.ok_or_else(|| AftError::FileNotFound {
path: path.display().to_string(),
})?;
}
restore_paths_atomically(checkpoint, validated_paths)?;
crate::slog_info!("checkpoint restored: {}", name);
Ok(CheckpointInfo {
name: checkpoint.name.clone(),
file_count: checkpoint.file_contents.len(),
created_at: checkpoint.created_at,
skipped: Vec::new(),
})
}
pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
let checkpoint = self.get(session, name)?;
Ok(checkpoint.file_contents.keys().cloned().collect())
}
pub fn delete(&mut self, session: &str, name: &str) -> bool {
let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
return false;
};
let removed = session_checkpoints.remove(name).is_some();
if session_checkpoints.is_empty() {
self.checkpoints.remove(session);
}
removed
}
pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
self.checkpoints
.get(session)
.map(|s| {
s.values()
.map(|cp| CheckpointInfo {
name: cp.name.clone(),
file_count: cp.file_contents.len(),
created_at: cp.created_at,
skipped: Vec::new(),
})
.collect()
})
.unwrap_or_default()
}
pub fn total_count(&self) -> usize {
self.checkpoints.values().map(|s| s.len()).sum()
}
pub fn cleanup(&mut self, ttl_hours: u32) {
let now = current_timestamp();
let ttl_secs = ttl_hours as u64 * 3600;
self.checkpoints.retain(|_, session_cps| {
session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
!session_cps.is_empty()
});
}
fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
self.checkpoints
.get(session)
.and_then(|s| s.get(name))
.ok_or_else(|| AftError::CheckpointNotFound {
name: name.to_string(),
})
}
}
fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
let mut pre_restore_snapshot: HashMap<PathBuf, Option<String>> = HashMap::new();
for path in paths {
let current = if path.exists() {
Some(
std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
path: path.display().to_string(),
})?,
)
} else {
None
};
pre_restore_snapshot.insert(path.clone(), current);
}
let mut restored_paths: Vec<PathBuf> = Vec::new();
for path in paths {
let content = checkpoint
.file_contents
.get(path)
.ok_or_else(|| AftError::FileNotFound {
path: path.display().to_string(),
})?;
if let Err(e) = write_restored_file(path, content) {
for restored_path in restored_paths.iter().rev() {
if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
let _ = restore_snapshot_file(restored_path, snapshot.as_deref());
}
}
return Err(e);
}
restored_paths.push(path.clone());
}
Ok(())
}
fn restore_snapshot_file(path: &Path, content: Option<&str>) -> Result<(), AftError> {
match content {
Some(content) => write_restored_file(path, content),
None => match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(AftError::FileNotFound {
path: path.display().to_string(),
}),
},
}
}
fn write_restored_file(path: &Path, content: &str) -> Result<(), AftError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|_| AftError::FileNotFound {
path: path.display().to_string(),
})?;
}
std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
path: path.display().to_string(),
})
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[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_checkpoint_tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
fs::write(&path, content).unwrap();
path
}
#[test]
fn create_and_restore_round_trip() {
let path1 = temp_file("cp_rt1.txt", "hello");
let path2 = temp_file("cp_rt2.txt", "world");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
let info = store
.create(
DEFAULT_SESSION_ID,
"snap1",
vec![path1.clone(), path2.clone()],
&backup_store,
)
.unwrap();
assert_eq!(info.name, "snap1");
assert_eq!(info.file_count, 2);
fs::write(&path1, "changed1").unwrap();
fs::write(&path2, "changed2").unwrap();
let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
assert_eq!(info.file_count, 2);
assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
}
#[test]
fn overwrite_existing_name() {
let path = temp_file("cp_overwrite.txt", "v1");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
.unwrap();
fs::write(&path, "v2").unwrap();
store
.create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
.unwrap();
fs::write(&path, "v3").unwrap();
store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
#[test]
fn list_returns_metadata_scoped_to_session() {
let path = temp_file("cp_list.txt", "data");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
.unwrap();
store
.create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
.unwrap();
store
.create("other_session", "c", vec![path.clone()], &backup_store)
.unwrap();
let default_list = store.list(DEFAULT_SESSION_ID);
assert_eq!(default_list.len(), 2);
let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
let other_list = store.list("other_session");
assert_eq!(other_list.len(), 1);
assert_eq!(other_list[0].name, "c");
}
#[test]
fn sessions_isolate_checkpoint_names() {
let path_a = temp_file("cp_isolated_a.txt", "a-original");
let path_b = temp_file("cp_isolated_b.txt", "b-original");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create("session_a", "snap", vec![path_a.clone()], &backup_store)
.unwrap();
store
.create("session_b", "snap", vec![path_b.clone()], &backup_store)
.unwrap();
fs::write(&path_a, "a-modified").unwrap();
fs::write(&path_b, "b-modified").unwrap();
store.restore("session_a", "snap").unwrap();
assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
fs::write(&path_a, "a-modified").unwrap();
store.restore("session_b", "snap").unwrap();
assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
}
#[test]
fn cleanup_removes_expired_across_sessions() {
let path = temp_file("cp_cleanup.txt", "data");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create(
DEFAULT_SESSION_ID,
"recent",
vec![path.clone()],
&backup_store,
)
.unwrap();
store
.checkpoints
.entry("other".to_string())
.or_default()
.insert(
"old".to_string(),
Checkpoint {
name: "old".to_string(),
file_contents: HashMap::new(),
created_at: 1000, },
);
assert_eq!(store.total_count(), 2);
store.cleanup(24); assert_eq!(store.total_count(), 1);
assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
assert!(store.list("other").is_empty());
}
#[test]
fn restore_nonexistent_returns_error() {
let store = CheckpointStore::new();
let result = store.restore(DEFAULT_SESSION_ID, "nope");
assert!(result.is_err());
match result.unwrap_err() {
AftError::CheckpointNotFound { name } => {
assert_eq!(name, "nope");
}
other => panic!("expected CheckpointNotFound, got: {:?}", other),
}
}
#[test]
fn restore_nonexistent_in_other_session_returns_error() {
let path = temp_file("cp_cross_session.txt", "data");
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create("session_a", "only_a", vec![path], &backup_store)
.unwrap();
assert!(store.restore("session_b", "only_a").is_err());
}
#[test]
fn create_skips_missing_files_from_backup_tracked_set() {
let readable = temp_file("cp_skip_readable.txt", "still_here");
let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
let deleted_canonical = fs::canonicalize(&deleted).unwrap();
let mut backup_store = BackupStore::new();
backup_store
.snapshot(DEFAULT_SESSION_ID, &readable, "auto")
.unwrap();
backup_store
.snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
.unwrap();
fs::remove_file(&deleted).unwrap();
let mut store = CheckpointStore::new();
let info = store
.create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
.expect("checkpoint should succeed despite one missing file");
assert_eq!(info.file_count, 1);
assert_eq!(info.skipped.len(), 1);
assert_eq!(info.skipped[0].0, deleted_canonical);
assert!(!info.skipped[0].1.is_empty());
}
#[test]
fn create_with_explicit_single_missing_file_errors() {
let missing = std::env::temp_dir()
.join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
let _ = fs::remove_file(&missing);
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
let result = store.create(
DEFAULT_SESSION_ID,
"explicit",
vec![missing.clone()],
&backup_store,
);
assert!(result.is_err());
match result.unwrap_err() {
AftError::FileNotFound { path } => {
assert!(path.contains(&missing.display().to_string()));
}
other => panic!("expected FileNotFound, got: {:?}", other),
}
}
#[test]
fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
let good = temp_file("cp_mixed_good.txt", "ok");
let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
let _ = fs::remove_file(&missing);
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
let info = store
.create(
DEFAULT_SESSION_ID,
"mixed",
vec![good.clone(), missing.clone()],
&backup_store,
)
.expect("mixed checkpoint should succeed when any file is readable");
assert_eq!(info.file_count, 1);
assert_eq!(info.skipped.len(), 1);
assert_eq!(info.skipped[0].0, missing);
}
#[test]
fn create_with_empty_files_uses_backup_tracked() {
let path = temp_file("cp_tracked.txt", "tracked_content");
let mut backup_store = BackupStore::new();
backup_store
.snapshot(DEFAULT_SESSION_ID, &path, "auto")
.unwrap();
let mut store = CheckpointStore::new();
let info = store
.create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
.unwrap();
assert!(info.file_count >= 1);
fs::write(&path, "modified").unwrap();
store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
}
#[test]
fn restore_recreates_missing_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("deeper").join("file.txt");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "original nested content").unwrap();
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create(
DEFAULT_SESSION_ID,
"nested",
vec![path.clone()],
&backup_store,
)
.unwrap();
fs::remove_dir_all(dir.path().join("nested")).unwrap();
store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"original nested content"
);
}
#[cfg(unix)]
#[test]
fn checkpoint_restore_rolls_back_on_partial_failure() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.txt");
let path_b = dir.path().join("b.txt");
fs::write(&path_a, "checkpoint-a").unwrap();
fs::write(&path_b, "checkpoint-b").unwrap();
let backup_store = BackupStore::new();
let mut store = CheckpointStore::new();
store
.create(
DEFAULT_SESSION_ID,
"partial_failure",
vec![path_a.clone(), path_b.clone()],
&backup_store,
)
.unwrap();
fs::write(&path_a, "pre-restore-a").unwrap();
fs::write(&path_b, "pre-restore-b").unwrap();
let mut readonly = fs::metadata(&path_b).unwrap().permissions();
readonly.set_mode(0o444);
fs::set_permissions(&path_b, readonly).unwrap();
let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
let mut writable = fs::metadata(&path_b).unwrap().permissions();
writable.set_mode(0o644);
fs::set_permissions(&path_b, writable).unwrap();
assert!(result.is_err(), "restore should surface write failure");
assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
}
}