use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use crate::error::{Error, Result};
pub const LAST_BACKUP_FILE: &str = ".inkhaven-backup.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupState {
pub last_at: chrono::DateTime<chrono::Utc>,
}
impl BackupState {
pub fn load(project_root: &Path) -> Option<Self> {
let path = project_root.join(LAST_BACKUP_FILE);
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn save(&self, project_root: &Path) -> std::io::Result<()> {
let path = project_root.join(LAST_BACKUP_FILE);
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)
}
}
pub fn backup_filename(now: chrono::DateTime<chrono::Utc>) -> String {
now.format("blackinkhaven_%Y%d%m_%H%M%S.zip").to_string()
}
pub type ProgressFn<'a> = Option<&'a mut dyn FnMut(usize, usize)>;
pub fn create_backup(
project_root: &Path,
out_dir: &Path,
skip_dirs: &[PathBuf],
progress: ProgressFn<'_>,
) -> Result<PathBuf> {
if !project_root.is_dir() {
return Err(Error::Store(format!(
"backup: project root `{}` is not a directory",
project_root.display()
)));
}
std::fs::create_dir_all(out_dir).map_err(Error::Io)?;
let now = Utc::now();
let filename = backup_filename(now);
let out_path = out_dir.join(&filename);
let mut to_include: Vec<PathBuf> = Vec::new();
for entry in walkdir::WalkDir::new(project_root)
.sort_by_file_name()
.follow_links(false)
{
let entry = match entry {
Ok(e) => e,
Err(e) => {
eprintln!("warning: walking {}: {e}", project_root.display());
continue;
}
};
if entry.file_type().is_dir() {
continue;
}
let path = entry.path();
let rel = match path.strip_prefix(project_root) {
Ok(r) => r,
Err(_) => continue,
};
if rel.as_os_str().is_empty() {
continue;
}
if rel.ends_with(".inkhaven.log") {
continue;
}
if skip_dirs
.iter()
.any(|skip| rel.starts_with(skip) || path.starts_with(skip))
{
continue;
}
to_include.push(path.to_path_buf());
}
let total = to_include.len();
let file = std::fs::File::create(&out_path).map_err(Error::Io)?;
let mut zip = ZipWriter::new(file);
let opts = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut buf = Vec::with_capacity(8 * 1024);
let mut done = 0usize;
let mut progress = progress;
for path in to_include {
let rel = path
.strip_prefix(project_root)
.map_err(|e| Error::Store(format!("backup: strip prefix: {e}")))?;
let zip_name = rel
.to_str()
.ok_or_else(|| Error::Store(format!("backup: non-UTF8 path: {}", rel.display())))?
.replace(std::path::MAIN_SEPARATOR, "/");
zip.start_file(&zip_name, opts)
.map_err(|e| Error::Store(format!("backup: zip start {zip_name}: {e}")))?;
let mut src = std::fs::File::open(&path).map_err(Error::Io)?;
buf.clear();
src.read_to_end(&mut buf).map_err(Error::Io)?;
zip.write_all(&buf).map_err(Error::Io)?;
done += 1;
if let Some(cb) = progress.as_deref_mut() {
cb(done, total);
}
}
zip.finish()
.map_err(|e| Error::Store(format!("backup: zip finalise: {e}")))?;
let _ = (BackupState { last_at: now }).save(project_root);
Ok(out_path)
}
pub fn restore_backup(archive: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive).map_err(Error::Io)?;
let mut zip = ZipArchive::new(file)
.map_err(|e| Error::Store(format!("restore: open zip: {e}")))?;
let has_marker = (0..zip.len()).any(|i| {
zip.by_index(i)
.map(|f| f.name() == "inkhaven.hjson")
.unwrap_or(false)
});
if !has_marker {
return Err(Error::Store(
"restore: archive does not look like an inkhaven backup (no `inkhaven.hjson` at root)"
.into(),
));
}
if dest.join("inkhaven.hjson").exists() {
return Err(Error::Store(format!(
"restore: `{}` already contains an inkhaven project — pick a fresh directory",
dest.display()
)));
}
std::fs::create_dir_all(dest).map_err(Error::Io)?;
for i in 0..zip.len() {
let mut entry = zip
.by_index(i)
.map_err(|e| Error::Store(format!("restore: read entry: {e}")))?;
let name = entry
.enclosed_name()
.ok_or_else(|| Error::Store(format!("restore: unsafe entry: {}", entry.name())))?
.to_path_buf();
let out_path = dest.join(&name);
if entry.is_dir() {
std::fs::create_dir_all(&out_path).map_err(Error::Io)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(Error::Io)?;
}
let mut out_file = std::fs::File::create(&out_path).map_err(Error::Io)?;
std::io::copy(&mut entry, &mut out_file).map_err(Error::Io)?;
}
Ok(())
}