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;
fn snapdir(cache: &Path) -> Command {
let mut cmd = Command::cargo_bin("snapdir").expect("snapdir binary built");
cmd.env("SNAPDIR_CACHE_DIR", cache);
cmd.env_remove("SNAPDIR_CATALOG");
cmd
}
fn build_tree(dir: &Path, leaf: &str) {
std::fs::write(dir.join("a.txt"), leaf).unwrap();
std::fs::set_permissions(dir.join("a.txt"), PermissionsExt::from_mode(0o644)).unwrap();
std::fs::set_permissions(dir, PermissionsExt::from_mode(0o755)).unwrap();
}
fn stdout_ok(cache: &Path, args: &[&str]) -> String {
let out = output_ok(cache, args);
String::from_utf8(out).unwrap().trim_end().to_owned()
}
fn output_ok(cache: &Path, args: &[&str]) -> Vec<u8> {
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),
);
out.stdout
}
fn json_field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("\"{key}\":");
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
if let Some(stripped) = rest.strip_prefix('"') {
let end = stripped.find('"')?;
Some(&stripped[..end])
} else {
let end = rest.find([',', '}']).unwrap_or(rest.len());
Some(rest[..end].trim())
}
}
#[test]
fn catalog_logging_manifest_records_location_and_revision() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
build_tree(src.path(), "hello");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
stdout_ok(
cache.path(),
&["manifest", "--catalog", &catalog_str, &src_str],
);
let id = stdout_ok(cache.path(), &["id", &src_str]);
assert_eq!(id.len(), 64, "id must be a 64-hex snapshot id: {id:?}");
let locations = stdout_ok(cache.path(), &["locations", "--catalog", &catalog_str]);
let loc_lines: Vec<&str> = locations.lines().collect();
assert_eq!(loc_lines.len(), 1, "exactly one location: {locations:?}");
assert_eq!(
json_field(loc_lines[0], "location"),
Some(src_str.as_str()),
"location is the manifested dir's absolute path: {:?}",
loc_lines[0]
);
assert_eq!(json_field(loc_lines[0], "id"), Some(id.as_str()));
let revisions = stdout_ok(
cache.path(),
&[
"revisions",
"--catalog",
&catalog_str,
"--location",
&src_str,
],
);
let rev_lines: Vec<&str> = revisions.lines().collect();
assert_eq!(rev_lines.len(), 1, "exactly one revision: {revisions:?}");
assert_eq!(json_field(rev_lines[0], "id"), Some(id.as_str()));
assert_eq!(
json_field(rev_lines[0], "previous_id"),
Some("null"),
"first revision's previous_id is null: {:?}",
rev_lines[0]
);
}
#[test]
fn catalog_logging_stage_records_location_and_revision() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
build_tree(src.path(), "hello-stage");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
let staged_id = stdout_ok(
cache.path(),
&["stage", "--catalog", &catalog_str, &src_str],
);
assert_eq!(
staged_id.len(),
64,
"stage prints a 64-hex id: {staged_id:?}"
);
let id = stdout_ok(cache.path(), &["id", &src_str]);
assert_eq!(id, staged_id, "staged id must equal `snapdir id`");
let locations = stdout_ok(cache.path(), &["locations", "--catalog", &catalog_str]);
let loc_lines: Vec<&str> = locations.lines().collect();
assert_eq!(loc_lines.len(), 1, "exactly one location: {locations:?}");
assert_eq!(
json_field(loc_lines[0], "location"),
Some(src_str.as_str()),
"location is the staged base dir: {:?}",
loc_lines[0]
);
let revisions = stdout_ok(
cache.path(),
&[
"revisions",
"--catalog",
&catalog_str,
"--location",
&src_str,
],
);
let rev_lines: Vec<&str> = revisions.lines().collect();
assert_eq!(rev_lines.len(), 1, "exactly one revision: {revisions:?}");
assert_eq!(json_field(rev_lines[0], "id"), Some(staged_id.as_str()));
}
#[test]
fn catalog_logging_second_manifest_chains_revisions_and_ancestors() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
build_tree(src.path(), "first");
stdout_ok(
cache.path(),
&["manifest", "--catalog", &catalog_str, &src_str],
);
let id1 = stdout_ok(cache.path(), &["id", &src_str]);
build_tree(src.path(), "second (changed)");
stdout_ok(
cache.path(),
&["manifest", "--catalog", &catalog_str, &src_str],
);
let id2 = stdout_ok(cache.path(), &["id", &src_str]);
assert_ne!(id1, id2, "changed content yields a distinct id");
let revisions = stdout_ok(
cache.path(),
&[
"revisions",
"--catalog",
&catalog_str,
"--location",
&src_str,
],
);
let rev_lines: Vec<&str> = revisions.lines().collect();
assert_eq!(rev_lines.len(), 2, "two revisions: {revisions:?}");
assert_eq!(json_field(rev_lines[0], "id"), Some(id2.as_str()));
assert_eq!(json_field(rev_lines[1], "id"), Some(id1.as_str()));
assert_eq!(json_field(rev_lines[0], "previous_id"), Some(id1.as_str()));
let ancestors = stdout_ok(
cache.path(),
&["ancestors", "--catalog", &catalog_str, "--id", &id2],
);
let anc_lines: Vec<&str> = ancestors.lines().collect();
assert_eq!(anc_lines.len(), 1, "one ancestor: {ancestors:?}");
assert_eq!(json_field(anc_lines[0], "id"), Some(id1.as_str()));
}
#[test]
fn manifest_stdout_is_unchanged_by_catalog_logging() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
build_tree(src.path(), "byte-identical");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
let with_catalog = output_ok(
cache.path(),
&["manifest", "--catalog", &catalog_str, &src_str],
);
let without_catalog = output_ok(cache.path(), &["manifest", &src_str]);
assert_eq!(
with_catalog, without_catalog,
"manifest stdout must be byte-identical with and without a catalog"
);
}
#[test]
fn stage_stdout_is_unchanged_by_catalog_logging() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
build_tree(src.path(), "stage-byte-identical");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
let with_catalog = output_ok(
cache.path(),
&["stage", "--catalog", &catalog_str, &src_str],
);
let cache2 = TempDir::new().unwrap();
let without_catalog = output_ok(cache2.path(), &["stage", &src_str]);
assert_eq!(
with_catalog, without_catalog,
"stage stdout must be byte-identical with and without a catalog"
);
}
#[test]
fn catalog_logging_manifest_without_catalog_exits_zero() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
build_tree(src.path(), "no-catalog");
let src_str = src.path().to_string_lossy().into_owned();
let manifest = stdout_ok(cache.path(), &["manifest", &src_str]);
assert!(
manifest.contains("a.txt"),
"manifest still printed without a catalog: {manifest:?}"
);
let id = stdout_ok(cache.path(), &["id", &src_str]);
assert_eq!(id.len(), 64);
}
#[test]
fn catalog_logging_id_does_not_log() {
let cache = TempDir::new().unwrap();
let catalog = cache.child("catalog.redb");
let src = TempDir::new().unwrap();
build_tree(src.path(), "id-no-log");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.path().to_string_lossy().into_owned();
let id_out = snapdir(cache.path())
.env("SNAPDIR_CATALOG", &catalog_str)
.args(["id", &src_str])
.output()
.expect("run snapdir id");
assert!(
id_out.status.success(),
"snapdir id failed: {}",
String::from_utf8_lossy(&id_out.stderr)
);
let id = String::from_utf8(id_out.stdout)
.unwrap()
.trim_end()
.to_owned();
assert_eq!(id.len(), 64);
let locations = stdout_ok(cache.path(), &["locations", "--catalog", &catalog_str]);
assert_eq!(
locations, "",
"`id` must not log to the catalog (oracle's snapdir_id does not): {locations:?}"
);
}