#![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\""));
}
fn update_payload(tmp: &Path, name: &str, json: &str) -> PathBuf {
let path = tmp.join(name);
fs::write(&path, json).unwrap();
path
}
fn seed_from_entity(tmp: &Path, title: &str, slug: &str) -> String {
memstead()
.current_dir(tmp)
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=original",
])
.assert()
.success();
entity_hash(tmp, &format!("cli-write--{slug}"))
}
#[test]
fn update_from_honors_dry_run_flag() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let hash = seed_from_entity(tmp.path(), "Fromdry", "fromdry");
let payload = update_payload(
tmp.path(),
"payload.json",
&format!(
r#"{{ "id": "cli-write--fromdry", "expected_hash": "{hash}",
"sections": {{ "purpose": "would change" }} }}"#
),
);
memstead()
.current_dir(tmp.path())
.args(["update", "--from"])
.arg(&payload)
.arg("--dry-run")
.assert()
.success()
.stdout(contains("Dry-run `cli-write--fromdry`"));
assert_eq!(entity_hash(tmp.path(), "cli-write--fromdry"), hash);
}
#[test]
fn update_from_enforces_expected_hash_flag() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let hash = seed_from_entity(tmp.path(), "Fromcas", "fromcas");
let payload = update_payload(
tmp.path(),
"payload.json",
r#"{ "id": "cli-write--fromcas", "sections": { "purpose": "changed" } }"#,
);
memstead()
.current_dir(tmp.path())
.args(["update", "--from"])
.arg(&payload)
.args(["--expected-hash", "deadbeef"])
.assert()
.code(4)
.stderr(contains("current:"));
memstead()
.current_dir(tmp.path())
.args(["update", "--from"])
.arg(&payload)
.args(["--expected-hash", &hash])
.assert()
.success()
.stdout(contains("Updated `cli-write--fromcas`"));
assert_ne!(entity_hash(tmp.path(), "cli-write--fromcas"), hash);
}
#[test]
fn update_from_flag_overrides_file_expected_hash() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let hash = seed_from_entity(tmp.path(), "Fromwins", "fromwins");
let payload = update_payload(
tmp.path(),
"payload.json",
r#"{ "id": "cli-write--fromwins", "expected_hash": "deadbeef",
"sections": { "purpose": "changed" } }"#,
);
memstead()
.current_dir(tmp.path())
.args(["update", "--from"])
.arg(&payload)
.args(["--expected-hash", &hash])
.assert()
.success()
.stdout(contains("Updated `cli-write--fromwins`"));
}
#[test]
fn update_from_refuses_content_flags() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let payload = update_payload(
tmp.path(),
"payload.json",
r#"{ "id": "cli-write--x", "sections": { "purpose": "y" } }"#,
);
for conflicting in [
vec!["--section", "purpose=inline"],
vec!["--append", "purpose=inline"],
vec!["--patch", "purpose=a=>b"],
vec!["--metadata", "status=active"],
vec!["--metadata-unset", "status"],
vec!["--declare-relations", "USES:cli-write--y"],
vec!["--anchor", "{}"],
vec!["--anchor-unset", "{}"],
] {
memstead()
.current_dir(tmp.path())
.args(["update", "--from"])
.arg(&payload)
.args(&conflicting)
.assert()
.code(2)
.stderr(contains("cannot be used with"));
}
}
#[test]
fn update_anchor_batches_merge_and_unset_is_explicit() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Anchored",
"--type",
"spec",
"--section",
"identity=x",
"--section",
"purpose=y",
"--anchor",
r#"{ "artifact": "src/a.rs", "grain": "file", "class": "anchored", "hash": "h-a" }"#,
])
.assert()
.success();
let hash = entity_hash(tmp.path(), "cli-write--anchored");
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--anchored",
"--expected-hash",
&hash,
"--anchor",
r#"{ "artifact": "src/b.rs", "grain": "file", "class": "anchored", "hash": "h-b" }"#,
])
.assert()
.success();
assert_eq!(entity_hash(tmp.path(), "cli-write--anchored"), hash);
for artifact in ["src/a.rs", "src/b.rs"] {
memstead()
.current_dir(tmp.path())
.args(["anchors", "--artifact", artifact])
.assert()
.success()
.stdout(contains("cli-write--anchored"));
}
memstead()
.current_dir(tmp.path())
.args([
"update",
"cli-write--anchored",
"--expected-hash",
&hash,
"--anchor-unset",
r#"{ "artifact": "src/a.rs" }"#,
])
.assert()
.success();
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "anchors", "cli-write--anchored"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: Value = serde_json::from_slice(&out).expect("anchors --json output is JSON");
assert_eq!(json["count"], 1, "only batch B survives the unset: {json}");
assert_eq!(json["anchors"][0]["artifact"], "src/b.rs");
}
#[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"));
}
#[test]
fn review_mark_lifecycle_via_cli() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let assert = memstead()
.current_dir(tmp.path())
.args(["--json", "review-mark", "list"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let roster: Value = serde_json::from_str(stdout.trim()).unwrap();
let entry = &roster["marks"][0];
assert_eq!(entry["mem"], "cli-write");
assert!(entry.get("mark").is_none() || entry["mark"].is_null());
let head = entry["head"].as_str().expect("git-branch head").to_string();
let assert = memstead()
.current_dir(tmp.path())
.args(["--json", "review-mark", "diff", "cli-write"])
.assert()
.failure();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let envelope: Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(envelope["code"], "REVIEW_MARK_NOT_SET");
let assert = memstead()
.current_dir(tmp.path())
.args(["--json", "review-mark", "set", "cli-write", "not-a-sha"])
.assert()
.failure();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let envelope: Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(envelope["code"], "INVALID_CURSOR");
memstead()
.current_dir(tmp.path())
.args([
"review-mark",
"set",
"cli-write",
&head,
"--note",
"reviewed via CLI test",
])
.assert()
.success()
.stdout(contains("Review mark set on `cli-write`"));
memstead()
.current_dir(tmp.path())
.args(["review-mark", "list"])
.assert()
.success()
.stdout(contains("at head (nothing unreviewed)"));
memstead()
.current_dir(tmp.path())
.args(["review-mark", "diff", "cli-write"])
.assert()
.success()
.stdout(contains("nothing unreviewed"));
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Past The Mark",
"--type",
"spec",
"--section",
"identity=Advances the head past the review mark.",
"--section",
"purpose=Review-mark CLI lifecycle.",
])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["review-mark", "diff", "cli-write"])
.assert()
.success()
.stdout(contains("**added** `cli-write--past-the-mark`"));
memstead()
.current_dir(tmp.path())
.args(["review-mark", "list"])
.assert()
.success()
.stdout(contains("unreviewed changes"));
memstead()
.current_dir(tmp.path())
.args(["--json", "review-mark", "clear", "cli-write"])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["review-mark", "list"])
.assert()
.success()
.stdout(contains("no mark"));
}
#[test]
fn shared_from_template_feeds_create_and_update() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
let git_log_note = |n: usize| -> String {
let out = std::process::Command::new("git")
.args([
"--git-dir",
tmp.path().join("mem-repo/.git").to_str().unwrap(),
"log",
&format!("-{n}"),
"--format=%B",
"cli-write",
])
.output()
.expect("git log runs");
String::from_utf8_lossy(&out.stdout).into_owned()
};
let template = tmp.path().join("template.json");
fs::write(
&template,
r#"{
"id": "cli-write--tmpl",
"title": "Tmpl",
"entity_type": "spec",
"sections": {
"identity": "Template identity.",
"purpose": "Template purpose."
},
"note": "file note",
"dry_run": true
}"#,
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["create", "--from", template.to_str().unwrap()])
.assert()
.success()
.stdout(contains("Dry run"));
let template_live = tmp.path().join("template-live.json");
fs::write(
&template_live,
fs::read_to_string(&template)
.unwrap()
.replace("\"dry_run\": true", "\"dry_run\": false"),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["create", "--from", template_live.to_str().unwrap()])
.assert()
.success()
.stdout(contains("Created `cli-write--tmpl`"));
assert!(
git_log_note(1).contains("file note"),
"create --from commits the file's note as provenance: {}",
git_log_note(1)
);
let hash = entity_hash(tmp.path(), "cli-write--tmpl");
let update_doc = tmp.path().join("template-update.json");
fs::write(
&update_doc,
fs::read_to_string(&template_live)
.unwrap()
.replace("Template identity.", "Updated identity."),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args([
"update",
"--from",
update_doc.to_str().unwrap(),
"--expected-hash",
&hash,
])
.assert()
.success()
.stdout(contains("Updated `cli-write--tmpl`"));
assert!(
git_log_note(1).contains("file note"),
"update --from commits the file's note as provenance: {}",
git_log_note(1)
);
let hash = entity_hash(tmp.path(), "cli-write--tmpl");
let update_doc2 = tmp.path().join("template-update2.json");
fs::write(
&update_doc2,
fs::read_to_string(&update_doc)
.unwrap()
.replace("Updated identity.", "Third identity."),
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args([
"update",
"--from",
update_doc2.to_str().unwrap(),
"--expected-hash",
&hash,
"--note",
"flag note wins",
])
.assert()
.success();
let body = git_log_note(1);
assert!(
body.contains("flag note wins") && !body.contains("file note"),
"the --note flag wins over the file's note: {body}"
);
for field in ["auto_hash", "force"] {
let bad = tmp.path().join(format!("bad-{field}.json"));
fs::write(
&bad,
fs::read_to_string(&update_doc).unwrap().replace(
"\"dry_run\": false",
&format!("\"dry_run\": false, \"{field}\": true"),
),
)
.unwrap();
for cmd in ["create", "update"] {
memstead()
.current_dir(tmp.path())
.args([cmd, "--from", bad.to_str().unwrap()])
.assert()
.failure()
.stderr(contains("unknown field"));
}
}
}