#![cfg(feature = "mem-repo")]
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use assert_cmd::Command;
use memstead_git_branch::test_support::init_real_mem_repo_from_disk;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
fn make_mem(tmp: &Path) -> PathBuf {
let mem = tmp.join("cli-write");
fs::create_dir_all(&mem).unwrap();
let store = mem.join(".memstead");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("config.json"),
r#"{ "schema": "default@1.0.0" }"#,
)
.unwrap();
init_real_mem_repo_from_disk(tmp, &[(&mem, "cli-write")]);
mem
}
fn memstead() -> Command {
Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}
fn entity_hash(workspace_root: &Path, id: &str) -> String {
let out = memstead()
.current_dir(workspace_root)
.args(["--json", "entity", id])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&out).expect("entity --json output is JSON");
json["_hash"]
.as_str()
.unwrap_or_else(|| panic!("entity --json must carry `_hash`: {json}"))
.to_string()
}
#[test]
fn create_markdown() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Alpha",
"--type",
"spec",
"--section",
"identity=The alpha entity.",
"--section",
"purpose=Verifies CLI write round-trip.",
])
.assert()
.success()
.stdout(contains("Created `cli-write--alpha`"));
}
#[test]
fn create_from_json_file() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let payload = tmp.path().join("payload.json");
fs::write(
&payload,
r#"{
"title": "Gamma",
"entity_type": "spec",
"sections": {
"identity": "Loaded via --from.",
"purpose": "Covers the JSON-input path."
}
}"#,
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["create", "--from"])
.arg(&payload)
.assert()
.success()
.stdout(contains("cli-write--gamma"));
}
#[test]
fn full_round_trip_create_update_delete() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Delta",
"--type",
"spec",
"--section",
"identity=d",
"--section",
"purpose=d",
])
.assert()
.success();
let hash1 = entity_hash(tmp.path(), "cli-write--delta");
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--delta",
"--expected-hash",
&hash1,
"--section",
"purpose=Updated purpose via strict hash.",
])
.assert()
.success()
.stdout(contains("Updated `cli-write--delta`"));
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--delta",
"--auto-hash",
"--append",
"purpose= Appended via auto-hash.",
])
.assert()
.success()
.stdout(contains("Updated `cli-write--delta`"));
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--delta"])
.assert()
.success()
.stdout(contains("Deleted `cli-write--delta`"));
}
#[test]
fn update_requires_hash_by_default() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Eps",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--eps",
"--section",
"purpose=no hash given",
])
.assert()
.code(5)
.stderr(contains("--expected-hash"));
}
#[test]
fn update_wrong_hash_returns_exit_4() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Zeta",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--zeta",
"--expected-hash",
"deadbeef",
"--section",
"purpose=q",
])
.assert()
.code(4)
.stderr(contains("current:"));
}
#[test]
fn update_wrong_hash_json_mode_carries_current() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Omicron",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["--json"])
.args([
"update",
"cli-write--omicron",
"--expected-hash",
"deadbeef",
"--section",
"purpose=q",
])
.assert()
.code(4)
.stdout(contains("\"current\""));
}
#[test]
fn relate_adds_edge_visible_from_relations() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for (title, slug_sections) in [("Src", "identity=s"), ("Dst", "identity=d")] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
slug_sections,
"--section",
"purpose=x",
])
.assert()
.success();
}
memstead()
.current_dir(tmp.path())
.args(["relate", "cli-write--src", "USES", "cli-write--dst"])
.assert()
.success()
.stdout(contains("Added"))
.stdout(contains("USES"));
memstead()
.current_dir(tmp.path())
.args(["relations", "cli-write--src"])
.assert()
.success()
.stdout(contains("cli-write--dst"));
}
#[test]
fn delete_dry_run_does_not_remove_file() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Phi",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--phi", "--dry-run"])
.assert()
.success()
.stdout(contains("Dry-run"));
}
#[test]
fn delete_dry_run_reports_verdict_matching_real_delete() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for (title, sect) in [("Src", "identity=s"), ("Dst", "identity=d")] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
sect,
"--section",
"purpose=x",
])
.assert()
.success();
}
memstead()
.current_dir(tmp.path())
.args(["relate", "cli-write--src", "USES", "cli-write--dst"])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--dst", "--dry-run"])
.assert()
.success()
.stdout(contains("would REFUSE"))
.stdout(contains("HAS_INCOMING_REFS"));
memstead()
.current_dir(tmp.path())
.args(["entity", "cli-write--dst"])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--dst"])
.assert()
.failure();
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--src", "--dry-run"])
.assert()
.success()
.stdout(contains("would PROCEED"));
memstead()
.current_dir(tmp.path())
.args(["delete", "cli-write--src"])
.assert()
.success()
.stdout(contains("Deleted"));
}
#[test]
fn rename_changes_id() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Old Name",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["rename", "cli-write--old-name", "New Name", "--auto-hash"])
.assert()
.success()
.stdout(contains("cli-write--new-name"));
memstead()
.current_dir(tmp.path())
.args(["entity", "cli-write--new-name"])
.assert()
.success()
.stdout(contains("# New Name"));
}
#[test]
fn batch_update_from_file() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for title in ["Bat1", "Bat2"] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
}
let h1 = entity_hash(tmp.path(), "cli-write--bat1");
let h2 = entity_hash(tmp.path(), "cli-write--bat2");
let payload = tmp.path().join("batch.json");
fs::write(
&payload,
serde_json::json!({
"updates": [
{ "id": "cli-write--bat1", "expected_hash": h1,
"sections": { "purpose": "Batched #1" } },
{ "id": "cli-write--bat2", "expected_hash": h2,
"sections": { "purpose": "Batched #2" } }
]
})
.to_string(),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["batch-update", "--from"])
.arg(&payload)
.assert()
.success()
.stdout(contains("applied — 2 item(s) in one commit"));
}
#[test]
fn batch_update_refuses_whole_batch_on_one_bad_entry() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Atomic",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=orig",
])
.assert()
.success();
let h = entity_hash(tmp.path(), "cli-write--atomic");
let payload = tmp.path().join("batch.json");
fs::write(
&payload,
serde_json::json!({
"updates": [
{ "id": "cli-write--atomic", "expected_hash": h,
"sections": { "purpose": "should NOT land" } },
{ "id": "cli-write--ghost", "force": true,
"sections": { "purpose": "missing entity" } }
]
})
.to_string(),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["batch-update", "--from"])
.arg(&payload)
.assert()
.failure()
.code(3)
.stdout(contains("REFUSED"))
.stdout(contains("not_applied"))
.stdout(contains("ENTITY_NOT_FOUND"));
memstead()
.current_dir(tmp.path())
.args(["entity", "cli-write--atomic"])
.assert()
.success()
.stdout(contains("orig"))
.stdout(contains("should NOT land").not());
}
#[test]
fn batch_update_json_refusal_exits_nonzero_with_envelope() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"JsonAtomic",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=orig",
])
.assert()
.success();
let payload = tmp.path().join("batch.json");
fs::write(
&payload,
serde_json::json!({
"updates": [
{ "id": "cli-write--jsonatomic", "expected_hash": "0000000000000000",
"sections": { "purpose": "should NOT land" } }
]
})
.to_string(),
)
.unwrap();
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-update", "--from"])
.arg(&payload)
.assert()
.failure()
.code(4)
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("refused-batch --json stdout must be one JSON document: {e}; stdout:\n{stdout}")
});
assert_eq!(
parsed["code"], "BATCH_REFUSED",
"top-level code must signal the refusal: {parsed}",
);
assert_eq!(
parsed["details"]["applied"], false,
"details carries the BatchResult: {parsed}"
);
assert_eq!(
parsed["details"]["results"][0]["error"]["code"], "HASH_MISMATCH",
"per-entry failure code stays available: {parsed}",
);
}
#[test]
fn batch_update_json_success_unchanged_exits_zero() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"JsonOk",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
let h = entity_hash(tmp.path(), "cli-write--jsonok");
let payload = tmp.path().join("batch.json");
fs::write(
&payload,
serde_json::json!({
"updates": [
{ "id": "cli-write--jsonok", "expected_hash": h,
"sections": { "purpose": "Batched" } }
]
})
.to_string(),
)
.unwrap();
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-update", "--from"])
.arg(&payload)
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
parsed["applied"], true,
"success path keeps the bare BatchResult shape: {parsed}"
);
assert_eq!(parsed["succeeded"], 1);
assert!(parsed["commit_sha"].as_str().is_some_and(|s| !s.is_empty()));
}
#[test]
fn batch_update_commit_note_names_entities_via_include_notes() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for title in ["Note1", "Note2"] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
}
let h1 = entity_hash(tmp.path(), "cli-write--note1");
let h2 = entity_hash(tmp.path(), "cli-write--note2");
let payload = tmp.path().join("batch.json");
fs::write(
&payload,
serde_json::json!({
"updates": [
{ "id": "cli-write--note1", "expected_hash": h1,
"sections": { "purpose": "Batched #1" } },
{ "id": "cli-write--note2", "expected_hash": h2,
"sections": { "purpose": "Batched #2" } }
]
})
.to_string(),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["batch-update", "--from"])
.arg(&payload)
.assert()
.success();
let output = memstead()
.current_dir(tmp.path())
.args([
"--json",
"changes",
"--since",
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
"--include-notes",
])
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
let notes = parsed["notes"]
.as_array()
.expect("notes[] present with --include-notes");
let batch_note = notes
.iter()
.find(|n| {
n["subject"]
.as_str()
.is_some_and(|s| s.contains("batch-update"))
})
.unwrap_or_else(|| panic!("batch-update commit note must be present; notes:\n{parsed}"));
assert!(
batch_note["subject"]
.as_str()
.unwrap()
.contains("(2 entities)"),
"subject keeps the count-string: {batch_note}",
);
let ids: Vec<&str> = batch_note["entity_ids"]
.as_array()
.expect("batch note carries entity_ids")
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
ids.contains(&"cli-write--note1") && ids.contains(&"cli-write--note2"),
"entity_ids must name every entity the batch touched; got: {ids:?}",
);
}
fn entity_hash_filesystem(workspace_root: &Path, id: &str) -> String {
entity_hash(workspace_root, id)
}
fn init_filesystem(tmp: &TempDir, name: &str) {
memstead()
.current_dir(tmp.path())
.args(["init", "--name", name, "--schema", "default@1.0.0"])
.assert()
.success();
}
#[test]
fn create_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Alpha",
"--type",
"spec",
"--section",
"identity=The alpha entity for filesystem CLI tests.",
"--section",
"purpose=Exercise the create-on-filesystem path end to end.",
])
.assert()
.success()
.stdout(contains("Created `demo--alpha`"));
memstead()
.current_dir(tmp.path())
.args(["entity", "demo--alpha"])
.assert()
.success()
.stdout(contains("# Alpha"));
}
#[test]
fn update_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Updatable",
"--type",
"spec",
"--section",
"identity=before",
"--section",
"purpose=before",
])
.assert()
.success();
let hash = entity_hash_filesystem(tmp.path(), "demo--updatable");
memstead()
.current_dir(tmp.path())
.args([
"update",
"demo--updatable",
"--expected-hash",
&hash,
"--section",
"identity=after",
])
.assert()
.success()
.stdout(contains("Updated `demo--updatable`"));
}
#[test]
fn delete_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Doomed",
"--type",
"spec",
"--section",
"identity=now you see me",
"--section",
"purpose=now you don't",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["delete", "demo--doomed"])
.assert()
.success()
.stdout(contains("Deleted `demo--doomed`"));
memstead()
.current_dir(tmp.path())
.args(["entity", "demo--doomed"])
.assert()
.failure()
.code(3);
}
#[test]
fn relate_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
for title in ["Source", "Target"] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
}
memstead()
.current_dir(tmp.path())
.args(["relate", "demo--source", "USES", "demo--target"])
.assert()
.success()
.stdout(contains("Added"));
}
#[test]
fn rename_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Old Name",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
let hash = entity_hash_filesystem(tmp.path(), "demo--old-name");
memstead()
.current_dir(tmp.path())
.args([
"rename",
"demo--old-name",
"New Name",
"--expected-hash",
&hash,
])
.assert()
.success()
.stdout(contains("Renamed"))
.stdout(contains("demo--new-name"));
}
#[test]
fn changes_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Logged",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["changes", "--since", ""])
.assert()
.success()
.stdout(contains("demo--logged"));
}
#[test]
fn export_mem_on_filesystem_requires_version() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
let config_path = tmp.path().join(".memstead").join("config.json");
let body = fs::read_to_string(&config_path).unwrap();
let mut parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
parsed.as_object_mut().unwrap().remove("version");
fs::write(&config_path, serde_json::to_string_pretty(&parsed).unwrap()).unwrap();
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "mem", "-o", "out.mem"])
.assert()
.failure();
}
#[test]
fn export_mem_on_filesystem_writes_archive() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
let config_path = tmp.path().join(".memstead").join("config.json");
fs::write(
&config_path,
r#"{ "format": 1, "name": "demo", "schema": "default@1.0.0", "version": "0.1.0" }"#,
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Packed",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=x",
])
.assert()
.success();
let archive_path = tmp.path().join("out.mem");
memstead()
.current_dir(tmp.path())
.args([
"export",
"--format",
"mem",
"-o",
archive_path.to_str().unwrap(),
])
.assert()
.success();
assert!(
archive_path.is_file(),
"expected {} to exist after export --format mem",
archive_path.display()
);
assert!(
fs::metadata(&archive_path).unwrap().len() > 0,
"archive should be non-empty"
);
}
#[test]
fn mem_set_version_persists_through_filesystem_backend() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "demo", "0.2.0"])
.assert()
.success();
let config_path = tmp.path().join(".memstead").join("config.json");
let body = fs::read_to_string(&config_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
parsed["version"].as_str(),
Some("0.2.0"),
"version must be bumped on disk: {body}"
);
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "demo", "not-a-semver"])
.assert()
.failure();
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "no-such-mem", "1.0.0"])
.assert()
.failure();
}
#[test]
fn export_markdown_on_filesystem_rejects() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "markdown"])
.assert()
.failure()
.stderr(contains("not yet supported"));
}
#[test]
fn batch_update_on_filesystem_surfaces_mem_repo_only() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
let payload = tmp.path().join("updates.json");
fs::write(
&payload,
r#"{ "updates": [{ "id": "demo--anything", "expected_hash": "deadbeef" }] }"#,
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["batch-update", "--from"])
.arg(&payload)
.assert()
.failure()
.stderr(contains("mem-repo-only"));
}
#[test]
fn workspace_dump_on_filesystem_surfaces_mem_repo_only() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args(["workspace", "dump"])
.assert()
.failure()
.stderr(contains("mem-repo-only"));
}
#[test]
fn update_declare_relations_lands_in_one_cli_call() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Source",
"--type",
"spec",
"--section",
"identity=Source entity",
"--section",
"purpose=Source purpose",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Target",
"--type",
"spec",
"--section",
"identity=Target entity",
"--section",
"purpose=Target purpose",
])
.assert()
.success();
let hash = entity_hash_filesystem(tmp.path(), "demo--source");
memstead()
.current_dir(tmp.path())
.args([
"update",
"demo--source",
"--expected-hash",
&hash,
"--declare-relations",
"USES:demo--target",
])
.assert()
.success()
.stdout(contains("Relations declared:"))
.stdout(contains("USES → demo--target"));
memstead()
.current_dir(tmp.path())
.args(["relations", "demo--source"])
.assert()
.success()
.stdout(contains("USES"))
.stdout(contains("demo--target"));
}
#[test]
fn update_declare_relations_rejects_malformed_value() {
let tmp = TempDir::new().unwrap();
init_filesystem(&tmp, "demo");
memstead()
.current_dir(tmp.path())
.args([
"update",
"demo--missing",
"--expected-hash",
"0000000000000000",
"--declare-relations",
"no-separator-here",
])
.assert()
.failure()
.stderr(contains("expected REL_TYPE:TARGET_ID"));
}