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)?;
crate::io_atomic::write(&path, json.as_bytes())
}
}
pub fn backup_filename(now: chrono::DateTime<chrono::Utc>) -> String {
now.format("blackinkhaven_%Y%d%m_%H%M%S.zip").to_string()
}
pub fn prune_backups(dir: &Path, keep_last: usize) {
if keep_last == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut zips: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("blackinkhaven_") && n.ends_with(".zip"))
.unwrap_or(false)
})
.filter_map(|p| {
let mtime = std::fs::metadata(&p).and_then(|m| m.modified()).ok()?;
Some((mtime, p))
})
.collect();
if zips.len() <= keep_last {
return;
}
zips.sort_by(|a, b| b.0.cmp(&a.0)); for (_, path) in zips.into_iter().skip(keep_last) {
let _ = std::fs::remove_file(path);
}
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
fn count_zips(dir: &Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("blackinkhaven_")
})
.count()
}
#[test]
fn prune_keeps_only_n_backups_and_spares_other_files() {
let dir = tempfile::tempdir().unwrap();
for n in [
"blackinkhaven_20260101_000000.zip",
"blackinkhaven_20260102_000000.zip",
"blackinkhaven_20260103_000000.zip",
"blackinkhaven_20260104_000000.zip",
] {
std::fs::write(dir.path().join(n), b"zip").unwrap();
}
std::fs::write(dir.path().join("notes.txt"), b"x").unwrap();
prune_backups(dir.path(), 2);
assert_eq!(count_zips(dir.path()), 2, "should retain exactly keep_last");
assert!(dir.path().join("notes.txt").exists(), "non-backup file spared");
}
#[test]
fn prune_zero_keeps_everything() {
let dir = tempfile::tempdir().unwrap();
for n in [
"blackinkhaven_20260101_000000.zip",
"blackinkhaven_20260102_000000.zip",
] {
std::fs::write(dir.path().join(n), b"z").unwrap();
}
prune_backups(dir.path(), 0);
assert_eq!(count_zips(dir.path()), 2);
}
}