use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use snapdir_core::{Blake3Hasher, Hasher};
fn snapdir_bin() -> std::path::PathBuf {
assert_cmd::cargo::cargo_bin("snapdir")
}
fn path_with_mock_store() -> String {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../snapdir-stores/tests/snapdir-mock-store")
.canonicalize()
.expect("snapdir-mock-store fixture exists");
let dir = fixture.parent().expect("fixture has a parent directory");
format!(
"{}:{}",
dir.display(),
std::env::var("PATH").unwrap_or_default()
)
}
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-cli-external-{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_raw(args: &[&str], cache: &Path) -> Output {
Command::new(snapdir_bin())
.args(args)
.env("SNAPDIR_CACHE_DIR", cache)
.env("PATH", path_with_mock_store())
.output()
.expect("run snapdir")
}
fn run_snapdir(args: &[&str], cache: &Path) -> String {
let output = run_snapdir_raw(args, cache);
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..]
)
}
const TREE_FILES: [(&str, &[u8]); 3] = [
("a.txt", b"hello external"),
("sub/b.txt", b"world!!"),
("with space.txt", b"spaced out"),
];
fn build_source_tree(src: &Path) {
fs::create_dir(src.join("sub")).unwrap();
fs::set_permissions(src.join("sub"), fs::Permissions::from_mode(0o755)).unwrap();
for (rel, bytes) in TREE_FILES {
let target = src.join(rel);
fs::write(&target, bytes).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
}
fs::set_permissions(src, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn external_push_lands_sharded_manifest_and_objects() {
let src = temp_dir("push-src");
let store = temp_dir("push-store");
let cache = temp_dir("push-cache");
build_source_tree(&src);
let store_url = format!("mock://{}", store.display());
let src_str = src.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 TREE_FILES {
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}");
}
let repushed_id = run_snapdir(&["push", "--store", &store_url, &src_str], &cache);
assert_eq!(repushed_id, src_id, "re-push must print the same id");
for dir in [&src, &store, &cache] {
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn external_fetch_fresh_cache_then_checkout_reproduces_tree() {
let src = temp_dir("fetch-src");
let store = temp_dir("fetch-store");
let dest = temp_dir("fetch-dest");
let push_cache = temp_dir("fetch-cache-pusher");
let fetch_cache = temp_dir("fetch-cache-fetcher");
build_source_tree(&src);
let store_url = format!("mock://{}", 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], &push_cache);
run_snapdir(&["fetch", "--store", &store_url, "--id", &id], &fetch_cache);
let cache_manifest = fetch_cache.join(sharded(".manifests", &id));
assert!(
cache_manifest.is_file(),
"external fetch must commit the manifest into the cache at {}",
cache_manifest.display()
);
for (rel, bytes) in TREE_FILES {
let sum = Blake3Hasher::new().hash_hex(bytes);
let obj = fetch_cache.join(sharded(".objects", &sum));
assert!(
obj.is_file(),
"external fetch must file the object for {rel} at {}",
obj.display()
);
}
run_snapdir(&["checkout", "--id", &id, &dest_str], &fetch_cache);
for (rel, bytes) in TREE_FILES {
assert_eq!(
fs::read(dest.join(rel)).unwrap(),
bytes,
"checked-out bytes for {rel}"
);
}
let dest_id = run_snapdir(&["id", &dest_str], &fetch_cache);
assert_eq!(
dest_id, id,
"checked-out tree must re-manifest to the pushed snapshot id"
);
for dir in [&src, &store, &dest, &push_cache, &fetch_cache] {
fs::remove_dir_all(dir).ok();
}
}
#[test]
fn external_fetch_unknown_id_fails_with_not_found() {
let store = temp_dir("unknown-store");
let cache = temp_dir("unknown-cache");
let store_url = format!("mock://{}", store.display());
let unknown = "0".repeat(64);
let output = run_snapdir_raw(&["fetch", "--store", &store_url, "--id", &unknown], &cache);
assert!(
!output.status.success(),
"fetch of an unknown id must fail, not succeed silently"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not found"),
"stderr should report the manifest as not found; got: {stderr}"
);
assert!(
!cache.join(sharded(".manifests", &unknown)).exists(),
"a failed fetch must not commit a cache manifest"
);
for dir in [&store, &cache] {
fs::remove_dir_all(dir).ok();
}
}