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_isolated(cache_dir: &Path) -> Command {
let mut cmd = Command::cargo_bin("snapdir").expect("snapdir binary built");
cmd.env_clear();
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
cmd.env("HOME", cache_dir);
cmd.env("XDG_CACHE_HOME", cache_dir);
cmd.env("SNAPDIR_CACHE_DIR", cache_dir);
cmd.env_remove("SNAPDIR_CATALOG");
cmd.env_remove("SNAPDIR_STORE");
cmd
}
fn build_tree(dir: &TempDir, leaf: &str) {
dir.child("a.txt").write_str(leaf).unwrap();
std::fs::set_permissions(dir.child("a.txt").path(), PermissionsExt::from_mode(0o644)).unwrap();
std::fs::set_permissions(dir.path(), PermissionsExt::from_mode(0o755)).unwrap();
}
fn stdout_ok(cache_dir: &Path, args: &[&str]) -> String {
let out = snapdir_isolated(cache_dir)
.args(args)
.output()
.expect("run snapdir");
assert!(
out.status.success(),
"snapdir {args:?} failed (code {:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).unwrap().trim_end().to_owned()
}
#[allow(dead_code)]
fn output_ok(cache_dir: &Path, args: &[&str]) -> Vec<u8> {
let out = snapdir_isolated(cache_dir)
.args(args)
.output()
.expect("run snapdir");
assert!(
out.status.success(),
"snapdir {args:?} failed (code {:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
out.stdout
}
fn file_store(dir: &Path) -> String {
format!("file://{}", dir.display())
}
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_default_push_and_stage_round_trip_no_flag() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src1 = TempDir::new().unwrap();
build_tree(&src1, "first push");
let push_id = stdout_ok(
cache.path(),
&["push", "--store", &store, &src1.path().to_string_lossy()],
);
assert_eq!(
push_id.len(),
64,
"push must print a 64-hex id: {push_id:?}"
);
let src2 = TempDir::new().unwrap();
build_tree(&src2, "second stage (different)");
let stage_id = stdout_ok(cache.path(), &["stage", &src2.path().to_string_lossy()]);
assert_eq!(
stage_id.len(),
64,
"stage must print a 64-hex id: {stage_id:?}"
);
assert_ne!(push_id, stage_id, "distinct trees must have distinct ids");
let revisions = stdout_ok(cache.path(), &["revisions", "--location", &store]);
let lines: Vec<&str> = revisions.lines().collect();
assert!(
!lines.is_empty(),
"revisions (default catalog, no flag) must not be empty after push; \
got {revisions:?}. This is the bug: writes without --catalog were silently dropped."
);
let ids_in_output: Vec<&str> = lines.iter().filter_map(|l| json_field(l, "id")).collect();
assert!(
ids_in_output.contains(&push_id.as_str()),
"push id {push_id} must appear in default-catalog revisions; got {revisions:?}"
);
}
#[test]
fn catalog_default_push_records_to_default_catalog_file() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "catalog file existence");
stdout_ok(
cache.path(),
&["push", "--store", &store, &src.path().to_string_lossy()],
);
let default_catalog = cache.path().join("default-catalog.redb");
assert!(
default_catalog.exists(),
"default-catalog.redb must be created at <cache_dir>/default-catalog.redb \
after a no-flag push; file not found at {default_catalog:?}"
);
}
#[test]
fn catalog_default_revisions_with_no_flag_lists_pushed_id() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "round-trip");
let src_str = src.path().to_string_lossy().into_owned();
let push_id = stdout_ok(cache.path(), &["push", "--store", &store, &src_str]);
let bare_id = stdout_ok(cache.path(), &["id", &src_str]);
assert_eq!(push_id, bare_id, "push id must equal `snapdir id`");
let revisions = stdout_ok(cache.path(), &["revisions", "--location", &store]);
assert!(
revisions.contains(&push_id),
"push id {push_id:?} must appear in no-flag revisions output; got {revisions:?}"
);
}
#[test]
fn catalog_none_push_records_nothing() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "none-disable");
stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
"none",
&src.path().to_string_lossy(),
],
);
let none_catalog = cache.path().join("none-catalog.redb");
assert!(
!none_catalog.exists(),
"--catalog none must not create none-catalog.redb; found it at {none_catalog:?}"
);
}
#[test]
fn catalog_none_revisions_prints_disabled_message_exit_zero() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let out = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", "none", "--location", &store])
.output()
.expect("run snapdir revisions --catalog none");
assert!(
out.status.success(),
"revisions --catalog none must exit 0, not {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8_lossy(&out.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("disabl") || combined.contains("none"),
"revisions --catalog none must print a 'catalog disabled' (or 'none') message; \
got stdout={stdout:?} stderr={stderr:?}"
);
}
#[test]
fn catalog_none_locations_prints_disabled_message_exit_zero() {
let cache = TempDir::new().unwrap();
let out = snapdir_isolated(cache.path())
.args(["locations", "--catalog", "none"])
.output()
.expect("run snapdir locations --catalog none");
assert!(
out.status.success(),
"locations --catalog none must exit 0, not {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout).to_lowercase(),
String::from_utf8_lossy(&out.stderr).to_lowercase(),
);
assert!(
combined.contains("disabl") || combined.contains("none"),
"locations --catalog none must print a disabled message; got {combined:?}"
);
}
#[test]
fn catalog_none_ancestors_prints_disabled_message_exit_zero() {
let cache = TempDir::new().unwrap();
let fake_id = "0".repeat(64);
let out = snapdir_isolated(cache.path())
.args(["ancestors", "--catalog", "none", "--id", &fake_id])
.output()
.expect("run snapdir ancestors --catalog none");
assert!(
out.status.success(),
"ancestors --catalog none must exit 0, not {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout).to_lowercase(),
String::from_utf8_lossy(&out.stderr).to_lowercase(),
);
assert!(
combined.contains("disabl") || combined.contains("none"),
"ancestors --catalog none must print a disabled message; got {combined:?}"
);
}
#[test]
fn catalog_empty_string_acts_like_none_sentinel() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "empty-catalog-arg");
let out = snapdir_isolated(cache.path())
.args([
"push",
"--store",
&store,
"--catalog",
"",
&src.path().to_string_lossy(),
])
.output()
.expect("run snapdir push --catalog ''");
assert!(
out.status.success(),
"push --catalog '' must exit 0; got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let empty_named = cache.path().join("-catalog.redb");
assert!(
!empty_named.exists(),
"--catalog '' must not create a '-catalog.redb' file; found {empty_named:?}"
);
let rev_out = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", "", "--location", &store])
.output()
.expect("run snapdir revisions --catalog ''");
assert!(
rev_out.status.success(),
"revisions --catalog '' must exit 0; got {:?}\nstderr: {}",
rev_out.status.code(),
String::from_utf8_lossy(&rev_out.stderr),
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&rev_out.stdout).to_lowercase(),
String::from_utf8_lossy(&rev_out.stderr).to_lowercase(),
);
assert!(
combined.contains("disabl") || combined.contains("none") || combined.contains("catalog"),
"revisions --catalog '' must signal a disabled catalog; got {combined:?}"
);
}
#[test]
fn catalog_named_foo_isolated_from_default_both_directions() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src_default = TempDir::new().unwrap();
build_tree(&src_default, "default-catalog-push");
let default_push_id = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
&src_default.path().to_string_lossy(),
],
);
let src_foo = TempDir::new().unwrap();
build_tree(&src_foo, "foo-catalog-push (different)");
let foo_push_id = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
"foo",
&src_foo.path().to_string_lossy(),
],
);
assert_ne!(
default_push_id, foo_push_id,
"distinct trees produce distinct ids"
);
let default_revisions = stdout_ok(cache.path(), &["revisions", "--location", &store]);
assert!(
!default_revisions.contains(&foo_push_id),
"default-catalog revisions must NOT contain --catalog foo rows; got {default_revisions:?}"
);
let foo_revisions = stdout_ok(
cache.path(),
&["revisions", "--catalog", "foo", "--location", &store],
);
assert!(
!foo_revisions.contains(&default_push_id),
"--catalog foo revisions must NOT contain default-catalog rows; got {foo_revisions:?}"
);
assert!(
foo_revisions.contains(&foo_push_id),
"--catalog foo revisions must contain foo's push id; got {foo_revisions:?}"
);
}
#[test]
fn catalog_named_foo_file_exists_under_cache_dir() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "named-foo");
stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
"foo",
&src.path().to_string_lossy(),
],
);
let foo_catalog = cache.path().join("foo-catalog.redb");
assert!(
foo_catalog.exists(),
"--catalog foo must create foo-catalog.redb in the cache dir; \
expected {foo_catalog:?}"
);
}
#[test]
fn catalog_defaults_shows_catalog_knob_source_default() {
let cache = TempDir::new().unwrap();
let out = snapdir_isolated(cache.path())
.args(["defaults"])
.output()
.expect("run snapdir defaults");
assert!(
out.status.success(),
"snapdir defaults failed ({:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
let low = stdout.to_lowercase();
assert!(
low.contains("catalog"),
"snapdir defaults must include a 'catalog' knob; got:\n{stdout}"
);
assert!(
low.contains("default"),
"snapdir defaults catalog source must be 'default' on a clean env; got:\n{stdout}"
);
}
#[test]
fn catalog_defaults_shows_source_env_when_snapdir_catalog_set() {
let cache = TempDir::new().unwrap();
let custom_catalog = cache.path().join("custom-catalog.redb");
let out = snapdir_isolated(cache.path())
.env("SNAPDIR_CATALOG", &custom_catalog)
.args(["defaults"])
.output()
.expect("run snapdir defaults with SNAPDIR_CATALOG set");
assert!(
out.status.success(),
"snapdir defaults failed ({:?})\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
let low = stdout.to_lowercase();
assert!(
low.contains("catalog"),
"snapdir defaults must include a 'catalog' knob; got:\n{stdout}"
);
assert!(
low.contains("env"),
"snapdir defaults catalog source must be 'env' when SNAPDIR_CATALOG is set; got:\n{stdout}"
);
}
#[test]
fn catalog_default_manifest_stdout_byte_identical_with_and_without_logging() {
let cache1 = TempDir::new().unwrap();
let cache2 = TempDir::new().unwrap();
let catalog = cache1.path().join("test-catalog.redb");
let src = TempDir::new().unwrap();
build_tree(&src, "keystone-manifest");
let src_str = src.path().to_string_lossy().into_owned();
let catalog_str = catalog.to_string_lossy().into_owned();
let with_catalog = snapdir_isolated(cache1.path())
.args(["manifest", "--catalog", &catalog_str, &src_str])
.output()
.expect("run manifest --catalog");
assert!(with_catalog.status.success(), "manifest --catalog failed");
let without_catalog = snapdir_isolated(cache2.path())
.args(["manifest", "--catalog", "none", &src_str])
.output()
.expect("run manifest --catalog none");
assert!(
without_catalog.status.success(),
"manifest --catalog none failed"
);
assert_eq!(
with_catalog.stdout, without_catalog.stdout,
"manifest stdout must be BYTE-IDENTICAL with/without catalog logging. \
KEYSTONE: the catalog is a pure side effect."
);
}
#[test]
fn catalog_default_id_stdout_byte_identical_with_and_without_logging() {
let cache1 = TempDir::new().unwrap();
let cache2 = TempDir::new().unwrap();
let catalog = cache1.path().join("test-catalog.redb");
let src = TempDir::new().unwrap();
build_tree(&src, "keystone-id");
let src_str = src.path().to_string_lossy().into_owned();
let with_catalog_env = snapdir_isolated(cache1.path())
.env("SNAPDIR_CATALOG", &catalog)
.args(["id", &src_str])
.output()
.expect("run id with SNAPDIR_CATALOG");
assert!(
with_catalog_env.status.success(),
"id with SNAPDIR_CATALOG failed"
);
let without_catalog_env = snapdir_isolated(cache2.path())
.args(["id", &src_str])
.output()
.expect("run id without SNAPDIR_CATALOG");
assert!(
without_catalog_env.status.success(),
"id without catalog failed"
);
assert_eq!(
with_catalog_env.stdout, without_catalog_env.stdout,
"id stdout must be BYTE-IDENTICAL with/without SNAPDIR_CATALOG. \
KEYSTONE: catalog is a pure side effect."
);
}
#[test]
fn catalog_default_id_is_64_hex_chars() {
let cache = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
build_tree(&src, "id-format");
let id = stdout_ok(cache.path(), &["id", &src.path().to_string_lossy()]);
assert_eq!(id.len(), 64, "id must be exactly 64 hex chars; got {id:?}");
assert!(
id.chars().all(|c| c.is_ascii_hexdigit()),
"id must be all hex digits; got {id:?}"
);
}
#[test]
fn catalog_path_like_arg_used_verbatim() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let db_dir = TempDir::new().unwrap();
let db_path = db_dir.path().join("x.redb");
let db_str = db_path.to_string_lossy().into_owned();
let src = TempDir::new().unwrap();
build_tree(&src, "path-like-catalog");
let push_id = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
&db_str,
&src.path().to_string_lossy(),
],
);
assert_eq!(
push_id.len(),
64,
"push with path catalog must print a 64-hex id"
);
assert!(
db_path.exists(),
"--catalog <path> must create the redb file at that exact path; \
expected {db_path:?}"
);
let revisions = stdout_ok(
cache.path(),
&["revisions", "--catalog", &db_str, "--location", &store],
);
assert!(
revisions.contains(&push_id),
"revisions via path-like catalog must list the pushed id {push_id:?}; \
got {revisions:?}"
);
}
#[test]
fn catalog_path_like_isolation_from_default() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let db_dir = TempDir::new().unwrap();
let db_path = db_dir.path().join("isolated.redb");
let db_str = db_path.to_string_lossy().into_owned();
let src = TempDir::new().unwrap();
build_tree(&src, "path-like-isolated");
let path_id = stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
&db_str,
&src.path().to_string_lossy(),
],
);
let default_revisions = stdout_ok(cache.path(), &["revisions", "--location", &store]);
assert!(
!default_revisions.contains(&path_id),
"default-catalog revisions must NOT contain rows from a path-like catalog; \
got {default_revisions:?}"
);
}
#[test]
#[allow(clippy::similar_names)] fn catalog_flag_overrides_snapdir_catalog_env() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let catalog_a = cache.path().join("catalog-a.redb");
let catalog_b = cache.path().join("catalog-b.redb");
let catalog_a_str = catalog_a.to_string_lossy().into_owned();
let catalog_b_str = catalog_b.to_string_lossy().into_owned();
let src = TempDir::new().unwrap();
build_tree(&src, "precedence-test");
let push_id = snapdir_isolated(cache.path())
.env("SNAPDIR_CATALOG", &catalog_a_str)
.args([
"push",
"--store",
&store,
"--catalog",
&catalog_b_str,
&src.path().to_string_lossy(),
])
.output()
.expect("run push with flag+env");
assert!(push_id.status.success(), "push with flag+env must exit 0");
let push_id_str = String::from_utf8(push_id.stdout)
.unwrap()
.trim_end()
.to_owned();
let rev_b = snapdir_isolated(cache.path())
.args([
"revisions",
"--catalog",
&catalog_b_str,
"--location",
&store,
])
.output()
.expect("run revisions --catalog B");
assert!(rev_b.status.success());
let rev_b_str = String::from_utf8(rev_b.stdout).unwrap();
assert!(
rev_b_str.contains(&push_id_str),
"--catalog flag (B) must contain the pushed revision {push_id_str:?}; \
got {rev_b_str:?}"
);
let rev_a = snapdir_isolated(cache.path())
.args([
"revisions",
"--catalog",
&catalog_a_str,
"--location",
&store,
])
.output()
.expect("run revisions --catalog A");
assert!(rev_a.status.success());
let rev_a_str = String::from_utf8(rev_a.stdout).unwrap();
assert!(
!rev_a_str.contains(&push_id_str),
"SNAPDIR_CATALOG env (A) must NOT contain the revision when --catalog flag (B) overrides; \
got {rev_a_str:?}"
);
}
#[test]
fn catalog_env_overrides_default_catalog() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let custom_catalog = cache.path().join("custom-env-catalog.redb");
let custom_str = custom_catalog.to_string_lossy().into_owned();
let src = TempDir::new().unwrap();
build_tree(&src, "env-precedence");
let push_out = snapdir_isolated(cache.path())
.env("SNAPDIR_CATALOG", &custom_str)
.args(["push", "--store", &store, &src.path().to_string_lossy()])
.output()
.expect("run push with SNAPDIR_CATALOG env");
assert!(
push_out.status.success(),
"push with SNAPDIR_CATALOG must exit 0"
);
let push_id = String::from_utf8(push_out.stdout)
.unwrap()
.trim_end()
.to_owned();
let custom_rev = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", &custom_str, "--location", &store])
.output()
.expect("run revisions --catalog custom");
assert!(custom_rev.status.success());
let custom_rev_str = String::from_utf8(custom_rev.stdout).unwrap();
assert!(
custom_rev_str.contains(&push_id),
"SNAPDIR_CATALOG env catalog must contain the pushed revision; got {custom_rev_str:?}"
);
let default_rev = snapdir_isolated(cache.path())
.args(["revisions", "--location", &store])
.output()
.expect("run revisions (default catalog)");
assert!(default_rev.status.success());
let default_rev_str = String::from_utf8(default_rev.stdout).unwrap();
assert!(
!default_rev_str.contains(&push_id),
"default catalog must NOT contain a revision pushed via SNAPDIR_CATALOG env; \
got {default_rev_str:?}"
);
}
#[test]
fn catalog_none_push_does_not_create_default_catalog_file() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "none-no-default-file");
stdout_ok(
cache.path(),
&[
"push",
"--store",
&store,
"--catalog",
"none",
&src.path().to_string_lossy(),
],
);
let none_catalog = cache.path().join("none-catalog.redb");
let default_catalog = cache.path().join("default-catalog.redb");
assert!(
!none_catalog.exists(),
"--catalog none must NOT create none-catalog.redb; found {none_catalog:?}"
);
assert!(
!default_catalog.exists(),
"--catalog none must NOT create default-catalog.redb either; found {default_catalog:?}"
);
}
#[test]
fn catalog_none_disabled_message_distinct_from_empty_enabled_catalog() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let empty_catalog = cache.path().join("empty.redb");
let empty_str = empty_catalog.to_string_lossy().into_owned();
let empty_out = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", &empty_str, "--location", &store])
.output()
.expect("run revisions with empty catalog");
assert!(
empty_out.status.success(),
"enabled-empty catalog must exit 0"
);
let empty_combined = format!(
"{}{}",
String::from_utf8_lossy(&empty_out.stdout).to_lowercase(),
String::from_utf8_lossy(&empty_out.stderr).to_lowercase(),
);
assert!(
!empty_combined.contains("disabl"),
"enabled-empty catalog must NOT print 'disabled'; got {empty_combined:?}"
);
let none_out = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", "none", "--location", &store])
.output()
.expect("run revisions --catalog none");
assert!(none_out.status.success(), "--catalog none must exit 0");
let none_combined = format!(
"{}{}",
String::from_utf8_lossy(&none_out.stdout).to_lowercase(),
String::from_utf8_lossy(&none_out.stderr).to_lowercase(),
);
assert!(
none_combined.contains("disabl") || none_combined.contains("none"),
"--catalog none must print a disabled message; got {none_combined:?}"
);
}
#[test]
fn catalog_none_disabled_message_is_on_stderr_not_stdout() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let out = snapdir_isolated(cache.path())
.args(["revisions", "--catalog", "none", "--location", &store])
.output()
.expect("run revisions --catalog none");
assert!(
out.status.success(),
"revisions --catalog none must exit 0; got {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
assert!(
out.stdout.is_empty(),
"revisions --catalog none stdout must be empty; got {:?}",
String::from_utf8_lossy(&out.stdout),
);
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("disabl") || stderr.contains("none"),
"disabled message must appear on stderr; got stderr={stderr:?}"
);
}
#[test]
fn catalog_none_locations_disabled_message_is_on_stderr_not_stdout() {
let cache = TempDir::new().unwrap();
let out = snapdir_isolated(cache.path())
.args(["locations", "--catalog", "none"])
.output()
.expect("run locations --catalog none");
assert!(out.status.success(), "locations --catalog none must exit 0");
assert!(
out.stdout.is_empty(),
"locations --catalog none stdout must be empty; got {:?}",
String::from_utf8_lossy(&out.stdout),
);
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("disabl") || stderr.contains("none"),
"disabled message must appear on stderr; got stderr={stderr:?}"
);
}
#[test]
fn catalog_none_ancestors_disabled_message_is_on_stderr_not_stdout() {
let cache = TempDir::new().unwrap();
let fake_id = "0".repeat(64);
let out = snapdir_isolated(cache.path())
.args(["ancestors", "--catalog", "none", "--id", &fake_id])
.output()
.expect("run ancestors --catalog none");
assert!(out.status.success(), "ancestors --catalog none must exit 0");
assert!(
out.stdout.is_empty(),
"ancestors --catalog none stdout must be empty; got {:?}",
String::from_utf8_lossy(&out.stdout),
);
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("disabl") || stderr.contains("none"),
"disabled message must appear on stderr; got stderr={stderr:?}"
);
}
#[test]
fn catalog_cache_dir_flag_round_trip_push_then_revisions() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let explicit_cache = TempDir::new().unwrap();
let explicit_cache_str = explicit_cache.path().to_string_lossy().into_owned();
let src = TempDir::new().unwrap();
build_tree(&src, "cache-dir-round-trip");
let push_id = snapdir_isolated(cache.path())
.args([
"push",
"--store",
&store,
"--cache-dir",
&explicit_cache_str,
&src.path().to_string_lossy(),
])
.output()
.expect("run push --cache-dir");
assert!(
push_id.status.success(),
"push --cache-dir must exit 0; stderr: {}",
String::from_utf8_lossy(&push_id.stderr),
);
let push_id_str = String::from_utf8(push_id.stdout)
.unwrap()
.trim_end()
.to_owned();
assert_eq!(push_id_str.len(), 64, "push must print a 64-hex id");
let default_catalog_explicit = explicit_cache.path().join("default-catalog.redb");
assert!(
default_catalog_explicit.exists(),
"default-catalog.redb must be in the explicit --cache-dir, not the HOME-derived one; \
expected {default_catalog_explicit:?}"
);
let default_catalog_home = cache.path().join("default-catalog.redb");
assert!(
!default_catalog_home.exists(),
"default-catalog.redb must NOT be in HOME when --cache-dir overrides; \
found stray file at {default_catalog_home:?}"
);
let revisions = snapdir_isolated(cache.path())
.args([
"revisions",
"--cache-dir",
&explicit_cache_str,
"--location",
&store,
])
.output()
.expect("run revisions --cache-dir");
assert!(
revisions.status.success(),
"revisions --cache-dir must exit 0; stderr: {}",
String::from_utf8_lossy(&revisions.stderr),
);
let revisions_str = String::from_utf8(revisions.stdout).unwrap();
assert!(
revisions_str.contains(&push_id_str),
"revisions with --cache-dir must list the push id {push_id_str:?}; \
got {revisions_str:?}"
);
}
#[test]
fn catalog_default_locations_end_to_end() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src = TempDir::new().unwrap();
build_tree(&src, "locations-default-catalog");
stdout_ok(
cache.path(),
&["push", "--store", &store, &src.path().to_string_lossy()],
);
let locations = stdout_ok(cache.path(), &["locations"]);
assert!(
locations.contains(store_dir.path().to_str().unwrap()),
"no-flag locations must list the pushed store URI; got {locations:?}"
);
}
#[test]
fn catalog_default_ancestors_end_to_end() {
let cache = TempDir::new().unwrap();
let store_dir = TempDir::new().unwrap();
let store = file_store(store_dir.path());
let src1 = TempDir::new().unwrap();
build_tree(&src1, "ancestors-first");
let id1 = stdout_ok(
cache.path(),
&["push", "--store", &store, &src1.path().to_string_lossy()],
);
assert_eq!(id1.len(), 64);
let src2 = TempDir::new().unwrap();
build_tree(&src2, "ancestors-second (different)");
let id2 = stdout_ok(
cache.path(),
&["push", "--store", &store, &src2.path().to_string_lossy()],
);
assert_eq!(id2.len(), 64);
assert_ne!(id1, id2, "distinct trees must have distinct ids");
let ancestors = stdout_ok(cache.path(), &["ancestors", "--id", &id2]);
assert!(
ancestors.contains(&id1),
"default-catalog ancestors of id2 must mention id1; got {ancestors:?}"
);
}
#[test]
fn catalog_defaults_value_is_full_path_when_default() {
let cache = TempDir::new().unwrap();
let out = snapdir_isolated(cache.path())
.args(["defaults"])
.output()
.expect("run snapdir defaults");
assert!(out.status.success(), "snapdir defaults must exit 0");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("default-catalog.redb"),
"defaults must show the resolved default-catalog.redb path; got:\n{stdout}"
);
assert!(
stdout.contains(cache.path().to_str().unwrap()),
"defaults catalog path must be under the sandboxed cache dir; got:\n{stdout}"
);
}