use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BackupError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupMetadata {
pub original_hash: String,
pub last_edit_hash: String,
}
impl BackupMetadata {
pub fn new(hash: String) -> Self {
BackupMetadata {
original_hash: hash.clone(),
last_edit_hash: hash,
}
}
pub fn update_last_edit(&mut self, hash: String) {
self.last_edit_hash = hash;
}
}
pub fn hash_file(path: &Path) -> Result<String, BackupError> {
let data = fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let result = hasher.finalize();
Ok(hex::encode(result))
}
pub fn backup_paths(save_path: &Path) -> (PathBuf, PathBuf) {
let backup_path = save_path.with_extension("sav.bak");
let metadata_path = save_path.with_extension("sav.bak.json");
(backup_path, metadata_path)
}
pub fn read_metadata(metadata_path: &Path) -> Result<Option<BackupMetadata>, BackupError> {
if !metadata_path.exists() {
return Ok(None);
}
let data = fs::read_to_string(metadata_path)?;
let metadata: BackupMetadata = serde_json::from_str(&data)?;
Ok(Some(metadata))
}
pub fn write_metadata(metadata_path: &Path, metadata: &BackupMetadata) -> Result<(), BackupError> {
let json = serde_json::to_string_pretty(metadata)?;
fs::write(metadata_path, json)?;
Ok(())
}
pub fn should_create_backup(
save_path: &Path,
backup_path: &Path,
metadata_path: &Path,
) -> Result<bool, BackupError> {
if !backup_path.exists() {
return Ok(true);
}
let Some(metadata) = read_metadata(metadata_path)? else {
return Ok(false);
};
let current_hash = hash_file(save_path)?;
if current_hash == metadata.original_hash || current_hash == metadata.last_edit_hash {
return Ok(false);
}
Ok(true)
}
pub fn create_backup(
save_path: &Path,
backup_path: &Path,
metadata_path: &Path,
) -> Result<(), BackupError> {
fs::copy(save_path, backup_path)?;
let hash = hash_file(save_path)?;
let metadata = BackupMetadata::new(hash);
write_metadata(metadata_path, &metadata)?;
Ok(())
}
pub fn update_after_edit(save_path: &Path, metadata_path: &Path) -> Result<(), BackupError> {
let current_hash = hash_file(save_path)?;
let mut metadata =
read_metadata(metadata_path)?.unwrap_or_else(|| BackupMetadata::new(current_hash.clone()));
metadata.update_last_edit(current_hash);
write_metadata(metadata_path, &metadata)?;
Ok(())
}
pub fn smart_backup(save_path: &Path) -> Result<bool, BackupError> {
let (backup_path, metadata_path) = backup_paths(save_path);
if should_create_backup(save_path, &backup_path, &metadata_path)? {
create_backup(save_path, &backup_path, &metadata_path)?;
Ok(true)
} else {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn create_test_file(path: &Path, content: &[u8]) -> Result<(), BackupError> {
let mut file = fs::File::create(path)?;
file.write_all(content)?;
Ok(())
}
#[test]
fn test_hash_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.sav");
create_test_file(&file_path, b"test content").unwrap();
let hash = hash_file(&file_path).unwrap();
assert!(!hash.is_empty());
assert_eq!(hash.len(), 64); }
#[test]
fn test_backup_paths() {
let save_path = Path::new("/tmp/1.sav");
let (backup, metadata) = backup_paths(save_path);
assert_eq!(backup, PathBuf::from("/tmp/1.sav.bak"));
assert_eq!(metadata, PathBuf::from("/tmp/1.sav.bak.json"));
}
#[test]
fn test_metadata_roundtrip() {
let temp_dir = tempfile::tempdir().unwrap();
let metadata_path = temp_dir.path().join("test.json");
let original = BackupMetadata::new("hash123".to_string());
write_metadata(&metadata_path, &original).unwrap();
let loaded = read_metadata(&metadata_path).unwrap().unwrap();
assert_eq!(loaded.original_hash, "hash123");
assert_eq!(loaded.last_edit_hash, "hash123");
}
#[test]
fn test_should_create_backup_no_backup() {
let temp_dir = tempfile::tempdir().unwrap();
let save_path = temp_dir.path().join("test.sav");
let (backup_path, metadata_path) = backup_paths(&save_path);
create_test_file(&save_path, b"content").unwrap();
let should_backup = should_create_backup(&save_path, &backup_path, &metadata_path).unwrap();
assert!(should_backup);
}
#[test]
fn test_should_create_backup_same_file() {
let temp_dir = tempfile::tempdir().unwrap();
let save_path = temp_dir.path().join("test.sav");
let (backup_path, metadata_path) = backup_paths(&save_path);
create_test_file(&save_path, b"content").unwrap();
create_backup(&save_path, &backup_path, &metadata_path).unwrap();
let should_backup = should_create_backup(&save_path, &backup_path, &metadata_path).unwrap();
assert!(!should_backup);
}
#[test]
fn test_should_create_backup_after_edit() {
let temp_dir = tempfile::tempdir().unwrap();
let save_path = temp_dir.path().join("test.sav");
let (backup_path, metadata_path) = backup_paths(&save_path);
create_test_file(&save_path, b"original").unwrap();
create_backup(&save_path, &backup_path, &metadata_path).unwrap();
create_test_file(&save_path, b"modified").unwrap();
update_after_edit(&save_path, &metadata_path).unwrap();
let should_backup = should_create_backup(&save_path, &backup_path, &metadata_path).unwrap();
assert!(!should_backup);
}
#[test]
fn test_should_create_backup_replaced_file() {
let temp_dir = tempfile::tempdir().unwrap();
let save_path = temp_dir.path().join("test.sav");
let (backup_path, metadata_path) = backup_paths(&save_path);
create_test_file(&save_path, b"original").unwrap();
create_backup(&save_path, &backup_path, &metadata_path).unwrap();
create_test_file(&save_path, b"completely different content").unwrap();
let should_backup = should_create_backup(&save_path, &backup_path, &metadata_path).unwrap();
assert!(should_backup);
}
#[test]
fn test_smart_backup() {
let temp_dir = tempfile::tempdir().unwrap();
let save_path = temp_dir.path().join("test.sav");
create_test_file(&save_path, b"content").unwrap();
let created = smart_backup(&save_path).unwrap();
assert!(created);
let created = smart_backup(&save_path).unwrap();
assert!(!created);
}
}