use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use snapdir_core::{Blake3Hasher, Hasher};
fn snapdir_bin() -> std::path::PathBuf {
assert_cmd::cargo::cargo_bin("snapdir")
}
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-cli-roundtrip-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn run_snapdir(args: &[&str], cache: &Path) -> String {
let output = Command::new(snapdir_bin())
.args(args)
.env("SNAPDIR_CACHE_DIR", cache)
.output()
.expect("run snapdir");
assert!(
output.status.success(),
"snapdir {args:?} exited with {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout)
.expect("stdout is UTF-8")
.trim_end()
.to_owned()
}
fn sharded(prefix: &str, hex: &str) -> String {
format!(
"{prefix}/{}/{}/{}/{}",
&hex[0..3],
&hex[3..6],
&hex[6..9],
&hex[9..]
)
}
#[test]
fn store_roundtrip_push_then_checkout_reproduces_tree() {
let src = temp_dir("src");
let store = temp_dir("store");
let dest = temp_dir("dest");
let cache = temp_dir("cache");
fs::write(src.join("a.txt"), b"hello").unwrap();
fs::set_permissions(src.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap();
fs::create_dir(src.join("sub")).unwrap();
fs::set_permissions(src.join("sub"), fs::Permissions::from_mode(0o755)).unwrap();
fs::write(src.join("sub").join("b.txt"), b"world!!").unwrap();
fs::set_permissions(
src.join("sub").join("b.txt"),
fs::Permissions::from_mode(0o600),
)
.unwrap();
fs::set_permissions(&src, fs::Permissions::from_mode(0o755)).unwrap();
let store_url = format!("file://{}", store.display());
let src_str = src.to_string_lossy().into_owned();
let dest_str = dest.to_string_lossy().into_owned();
let src_id = run_snapdir(&["id", &src_str], &cache);
assert_eq!(src_id.len(), 64, "snapshot id should be 64 hex chars");
let pushed_id = run_snapdir(&["push", "--store", &store_url, &src_str], &cache);
assert_eq!(pushed_id, src_id, "push must print the source snapshot id");
let manifest_key = store.join(sharded(".manifests", &src_id));
assert!(
manifest_key.is_file(),
"manifest must land at sharded key {}",
manifest_key.display()
);
for (rel, bytes) in [("a.txt", &b"hello"[..]), ("sub/b.txt", &b"world!!"[..])] {
let sum = Blake3Hasher::new().hash_hex(bytes);
let obj = store.join(sharded(".objects", &sum));
assert!(
obj.is_file(),
"object for {rel} must land at {}",
obj.display()
);
assert_eq!(fs::read(&obj).unwrap(), bytes, "object bytes for {rel}");
}
run_snapdir(
&["pull", "--store", &store_url, "--id", &src_id, &dest_str],
&cache,
);
assert_eq!(fs::read(dest.join("a.txt")).unwrap(), b"hello");
assert_eq!(
fs::read(dest.join("sub").join("b.txt")).unwrap(),
b"world!!"
);
let dest_id = run_snapdir(&["id", &dest_str], &cache);
assert_eq!(
dest_id, src_id,
"checked-out tree must re-manifest to the source snapshot id"
);
for dir in [&src, &store, &dest, &cache] {
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn push_by_staged_id_pushes_the_staged_snapshot_not_cwd() {
let src = temp_dir("src-staged");
let store = temp_dir("store-staged");
let dest = temp_dir("dest-staged");
let cache = temp_dir("cache-staged");
fs::write(src.join("a.txt"), b"hello").unwrap();
fs::set_permissions(src.join("a.txt"), fs::Permissions::from_mode(0o644)).unwrap();
fs::set_permissions(&src, fs::Permissions::from_mode(0o755)).unwrap();
let store_url = format!("file://{}", store.display());
let src_str = src.to_string_lossy().into_owned();
let dest_str = dest.to_string_lossy().into_owned();
let staged_id = run_snapdir(&["stage", &src_str], &cache);
assert_eq!(staged_id.len(), 64, "staged id should be 64 hex chars");
let pushed_id = run_snapdir(&["push", "--store", &store_url, "--id", &staged_id], &cache);
assert_eq!(
pushed_id, staged_id,
"push --id must push the staged snapshot, not the working directory"
);
assert!(
store.join(sharded(".manifests", &staged_id)).is_file(),
"manifest must land in the store under its sharded key"
);
let obj = store.join(sharded(".objects", &Blake3Hasher::new().hash_hex(b"hello")));
assert!(obj.is_file(), "the file object must land in the store");
run_snapdir(
&["pull", "--store", &store_url, "--id", &staged_id, &dest_str],
&cache,
);
assert_eq!(
run_snapdir(&["id", &dest_str], &cache),
staged_id,
"restore from the store must re-manifest to the staged id"
);
for dir in [&src, &store, &dest, &cache] {
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn push_by_unknown_id_errors_without_walking_cwd() {
let store = temp_dir("store-unknown");
let cache = temp_dir("cache-unknown");
let store_url = format!("file://{}", store.display());
let unknown = "0".repeat(64);
let output = Command::new(snapdir_bin())
.args(["push", "--store", &store_url, "--id", &unknown])
.env("SNAPDIR_CACHE_DIR", &cache)
.output()
.expect("run snapdir");
assert!(
!output.status.success(),
"push --id with an unknown id must fail, not push a snapshot of the CWD"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not found in the local cache"),
"error should explain the id was never staged/fetched; got: {stderr}"
);
for dir in [&store, &cache] {
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn store_roundtrip_fetch_then_checkout_separately() {
let src = temp_dir("src2");
let store = temp_dir("store2");
let dest = temp_dir("dest2");
let cache = temp_dir("cache2");
fs::write(src.join("only.txt"), b"solo").unwrap();
fs::set_permissions(src.join("only.txt"), fs::Permissions::from_mode(0o644)).unwrap();
fs::set_permissions(&src, fs::Permissions::from_mode(0o755)).unwrap();
let store_url = format!("file://{}", store.display());
let src_str = src.to_string_lossy().into_owned();
let dest_str = dest.to_string_lossy().into_owned();
let id = run_snapdir(&["push", "--store", &store_url, &src_str], &cache);
run_snapdir(&["fetch", "--store", &store_url, "--id", &id], &cache);
let cache_manifest = cache.join(sharded(".manifests", &id));
assert!(cache_manifest.is_file(), "fetch must cache the manifest");
run_snapdir(&["checkout", "--id", &id, &dest_str], &cache);
assert_eq!(fs::read(dest.join("only.txt")).unwrap(), b"solo");
assert_eq!(run_snapdir(&["id", &dest_str], &cache), id);
for dir in [&src, &store, &dest, &cache] {
fs::remove_dir_all(dir).ok();
}
}