use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::UNIX_EPOCH;
use crate::storage::zip_cache::{self, ZipCache, ZipFingerprint};
use crate::util::archive::{self, Manifest};
pub struct ZipJob {
pub src: PathBuf,
pub dest: PathBuf,
pub name: String,
}
pub struct ZipUpdate {
pub done: usize,
pub total: usize,
pub finished: bool,
pub archives: usize,
pub unchanged: usize,
pub errors: usize,
pub label: Option<String>,
}
impl ZipUpdate {
fn progress(done: usize, total: usize, label: Option<String>) -> Self {
ZipUpdate {
done,
total,
finished: false,
archives: 0,
unchanged: 0,
errors: 0,
label,
}
}
fn finished(
total: usize,
archives: usize,
unchanged: usize,
errors: usize,
) -> Self {
ZipUpdate {
done: total,
total,
finished: true,
archives,
unchanged,
errors,
label: None,
}
}
}
pub fn spawn_zip(
jobs: Vec<ZipJob>,
exclude_dirs: Vec<String>,
cache_path: PathBuf,
) -> Receiver<ZipUpdate> {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let planned: Vec<(ZipJob, Vec<PathBuf>)> = jobs
.into_iter()
.map(|job| {
let files = archive::collect_files(&job.src, &exclude_dirs);
(job, files)
})
.collect();
let total: usize = planned.iter().map(|(_, files)| files.len()).sum();
if sender.send(ZipUpdate::progress(0, total, None)).is_err() {
return;
}
let mut cache = zip_cache::load(&cache_path);
let mut done = 0;
let mut archives = 0;
let mut unchanged = 0;
let mut errors = 0;
for (job, files) in &planned {
let base = done;
let name = job.name.clone();
let source = archive::source_manifest(&job.src, files, |in_job| {
let _ = sender.send(ZipUpdate::progress(
base + in_job,
total,
Some(name.clone()),
));
});
done = base + files.len();
match decide(&source, &job.dest, &cache) {
Decision::Skip(fp) => {
unchanged += 1;
cache.insert(job.dest.clone(), fp);
}
Decision::Write(src_hash) => {
write_job(job, files, src_hash, &mut cache)
.map_or_else(|| errors += 1, |()| archives += 1);
}
}
let _ = sender.send(ZipUpdate::progress(done, total, Some(name)));
}
let _ = zip_cache::save(&cache_path, &cache);
let _ = sender
.send(ZipUpdate::finished(total, archives, unchanged, errors));
});
receiver
}
fn write_job(
job: &ZipJob,
files: &[PathBuf],
src_hash: Option<u64>,
cache: &mut ZipCache,
) -> Option<()> {
match archive::write_zip(&job.src, files, &job.dest, |_| {}) {
Ok(()) => {
match (src_hash, dest_stat(&job.dest)) {
(Some(hash), Some((mtime, size))) => {
cache.insert(
job.dest.clone(),
ZipFingerprint { hash, mtime, size },
);
}
_ => {
cache.remove(&job.dest);
}
}
Some(())
}
Err(error) => {
cache.remove(&job.dest);
log::error!("could not write {}: {error}", job.dest.display());
None
}
}
}
enum Decision {
Skip(ZipFingerprint),
Write(Option<u64>),
}
fn decide(
source: &std::io::Result<Manifest>,
dest: &Path,
cache: &ZipCache,
) -> Decision {
let Ok(source) = source else {
return Decision::Write(None);
};
let src_hash = archive::manifest_hash(source);
let Some((mtime, size)) = dest_stat(dest) else {
return Decision::Write(Some(src_hash));
};
if let Some(fp) = cache.get(dest)
&& fp.mtime == mtime
&& fp.size == size
{
return decide_with_hash(fp.hash, src_hash, mtime, size);
}
match archive::zip_manifest(dest) {
Ok(existing) => decide_with_hash(
archive::manifest_hash(&existing),
src_hash,
mtime,
size,
),
Err(_) => Decision::Write(Some(src_hash)),
}
}
fn decide_with_hash(
existing_hash: u64,
src_hash: u64,
mtime: i64,
size: u64,
) -> Decision {
if existing_hash == src_hash {
Decision::Skip(ZipFingerprint {
hash: src_hash,
mtime,
size,
})
} else {
Decision::Write(Some(src_hash))
}
}
fn dest_stat(dest: &Path) -> Option<(i64, u64)> {
let meta = std::fs::metadata(dest).ok()?;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64);
Some((mtime, meta.len()))
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn run(src: &Path, dest: &Path, cache: &Path) -> ZipUpdate {
let jobs = vec![ZipJob {
src: src.to_path_buf(),
dest: dest.to_path_buf(),
name: "repo".to_string(),
}];
let rx = spawn_zip(jobs, Vec::new(), cache.to_path_buf());
let mut last = None;
while let Ok(update) = rx.recv() {
if update.finished {
last = Some(update);
}
}
last.expect("a finished update")
}
#[test]
fn writes_then_skips_unchanged_then_rewrites_changed() {
let dir = std::env::temp_dir()
.join(format!("hop-zipsvc-test-{}", std::process::id()));
let src = dir.join("repo");
let backups = dir.join("backups");
fs::create_dir_all(src.join("src")).unwrap();
fs::create_dir_all(&backups).unwrap();
fs::write(src.join("src/main.rs"), "fn main() {}").unwrap();
let dest = backups.join("repo.zip");
let cache = dir.join("zip-manifests.toml");
let first = run(&src, &dest, &cache);
assert_eq!((first.archives, first.unchanged, first.errors), (1, 0, 0));
assert!(dest.exists());
assert!(cache.exists());
let before = fs::metadata(&dest).unwrap().modified().unwrap();
let second = run(&src, &dest, &cache);
assert_eq!(
(second.archives, second.unchanged, second.errors),
(0, 1, 0)
);
let after = fs::metadata(&dest).unwrap().modified().unwrap();
assert_eq!(before, after, "unchanged archive must not be rewritten");
fs::write(src.join("src/main.rs"), "fn main() { /* edit */ }").unwrap();
let third = run(&src, &dest, &cache);
assert_eq!((third.archives, third.unchanged, third.errors), (1, 0, 0));
let _ = fs::remove_dir_all(&dir);
}
}