use crate::error::{Result, SpliceError};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupEntry {
pub replaced_path: PathBuf,
pub hash: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupManifest {
pub operation_id: String,
pub timestamp: String,
pub files: Vec<BackupEntry>,
#[serde(skip)]
pub backup_dir: PathBuf,
}
impl BackupManifest {
pub fn new(operation_id: String, backup_dir: PathBuf) -> Self {
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
BackupManifest {
operation_id,
timestamp,
files: Vec::new(),
backup_dir,
}
}
pub fn add_file(&mut self, replaced_path: PathBuf, hash: String, size: u64) {
self.files.push(BackupEntry {
replaced_path,
hash,
size,
});
}
pub fn save(&self) -> Result<()> {
let manifest_path = self.backup_dir.join("manifest.json");
let json = serde_json::to_string_pretty(self)
.map_err(|e| SpliceError::Other(format!("Failed to serialize manifest: {}", e)))?;
fs::write(&manifest_path, json).map_err(|e| SpliceError::Io {
path: manifest_path,
source: e,
})?;
Ok(())
}
pub fn load(manifest_path: &Path) -> Result<Self> {
let json = fs::read_to_string(manifest_path).map_err(|e| SpliceError::Io {
path: manifest_path.to_path_buf(),
source: e,
})?;
let mut manifest: BackupManifest = serde_json::from_str(&json)
.map_err(|e| SpliceError::Other(format!("Failed to parse manifest: {}", e)))?;
manifest.backup_dir = manifest_path
.parent()
.ok_or_else(|| SpliceError::Other("Manifest has no parent directory".to_string()))?
.to_path_buf();
Ok(manifest)
}
}
pub struct BackupWriter {
manifest: BackupManifest,
workspace_root: PathBuf,
}
impl BackupWriter {
pub fn new(workspace_root: &Path, operation_id: Option<String>) -> Result<Self> {
let op_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let backup_dir = workspace_root.join(".splice-backup").join(&op_id);
fs::create_dir_all(&backup_dir).map_err(|e| SpliceError::Io {
path: backup_dir.clone(),
source: e,
})?;
let manifest = BackupManifest::new(op_id, backup_dir);
Ok(BackupWriter {
manifest,
workspace_root: workspace_root.to_path_buf(),
})
}
pub fn operation_id(&self) -> &str {
&self.manifest.operation_id
}
pub fn manifest_path(&self) -> PathBuf {
self.manifest.backup_dir.join("manifest.json")
}
pub fn backup_file(&mut self, file_path: &Path) -> Result<()> {
let content = fs::read(file_path).map_err(|e| SpliceError::Io {
path: file_path.to_path_buf(),
source: e,
})?;
let hash = compute_hash(&content);
let size = content.len() as u64;
let relative = file_path.strip_prefix(&self.workspace_root).map_err(|_| {
SpliceError::Other(format!(
"File '{}' is not under workspace root '{}'",
file_path.display(),
self.workspace_root.display()
))
})?;
let backup_path = self.manifest.backup_dir.join(relative);
if let Some(parent) = backup_path.parent() {
fs::create_dir_all(parent).map_err(|e| SpliceError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
fs::write(&backup_path, &content).map_err(|e| SpliceError::Io {
path: backup_path.clone(),
source: e,
})?;
self.manifest.add_file(relative.to_path_buf(), hash, size);
Ok(())
}
pub fn finalize(self) -> Result<PathBuf> {
self.manifest.save()?;
Ok(self.manifest_path())
}
pub fn backup_dir(&self) -> &Path {
&self.manifest.backup_dir
}
}
pub fn restore_from_manifest(manifest_path: &Path, workspace_root: &Path) -> Result<usize> {
let manifest = BackupManifest::load(manifest_path)?;
let mut restored = 0;
for entry in &manifest.files {
let replaced_path = workspace_root.join(&entry.replaced_path);
let backup_path = manifest.backup_dir.join(&entry.replaced_path);
if !backup_path.exists() {
return Err(SpliceError::Other(format!(
"Backup file missing: {}",
backup_path.display()
)));
}
let content = fs::read(&backup_path).map_err(|e| SpliceError::Io {
path: backup_path.clone(),
source: e,
})?;
let actual_hash = compute_hash(&content);
if actual_hash != entry.hash {
return Err(SpliceError::Other(format!(
"Hash mismatch for {}: expected {}, got {}",
entry.replaced_path.display(),
entry.hash,
actual_hash
)));
}
if let Some(parent) = replaced_path.parent() {
fs::create_dir_all(parent).map_err(|e| SpliceError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
fs::write(&replaced_path, &content).map_err(|e| SpliceError::Io {
path: replaced_path.clone(),
source: e,
})?;
restored += 1;
}
Ok(restored)
}
fn compute_hash(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let result = hasher.finalize();
format!("{:x}", result)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_backup_writer_creates_manifest() {
let workspace = TempDir::new().expect("Failed to create temp dir");
let workspace_root = workspace.path();
let test_file = workspace_root.join("test.txt");
fs::write(&test_file, b"hello world").expect("Failed to write test file");
let mut writer = BackupWriter::new(workspace_root, Some("test-op-123".to_string()))
.expect("Failed to create BackupWriter");
writer
.backup_file(&test_file)
.expect("Failed to backup file");
let manifest_path = writer.finalize().expect("Failed to finalize backup");
assert!(manifest_path.exists(), "Manifest file should exist");
let backup_file = workspace_root.join(".splice-backup/test-op-123/test.txt");
assert!(backup_file.exists(), "Backup file should exist");
let backup_content = fs::read_to_string(&backup_file).expect("Failed to read backup file");
assert_eq!(backup_content, "hello world");
}
#[test]
fn test_restore_from_manifest_restores_files() {
let workspace = TempDir::new().expect("Failed to create temp dir");
let workspace_root = workspace.path();
let test_file = workspace_root.join("test.txt");
fs::write(&test_file, b"replaced content").expect("Failed to write test file");
let mut writer = BackupWriter::new(workspace_root, Some("restore-test".to_string()))
.expect("Failed to create BackupWriter");
writer
.backup_file(&test_file)
.expect("Failed to backup file");
let manifest_path = writer.finalize().expect("Failed to finalize backup");
fs::write(&test_file, b"modified content").expect("Failed to modify file");
let restored =
restore_from_manifest(&manifest_path, workspace_root).expect("Failed to restore");
assert_eq!(restored, 1, "Should restore one file");
let content = fs::read_to_string(&test_file).expect("Failed to read file");
assert_eq!(content, "replaced content", "Content should be restored");
}
#[test]
fn test_restore_hash_mismatch_fails() {
let workspace = TempDir::new().expect("Failed to create temp dir");
let workspace_root = workspace.path();
let test_file = workspace_root.join("test.txt");
fs::write(&test_file, b"replaced").expect("Failed to write test file");
let mut writer = BackupWriter::new(workspace_root, Some("hash-test".to_string()))
.expect("Failed to create BackupWriter");
writer
.backup_file(&test_file)
.expect("Failed to backup file");
let manifest_path = writer.finalize().expect("Failed to finalize backup");
let backup_file = workspace_root.join(".splice-backup/hash-test/test.txt");
fs::write(&backup_file, b"tampered").expect("Failed to tamper with backup");
let result = restore_from_manifest(&manifest_path, workspace_root);
assert!(result.is_err(), "Restore should fail on hash mismatch");
match result {
Err(SpliceError::Other(msg)) if msg.contains("Hash mismatch") => {
}
other => {
panic!("Expected hash mismatch error, got: {:?}", other);
}
}
}
#[test]
fn test_backup_with_subdirectories() {
let workspace = TempDir::new().expect("Failed to create temp dir");
let workspace_root = workspace.path();
let src_dir = workspace_root.join("src");
fs::create_dir(&src_dir).expect("Failed to create src dir");
let test_file = src_dir.join("lib.rs");
fs::write(&test_file, b"fn main() {}").expect("Failed to write test file");
let mut writer = BackupWriter::new(workspace_root, Some("subdir-test".to_string()))
.expect("Failed to create BackupWriter");
writer
.backup_file(&test_file)
.expect("Failed to backup file");
let manifest_path = writer.finalize().expect("Failed to finalize backup");
let backup_file = workspace_root.join(".splice-backup/subdir-test/src/lib.rs");
assert!(
backup_file.exists(),
"Backup should preserve directory structure"
);
fs::write(&test_file, b"modified").expect("Failed to modify");
let restored =
restore_from_manifest(&manifest_path, workspace_root).expect("Failed to restore");
assert_eq!(restored, 1);
let content = fs::read_to_string(&test_file).expect("Failed to read");
assert_eq!(content, "fn main() {}");
}
#[test]
fn test_manifest_save_and_load() {
let workspace = TempDir::new().expect("Failed to create temp dir");
let workspace_root = workspace.path();
let mut manifest = BackupManifest::new(
"test-manifest".to_string(),
workspace_root.join(".splice-backup").join("test-manifest"),
);
manifest.add_file(PathBuf::from("src/lib.rs"), "abc123".to_string(), 1024);
let manifest_path = workspace_root.join(".splice-backup/test-manifest/manifest.json");
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent).expect("Failed to create dir");
} else {
fs::create_dir_all(&manifest_path).expect("Failed to create manifest dir");
}
manifest.save().expect("Failed to save manifest");
let loaded = BackupManifest::load(&manifest_path).expect("Failed to load manifest");
assert_eq!(loaded.operation_id, "test-manifest");
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files[0].replaced_path, PathBuf::from("src/lib.rs"));
assert_eq!(loaded.files[0].hash, "abc123");
assert_eq!(loaded.files[0].size, 1024);
}
}