use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use assert_fs::TempDir;
use predicates::prelude::*;
fn snapdir(cache: &Path) -> Command {
let mut cmd = Command::cargo_bin("snapdir").expect("snapdir binary built");
cmd.env("SNAPDIR_CACHE_DIR", cache);
cmd
}
fn build_tree(dir: &TempDir) {
dir.child("a.txt").write_str("hello").unwrap();
std::fs::set_permissions(dir.child("a.txt").path(), PermissionsExt::from_mode(0o644)).unwrap();
dir.child("sub/b.txt").write_str("world!!").unwrap();
std::fs::set_permissions(
dir.child("sub/b.txt").path(),
PermissionsExt::from_mode(0o600),
)
.unwrap();
std::fs::set_permissions(dir.child("sub").path(), PermissionsExt::from_mode(0o755)).unwrap();
std::fs::set_permissions(dir.path(), PermissionsExt::from_mode(0o755)).unwrap();
}
fn stdout_ok(cache: &Path, args: &[&str]) -> String {
let out = snapdir(cache).args(args).output().expect("run snapdir");
assert!(
out.status.success(),
"snapdir {args:?} failed ({:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).unwrap().trim_end().to_owned()
}
#[test]
fn id_is_64_lowercase_hex() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let id = stdout_ok(cache.path(), &["id", &src_str]);
assert_eq!(id.len(), 64, "snapshot id must be 64 hex chars: {id:?}");
assert!(
id.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"snapshot id must be lowercase hex: {id:?}"
);
}
#[test]
fn push_fetch_checkout_roundtrip_reproduces_id() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let pushed = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
assert_eq!(pushed, src_id, "push must print the source snapshot id");
snapdir(cache.path())
.args(["fetch", "--store", &store_url, "--id", &src_id])
.assert()
.success();
snapdir(cache.path())
.args(["checkout", "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"checked-out tree must re-manifest to the source id"
);
snapdir(cache.path())
.args(["verify", "--store", &store_url, "--id", &src_id])
.assert()
.success();
}
#[test]
fn verify_purge_is_rejected() {
let cache = TempDir::new().unwrap();
let zeros = "0".repeat(64);
snapdir(cache.path())
.args([
"verify",
"--store",
"file:///tmp/nonexistent-snapdir-verify-purge",
"--id",
&zeros,
"--purge",
])
.assert()
.failure()
.stderr(predicate::str::contains("verify").and(predicate::str::contains("--purge")));
}
#[test]
fn verify_without_purge_does_not_hit_purge_error() {
let cache = TempDir::new().unwrap();
let zeros = "0".repeat(64);
snapdir(cache.path())
.args([
"verify",
"--store",
"file:///tmp/nonexistent-snapdir-verify-purge",
"--id",
&zeros,
])
.assert()
.failure()
.stderr(predicate::str::contains("does not support --purge").not());
}
#[test]
fn pull_is_fetch_plus_checkout() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(stdout_ok(cache.path(), &["id", &dest_str]), src_id);
}
#[test]
fn transfer_flags_push_pull_roundtrip() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let pushed = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store_url,
"--jobs",
"2",
"--limit-rate",
"1M",
&src_str,
],
);
assert_eq!(pushed, src_id, "push with transfer flags must print the id");
snapdir(cache.path())
.args([
"pull",
"--store",
&store_url,
"--id",
&src_id,
"-j",
"1",
"--limit-rate",
"512K",
&dest_str,
])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"tree pulled with transfer flags must re-manifest to the source id"
);
}
#[test]
fn fetch_cached_skips_store_objects() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
let dest2 = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let redest_str = dest2.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
assert_eq!(stdout_ok(cache.path(), &["id", &dest_str]), src_id);
let objects = store.path().join(".objects");
assert!(objects.exists(), "store must have an .objects subtree");
std::fs::remove_dir_all(&objects).expect("remove store .objects subtree");
assert!(store.path().join(".manifests").exists(), "manifest kept");
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &redest_str])
.assert()
.success();
dest2.child("a.txt").assert("hello");
dest2.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &redest_str]),
src_id,
"cache-served pull must reproduce the source id"
);
snapdir(cache.path())
.args(["fetch", "--store", &store_url, "--id", &src_id])
.assert()
.success();
}
#[test]
fn fetch_without_store_fails_with_clear_message() {
let cache = TempDir::new().unwrap();
snapdir(cache.path())
.args(["fetch", "--id", &"0".repeat(64)])
.assert()
.failure()
.stderr(predicate::str::contains("--store"));
}
#[test]
fn checkout_unknown_id_fails() {
let cache = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
let dest_str = dest.path().to_string_lossy().into_owned();
snapdir(cache.path())
.args(["checkout", "--id", &"0".repeat(64), &dest_str])
.assert()
.failure();
}
fn count_files(dir: &Path) -> usize {
let mut total = 0;
if !dir.exists() {
return 0;
}
for entry in std::fs::read_dir(dir).expect("read_dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
total += count_files(&path);
} else {
total += 1;
}
}
total
}
#[test]
fn push_pull_pull_is_idempotent() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"first pull must reproduce the source id"
);
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"repeated pull must leave the destination re-manifesting to the same id"
);
}
#[test]
fn dryrun_push_leaves_store_empty_e2e() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let id = stdout_ok(
cache.path(),
&["push", "--dryrun", "--store", &store_url, &src_str],
);
assert_eq!(id.len(), 64, "push --dryrun must still print the id");
assert!(
!store.path().join(".objects").exists(),
"push --dryrun must not create any store objects"
);
assert!(
!store.path().join(".manifests").exists(),
"push --dryrun must not create any store manifests"
);
assert_eq!(
count_files(store.path()),
0,
"store must remain empty after push --dryrun"
);
let realstore = TempDir::new().unwrap();
let real_url = format!("file://{}", realstore.path().display());
let pushcache = TempDir::new().unwrap();
let real_id = stdout_ok(pushcache.path(), &["push", "--store", &real_url, &src_str]);
let dest = TempDir::new().unwrap();
let pullcache = TempDir::new().unwrap();
let dest_str = dest.path().to_string_lossy().into_owned();
snapdir(pullcache.path())
.args([
"pull", "--dryrun", "--store", &real_url, "--id", &real_id, &dest_str,
])
.assert()
.success();
assert_eq!(
count_files(dest.path()),
0,
"pull --dryrun must not materialize any destination files"
);
assert_eq!(
count_files(pullcache.path()),
0,
"pull --dryrun must not write to the cache"
);
}
#[test]
fn pull_repairs_corrupted_dest_file() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
assert_eq!(stdout_ok(cache.path(), &["id", &dest_str]), src_id);
dest.child("a.txt")
.write_str("CORRUPTED-WRONG-BYTES")
.unwrap();
assert_ne!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"corrupting the file must change the re-manifested id"
);
let objects = store.path().join(".objects");
assert!(objects.exists(), "store must have an .objects subtree");
std::fs::remove_dir_all(&objects).expect("remove store .objects subtree");
snapdir(cache.path())
.args(["pull", "--store", &store_url, "--id", &src_id, &dest_str])
.assert()
.success();
dest.child("a.txt").assert("hello");
dest.child("sub/b.txt").assert("world!!");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"repairing pull must restore the destination to the source id"
);
}
fn build_multi_tree(dir: &TempDir) {
let files = [
("top1.txt", "alpha", 0o644),
("top2.bin", "bravo-bravo", 0o600),
("dir_a/a1.txt", "charlie", 0o644),
("dir_a/a2.txt", "delta-delta-delta", 0o640),
("dir_a/nested/deep.txt", "echo!!", 0o644),
("dir_b/b1.txt", "foxtrot", 0o600),
("dir_b/b2.txt", "golf", 0o644),
("dir_b/sub/c/leaf.dat", "hotel-hotel", 0o644),
];
for (rel, body, mode) in files {
dir.child(rel).write_str(body).unwrap();
std::fs::set_permissions(dir.child(rel).path(), PermissionsExt::from_mode(mode)).unwrap();
}
for d in ["dir_a", "dir_a/nested", "dir_b", "dir_b/sub", "dir_b/sub/c"] {
std::fs::set_permissions(dir.child(d).path(), PermissionsExt::from_mode(0o755)).unwrap();
}
std::fs::set_permissions(dir.path(), PermissionsExt::from_mode(0o755)).unwrap();
}
#[test]
fn transfer_concurrency_jobs4_roundtrip() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_multi_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let pushed = stdout_ok(
cache.path(),
&["push", "--store", &store_url, "--jobs", "4", &src_str],
);
assert_eq!(pushed, src_id, "push --jobs 4 must print the source id");
snapdir(cache.path())
.args([
"pull", "--store", &store_url, "--id", &src_id, "--jobs", "4", &dest_str,
])
.assert()
.success();
dest.child("dir_a/nested/deep.txt").assert("echo!!");
dest.child("dir_b/sub/c/leaf.dat").assert("hotel-hotel");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"tree pulled with --jobs 4 must re-manifest to the source id"
);
}
#[test]
fn transfer_concurrency_jobs1_roundtrip() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_multi_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let pushed = stdout_ok(
cache.path(),
&["push", "--store", &store_url, "--jobs", "1", &src_str],
);
assert_eq!(pushed, src_id, "push --jobs 1 must print the source id");
snapdir(cache.path())
.args([
"pull", "--store", &store_url, "--id", &src_id, "--jobs", "1", &dest_str,
])
.assert()
.success();
dest.child("dir_a/a2.txt").assert("delta-delta-delta");
dest.child("dir_b/b1.txt").assert("foxtrot");
let dest_id = stdout_ok(cache.path(), &["id", &dest_str]);
assert_eq!(
dest_id, src_id,
"tree pulled with --jobs 1 must re-manifest to the source id"
);
let parallel_store = TempDir::new().unwrap();
let parallel_url = format!("file://{}", parallel_store.path().display());
let pushed4 = stdout_ok(
cache.path(),
&["push", "--store", ¶llel_url, "--jobs", "4", &src_str],
);
assert_eq!(
pushed4, pushed,
"--jobs 4 and --jobs 1 pushes must yield the same snapshot id"
);
}
#[test]
fn transfer_concurrency_limit_rate_accepted() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_multi_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let pushed = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store_url,
"--jobs",
"2",
"--limit-rate",
"1M",
&src_str,
],
);
assert_eq!(
pushed, src_id,
"push --jobs 2 --limit-rate 1M must print the source id"
);
snapdir(cache.path())
.args([
"pull",
"--store",
&store_url,
"--id",
&src_id,
"--limit-rate",
"512K",
&dest_str,
])
.assert()
.success();
dest.child("top1.txt").assert("alpha");
dest.child("dir_b/sub/c/leaf.dat").assert("hotel-hotel");
assert_eq!(
stdout_ok(cache.path(), &["id", &dest_str]),
src_id,
"tree pulled with --limit-rate must re-manifest to the source id"
);
}
#[test]
fn manifest_multi_exclude_drops_paths_e2e() {
let src = TempDir::new().unwrap();
build_tree(&src);
src.child("node_modules/x").write_str("dep").unwrap();
src.child("coverage/y").write_str("scratch").unwrap();
let src_str = src.path().to_string_lossy().into_owned();
let cache = TempDir::new().unwrap();
let plain = stdout_ok(cache.path(), &["manifest", &src_str]);
assert!(
plain.contains("node_modules"),
"plain manifest should include node_modules:\n{plain}"
);
assert!(
plain.contains("coverage"),
"plain manifest should include coverage:\n{plain}"
);
let comma = stdout_ok(
cache.path(),
&["manifest", "--exclude", "node_modules,coverage", &src_str],
);
let repeated = stdout_ok(
cache.path(),
&[
"manifest",
"--exclude",
"node_modules",
"--exclude",
"coverage",
&src_str,
],
);
for (label, out) in [("comma", &comma), ("repeated", &repeated)] {
assert!(
!out.contains("node_modules"),
"{label} --exclude must drop node_modules:\n{out}"
);
assert!(
!out.contains("coverage"),
"{label} --exclude must drop coverage:\n{out}"
);
assert!(
out.contains("./a.txt") && out.contains("./sub/b.txt"),
"{label} --exclude must keep the non-excluded files:\n{out}"
);
}
assert_eq!(
comma, repeated,
"comma and repeated --exclude forms must produce identical manifests"
);
}
#[test]
fn verbose_jobs_push_reports_concurrency() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let out = snapdir(cache.path())
.args([
"push",
"--jobs",
"3",
"--verbose",
"--store",
&store_url,
&src_str,
])
.output()
.expect("run snapdir");
assert!(out.status.success(), "verbose push must succeed");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
stdout.trim_end(),
src_id,
"stdout must remain exactly the snapshot id"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("transfers: 3 concurrent"),
"stderr must report effective concurrency:\n{stderr}"
);
assert_eq!(
stderr.matches("transfers:").count(),
1,
"the transfer-config banner must print exactly once:\n{stderr}"
);
}
#[test]
fn verbose_jobs_limit_rate_reported() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let out = snapdir(cache.path())
.args([
"push",
"--jobs",
"2",
"--limit-rate",
"1M",
"--verbose",
"--store",
&store_url,
&src_str,
])
.output()
.expect("run snapdir");
assert!(out.status.success(), "verbose limited push must succeed");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("2 concurrent") && stderr.contains("limit 1M"),
"stderr must report concurrency AND the limit rate:\n{stderr}"
);
}
#[test]
fn verbose_jobs_silent_without_verbose() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["id", &src_str]);
let out = snapdir(cache.path())
.args(["push", "--jobs", "3", "--store", &store_url, &src_str])
.output()
.expect("run snapdir");
assert!(out.status.success(), "non-verbose push must succeed");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(stdout.trim_end(), src_id, "stdout must be the snapshot id");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
!stderr.contains("concurrent") && !stderr.contains("transfers:"),
"non-verbose run must not emit the transfer-config banner:\n{stderr}"
);
}
#[test]
fn verbose_jobs_pull_reports_once() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let store = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
build_tree(&src);
let src_str = src.path().to_string_lossy().into_owned();
let dest_str = dest.path().to_string_lossy().into_owned();
let store_url = format!("file://{}", store.path().display());
let src_id = stdout_ok(cache.path(), &["push", "--store", &store_url, &src_str]);
let out = snapdir(cache.path())
.args([
"pull",
"--jobs",
"4",
"--verbose",
"--store",
&store_url,
"--id",
&src_id,
&dest_str,
])
.output()
.expect("run snapdir");
assert!(out.status.success(), "verbose pull must succeed");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("transfers: 4 concurrent"),
"pull --verbose must report concurrency:\n{stderr}"
);
assert_eq!(
stderr.matches("transfers:").count(),
1,
"pull must print the transfer-config banner exactly once:\n{stderr}"
);
}