use std::path::{Path, PathBuf};
use crate::error::{ClawError, ClawResult};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMeta {
pub path: PathBuf,
pub created_at: chrono::DateTime<chrono::Utc>,
pub size_bytes: u64,
pub checksum: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SnapshotManifest {
pub entries: Vec<SnapshotMeta>,
}
impl SnapshotManifest {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug)]
pub struct Snapshotter {
snapshot_dir: PathBuf,
}
impl Snapshotter {
pub fn new(snapshot_dir: impl Into<PathBuf>) -> ClawResult<Self> {
let dir = snapshot_dir.into();
std::fs::create_dir_all(&dir).map_err(|e| {
ClawError::Snapshot(format!(
"cannot create snapshot directory '{}': {e}",
dir.display()
))
})?;
Ok(Snapshotter { snapshot_dir: dir })
}
pub fn take(&self, db_path: &Path) -> ClawResult<SnapshotMeta> {
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let file_name = format!("snapshot-{timestamp}.db");
let dest = self.snapshot_dir.join(&file_name);
std::fs::copy(db_path, &dest).map_err(|e| {
ClawError::Snapshot(format!(
"failed to copy '{}' → '{}': {e}",
db_path.display(),
dest.display()
))
})?;
let size_bytes = std::fs::metadata(&dest)
.map_err(|e| {
ClawError::Snapshot(format!(
"failed to read metadata for '{}': {e}",
dest.display()
))
})?
.len();
tracing::info!(path = %dest.display(), "snapshot taken");
let checksum = blake3_file_hex(&dest)?;
let meta = SnapshotMeta {
path: dest,
created_at: chrono::Utc::now(),
size_bytes,
checksum,
};
let mut manifest = self.load_manifest().unwrap_or_default();
manifest.entries.push(meta.clone());
self.save_manifest(&manifest)?;
tracing::info!(path = %meta.path.display(), size_bytes = meta.size_bytes, "manifest updated");
Ok(meta)
}
pub fn restore(&self, snapshot_path: &Path, db_path: &Path) -> ClawResult<()> {
validate_sqlite_magic(snapshot_path)?;
std::fs::copy(snapshot_path, db_path).map_err(|e| {
ClawError::Snapshot(format!(
"failed to restore '{}' → '{}': {e}",
snapshot_path.display(),
db_path.display()
))
})?;
for suffix in &["-wal", "-shm"] {
let sidecar = PathBuf::from(format!("{}{suffix}", db_path.display()));
if sidecar.exists() {
let _ = std::fs::remove_file(&sidecar);
}
}
tracing::info!(
from = %snapshot_path.display(),
to = %db_path.display(),
"snapshot restored"
);
Ok(())
}
pub fn list(&self) -> ClawResult<Vec<PathBuf>> {
let mut entries = std::fs::read_dir(&self.snapshot_dir)
.map_err(|e| {
ClawError::Snapshot(format!(
"cannot read snapshot directory '{}': {e}",
self.snapshot_dir.display()
))
})?
.filter_map(|r| r.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|e| e == "db").unwrap_or(false))
.collect::<Vec<_>>();
entries.sort();
Ok(entries)
}
pub fn load_manifest(&self) -> ClawResult<SnapshotManifest> {
let path = self.snapshot_dir.join("manifest.json");
if !path.exists() {
return Ok(SnapshotManifest::default());
}
let bytes = std::fs::read(&path).map_err(|e| {
ClawError::Snapshot(format!("cannot read manifest '{}': {e}", path.display()))
})?;
serde_json::from_slice(&bytes).map_err(|e| {
ClawError::Snapshot(format!("cannot parse manifest '{}': {e}", path.display()))
})
}
fn save_manifest(&self, manifest: &SnapshotManifest) -> ClawResult<()> {
let path = self.snapshot_dir.join("manifest.json");
let bytes = serde_json::to_vec_pretty(manifest)
.map_err(|e| ClawError::Snapshot(format!("cannot serialise manifest: {e}")))?;
std::fs::write(&path, bytes).map_err(|e| {
ClawError::Snapshot(format!("cannot write manifest '{}': {e}", path.display()))
})
}
}
fn blake3_file_hex(path: &Path) -> ClawResult<String> {
use std::io::Read;
let mut hasher = blake3::Hasher::new();
let mut file = std::fs::File::open(path).map_err(|e| {
ClawError::Snapshot(format!("cannot open '{}' for hashing: {e}", path.display()))
})?;
let mut buf = vec![0u8; 65536];
loop {
let n = file.read(&mut buf).map_err(|e| {
ClawError::Snapshot(format!(
"read error while hashing '{}': {e}",
path.display()
))
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().to_hex().to_string())
}
fn validate_sqlite_magic(path: &Path) -> ClawResult<()> {
use std::io::Read;
const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
let mut header = [0u8; 16];
let mut file = std::fs::File::open(path)
.map_err(|e| ClawError::Snapshot(format!("cannot open snapshot for validation: {e}")))?;
file.read_exact(&mut header)
.map_err(|e| ClawError::Snapshot(format!("cannot read snapshot header: {e}")))?;
if &header != SQLITE_MAGIC {
return Err(ClawError::Snapshot(
"snapshot file does not have a valid SQLite 3 header".to_string(),
));
}
Ok(())
}