#![cfg(feature = "mem-repo")]
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use memstead_git_branch::test_support::init_real_mem_repo_from_disk;
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 parse_json(stdout_bytes: &[u8]) -> Value {
let body = std::str::from_utf8(stdout_bytes).expect("stdout must be UTF-8");
serde_json::from_str(body.trim()).unwrap_or_else(|e| {
panic!("--json output must parse as JSON: {e}\n--- stdout ---\n{body}\n--- end ---")
})
}
fn tree_digest(root: &Path) -> BTreeMap<String, Vec<u8>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
for entry in fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
walk(root, &path, out);
} else {
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.to_string();
out.insert(rel, fs::read(&path).unwrap());
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
fn assert_trees_identical(before: &BTreeMap<String, Vec<u8>>, after: &BTreeMap<String, Vec<u8>>) {
for (path, bytes) in before {
match after.get(path) {
None => panic!("rehearsal DELETED {path}"),
Some(b) if b != bytes => panic!("rehearsal MODIFIED {path}"),
Some(_) => {}
}
}
for path in after.keys() {
assert!(before.contains_key(path), "rehearsal CREATED {path}");
}
}
fn warm_up(ws: &Path) {
memstead()
.current_dir(ws)
.args(["--json", "health"])
.assert()
.success();
}
fn refusal_envelope(ws: &Path, args: &[&str]) -> Value {
let out = memstead()
.current_dir(ws)
.args(args)
.assert()
.failure()
.get_output()
.stdout
.clone();
let v = parse_json(&out);
serde_json::json!({
"code": v["code"],
"message": v["message"],
"details": v["details"],
})
}
#[test]
fn create_rehearsal_refuses_identically_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
warm_up(tmp.path());
let illegal = |dry: bool| {
let mut args = vec!["--json", "create", "--title", "Alpha", "--type", "spec"];
args.extend(["--section", "bogus-section=nope"]);
if dry {
args.push("--dry-run");
}
args.into_iter().map(String::from).collect::<Vec<_>>()
};
let rehearsed = refusal_envelope(
tmp.path(),
&illegal(true).iter().map(String::as_str).collect::<Vec<_>>(),
);
let real = refusal_envelope(
tmp.path(),
&illegal(false)
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
);
assert_eq!(
rehearsed, real,
"identical typed refusal, rehearsed vs real"
);
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"create",
"--title",
"Alpha",
"--type",
"spec",
"--section",
"identity=The alpha entity.",
"--section",
"purpose=Rehearsal contract test.",
"--dry-run",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
assert_eq!(body["id"], "cli-write--alpha", "prospective id reported");
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"create",
"--title",
"Alpha",
"--type",
"spec",
"--section",
"identity=The alpha entity.",
"--section",
"purpose=Rehearsal contract test.",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_ne!(real_body["commit_sha"], "", "the real create commits");
assert_eq!(real_body["id"], body["id"], "same prospective identity");
}
#[test]
fn update_rehearsal_refuses_identically_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Target",
"--type",
"spec",
"--section",
"identity=Before.",
"--section",
"purpose=Rehearsal target.",
])
.assert()
.success();
warm_up(tmp.path());
let hash = {
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "entity", "cli-write--target"])
.assert()
.success()
.get_output()
.stdout
.clone();
parse_json(&out)["_hash"].as_str().unwrap().to_string()
};
let illegal = |dry: bool| {
let mut v = vec![
"--json".to_string(),
"update".to_string(),
"cli-write--target".to_string(),
"--expected-hash".to_string(),
hash.clone(),
"--section".to_string(),
"bogus-section=nope".to_string(),
];
if dry {
v.push("--dry-run".to_string());
}
v
};
let rehearsed = refusal_envelope(
tmp.path(),
&illegal(true).iter().map(String::as_str).collect::<Vec<_>>(),
);
let real = refusal_envelope(
tmp.path(),
&illegal(false)
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
);
assert_eq!(
rehearsed, real,
"identical typed refusal, rehearsed vs real"
);
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"update",
"cli-write--target",
"--expected-hash",
&hash,
"--section",
"identity=After.",
"--dry-run",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
assert_eq!(
body["_hash"].as_str().unwrap(),
hash,
"dry-run `_hash` is the CURRENT hash (the next expected_hash)"
);
let prospective = body["prospective_hash"].as_str().unwrap().to_string();
assert_ne!(prospective, hash);
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"update",
"cli-write--target",
"--expected-hash",
&hash,
"--section",
"identity=After.",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_ne!(real_body["commit_sha"], "");
assert_ne!(
real_body["_hash"].as_str().unwrap(),
hash,
"the real update moved the hash"
);
}
#[test]
fn relate_rehearsal_reports_would_be_stub_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"Source",
"--type",
"spec",
"--section",
"identity=Src.",
"--section",
"purpose=Rehearsal source.",
])
.assert()
.success();
warm_up(tmp.path());
let rehearsed = refusal_envelope(
tmp.path(),
&[
"--json",
"relate",
"cli-write--source",
"USES",
"not a valid id",
"--dry-run",
],
);
let real = refusal_envelope(
tmp.path(),
&[
"--json",
"relate",
"cli-write--source",
"USES",
"not a valid id",
],
);
assert_eq!(
rehearsed, real,
"identical typed refusal, rehearsed vs real"
);
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"relate",
"cli-write--source",
"USES",
"cli-write--ghost",
"--dry-run",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
let warnings = serde_json::to_string(&body["warnings"]).unwrap();
assert!(
warnings.contains("AUTO_STUB_CREATED"),
"would-be stub must be reported: {warnings}"
);
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
memstead()
.current_dir(tmp.path())
.args(["--json", "entity", "cli-write--ghost"])
.assert()
.failure();
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"relate",
"cli-write--source",
"USES",
"cli-write--ghost",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_ne!(real_body["commit_sha"], "", "the real relate commits");
}
#[test]
fn batch_create_rehearsal_reports_receipt_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"creates": [
{ "title": "Alpha", "entity_type": "spec",
"sections": { "identity": "A.", "purpose": "P." },
"relations": [ { "to": "cli-write--beta", "type": "USES" } ] },
{ "title": "Beta", "entity_type": "spec",
"sections": { "identity": "B.", "purpose": "P." } }
]
})
.to_string(),
)
.unwrap();
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"batch-create",
"--from",
batch_file.to_str().unwrap(),
"--dry-run",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["applied"], true, "{body}");
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
assert_eq!(body["succeeded"], 2);
assert_eq!(body["results"][0]["action"], "created");
assert_eq!(body["results"][0]["id"], "cli-write--alpha");
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"batch-create",
"--from",
batch_file.to_str().unwrap(),
])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_eq!(real_body["applied"], true);
assert_ne!(real_body["commit_sha"], "", "the real batch commits");
}
#[test]
fn batch_create_rehearsal_refuses_identically_to_real() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"creates": [
{ "title": "Fine", "entity_type": "spec",
"sections": { "identity": "A.", "purpose": "P." } },
{ "title": "Bad\tTitle", "entity_type": "spec",
"sections": { "identity": "B.", "purpose": "P." } }
]
})
.to_string(),
)
.unwrap();
let path = batch_file.to_str().unwrap();
let rehearsed = refusal_envelope(
tmp.path(),
&["--json", "batch-create", "--from", path, "--dry-run"],
);
let real = refusal_envelope(tmp.path(), &["--json", "batch-create", "--from", path]);
assert_eq!(rehearsed, real, "identical per-entry refusals");
memstead()
.current_dir(tmp.path())
.args(["--json", "entity", "cli-write--fine"])
.assert()
.failure();
}
#[test]
fn batch_update_rehearsal_reports_receipt_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for title in ["One", "Two"] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=Before.",
"--section",
"purpose=P.",
])
.assert()
.success();
}
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"updates": [
{ "id": "cli-write--one", "auto_hash": true,
"sections": { "identity": "After one." } },
{ "id": "cli-write--two", "auto_hash": true,
"sections": { "identity": "After two." } }
]
})
.to_string(),
)
.unwrap();
let path = batch_file.to_str().unwrap();
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-update", "--from", path, "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["applied"], true, "{body}");
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
assert!(
body["results"]
.as_array()
.unwrap()
.iter()
.all(|r| r["action"] == "updated"),
"{body}"
);
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-update", "--from", path])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_eq!(real_body["applied"], true);
assert_ne!(real_body["commit_sha"], "");
}
#[test]
fn batch_update_rehearsal_refuses_identically_to_real() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"One",
"--type",
"spec",
"--section",
"identity=X.",
"--section",
"purpose=P.",
])
.assert()
.success();
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"updates": [
{ "id": "cli-write--one", "expected_hash": "wrong-hash",
"sections": { "identity": "Y." } },
{ "id": "cli-write--missing", "force": true,
"sections": { "identity": "Z." } }
]
})
.to_string(),
)
.unwrap();
let path = batch_file.to_str().unwrap();
let rehearsed = refusal_envelope(
tmp.path(),
&["--json", "batch-update", "--from", path, "--dry-run"],
);
let real = refusal_envelope(tmp.path(), &["--json", "batch-update", "--from", path]);
assert_eq!(rehearsed, real, "identical per-entry refusals");
}
#[test]
fn batch_relate_rehearsal_reports_receipt_and_leaves_workspace_byte_identical() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
for title in ["One", "Two"] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
title,
"--type",
"spec",
"--section",
"identity=X.",
"--section",
"purpose=P.",
])
.assert()
.success();
}
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"relates": [
{ "from": "cli-write--one", "type": "USES", "to": "cli-write--two" },
{ "from": "cli-write--one", "type": "USES", "to": "cli-write--ghost" }
]
})
.to_string(),
)
.unwrap();
let path = batch_file.to_str().unwrap();
let before = tree_digest(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-relate", "--from", path, "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let body = parse_json(&out);
assert_eq!(body["applied"], true, "{body}");
assert_eq!(body["commit_sha"], "", "marker form: empty commit_sha");
assert!(
body["results"]
.as_array()
.unwrap()
.iter()
.all(|r| r["action"] == "added"),
"{body}"
);
let after = tree_digest(tmp.path());
assert_trees_identical(&before, &after);
memstead()
.current_dir(tmp.path())
.args(["--json", "entity", "cli-write--ghost"])
.assert()
.failure();
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "batch-relate", "--from", path])
.assert()
.success()
.get_output()
.stdout
.clone();
let real_body = parse_json(&out);
assert_eq!(real_body["applied"], true);
assert_ne!(real_body["commit_sha"], "");
}
#[test]
fn batch_relate_rehearsal_refuses_identically_to_real() {
let tmp = TempDir::new().unwrap();
let _mem = make_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"create",
"--title",
"One",
"--type",
"spec",
"--section",
"identity=X.",
"--section",
"purpose=P.",
])
.assert()
.success();
warm_up(tmp.path());
let batch_file = tmp.path().join("batch.json");
fs::write(
&batch_file,
serde_json::json!({
"relates": [
{ "from": "cli-write--one", "type": "USES", "to": "not a valid id" }
]
})
.to_string(),
)
.unwrap();
let path = batch_file.to_str().unwrap();
let rehearsed = refusal_envelope(
tmp.path(),
&["--json", "batch-relate", "--from", path, "--dry-run"],
);
let real = refusal_envelope(tmp.path(), &["--json", "batch-relate", "--from", path]);
assert_eq!(rehearsed, real, "identical refusals");
}
fn quarantined_workspace(tmp: &TempDir) -> PathBuf {
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
fs::create_dir_all(ws.join("plenum")).unwrap();
let mounts = ws.join(".memstead").join("state").join("mounts.json");
fs::create_dir_all(mounts.parent().unwrap()).unwrap();
fs::write(
&mounts,
r#"{
"format": "memstead-mounts-3",
"mounts": [
{ "mem": "plenum", "schema": "ghost@1.0.0", "storage": { "type": "folder", "path": "plenum" }, "capability": "write", "lifecycle": "eager", "cross_linkable": true }
]
}"#,
)
.unwrap();
ws
}
#[test]
fn rehearsal_against_quarantined_mem_refuses_identically_to_real() {
let tmp = TempDir::new().unwrap();
let ws = quarantined_workspace(&tmp);
let illegal = |dry: bool| {
let mut v = vec![
"--json".to_string(),
"create".to_string(),
"--title".to_string(),
"Doc".to_string(),
"--type".to_string(),
"note".to_string(),
"--mem".to_string(),
"plenum".to_string(),
];
if dry {
v.push("--dry-run".to_string());
}
v
};
let rehearsed = refusal_envelope(
&ws,
&illegal(true).iter().map(String::as_str).collect::<Vec<_>>(),
);
let real = refusal_envelope(
&ws,
&illegal(false)
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
);
assert_eq!(rehearsed, real, "identical quarantine refusal");
assert_eq!(rehearsed["code"], "MEM_QUARANTINED", "{rehearsed}");
}