#![cfg(feature = "mem-repo")]
use std::fs;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use assert_cmd::Command;
use memstead_git_branch::test_support::init_real_mem_repo_from_disk;
use predicates::str::contains;
use tempfile::TempDir;
fn cache_guard() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn commit_mem_branch(root: &Path, mem_name: &str, entries: &[(&str, &str)]) {
use memstead_git_branch::storage::MemWriter;
use memstead_git_branch::storage::git_tree::GitTreeMemWriter;
use memstead_git_branch::vcs::CommitContext;
let gitdir = root.join("mem-repo").join(".git");
let writer = GitTreeMemWriter::new(gitdir, format!("refs/heads/{mem_name}"));
for (rel, content) in entries {
writer
.write_entity(Path::new(rel), content.as_bytes())
.unwrap();
}
writer.commit("seed", &CommitContext::internal()).unwrap();
}
fn make_sender_mem(root: &Path) -> std::path::PathBuf {
let dir = root.join("sender-mem");
fs::create_dir_all(&dir).unwrap();
let store = dir.join(".memstead");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("config.json"),
r#"{
"version": "1.0.0",
"description": "Fixture write mem used to exercise export → install",
"schema": "default@1.0.0"
}"#,
)
.unwrap();
init_real_mem_repo_from_disk(root, &[(&dir, "sender-mem")]);
let alpha_body = r#"---
type: spec
created_date: 2026-01-01
last_modified: 2026-01-01
level: M0
---
# Alpha
## Identity
First entity in the sender mem. Used to verify a read mem's entities
become discoverable from a second project after install.
## Purpose
Exercises the export → install round trip via the CLI.
"#;
fs::write(dir.join("alpha.md"), alpha_body).unwrap();
let beta_body = r#"---
type: spec
created_date: 2026-01-02
last_modified: 2026-01-02
level: M0
---
# Beta
## Identity
Second entity in the sender mem.
## Purpose
Provides a second hit for the search test below.
"#;
fs::write(dir.join("beta.md"), beta_body).unwrap();
commit_mem_branch(
root,
"sender-mem",
&[("alpha.md", alpha_body), ("beta.md", beta_body)],
);
dir
}
fn make_receiver_mem(root: &Path) -> std::path::PathBuf {
let dir = root.join("receiver-mem");
fs::create_dir_all(&dir).unwrap();
let store = dir.join(".memstead");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("config.json"),
r#"{ "version": "0.1.0", "schema": "default@1.0.0" }"#,
)
.unwrap();
init_real_mem_repo_from_disk(root, &[(&dir, "receiver-mem")]);
dir
}
fn memstead() -> Command {
Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}
#[test]
fn export_markdown_default_runs() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("export")
.assert()
.success()
.stdout(contains("Export — markdown"));
}
#[test]
fn export_markdown_workspace_wide_reports_skipped_git_branch_mounts() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("export")
.assert()
.success()
.stdout(contains("Export — markdown"))
.stdout(contains("Skipped mounts"))
.stdout(contains("sender-mem"))
.stdout(contains("git-branch"))
.stdout(contains("backend_does_not_support_markdown_export"));
}
#[test]
fn export_markdown_per_mem_refuses_on_git_branch_backend() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "markdown", "--mem", "sender-mem"])
.assert()
.failure()
.stderr(contains("MARKDOWN_EXPORT_UNSUPPORTED_BACKEND"))
.stderr(contains("git-branch"))
.stderr(contains("--format mem"));
}
#[test]
fn export_markdown_per_mem_refuses_with_json_envelope() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
let assert = memstead()
.current_dir(tmp.path())
.args([
"--json",
"export",
"--format",
"markdown",
"--mem",
"sender-mem",
])
.assert()
.failure();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let env: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("expected JSON error envelope on stdout; got:\n{stdout}\n({e})")
});
assert_eq!(env["code"], "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND");
assert_eq!(env["details"]["mem"], "sender-mem");
assert_eq!(env["details"]["active_backend"], "git-branch");
assert_eq!(
env["details"]["supported_backends"],
serde_json::json!(["folder"])
);
}
#[test]
fn export_markdown_workspace_wide_json_carries_skipped_mounts() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
let assert = memstead()
.current_dir(tmp.path())
.args(["--json", "export"])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let env: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("expected JSON envelope on stdout; got:\n{stdout}\n({e})"));
assert_eq!(env["written"], 0);
assert_eq!(env["unchanged"], 0);
let skipped = env["skipped_mounts"].as_array().expect(
"skipped_mounts must be a JSON array under the workspace-wide partial-success shape",
);
assert_eq!(skipped.len(), 1, "one git-branch-mount in this fixture");
assert_eq!(skipped[0]["mem"], "sender-mem");
assert_eq!(skipped[0]["active_backend"], "git-branch");
assert_eq!(
skipped[0]["reason"],
"backend_does_not_support_markdown_export"
);
}
#[test]
fn export_mem_produces_memstead_archive() {
let _guard = cache_guard().lock().unwrap();
let tmp = TempDir::new().unwrap();
let mem = make_sender_mem(tmp.path());
let output_path = mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "mem", "-o"])
.arg(&output_path)
.assert()
.success()
.stdout(contains("Exported `sender-mem` v1.0.0"))
.stdout(contains("sender-mem-1.0.0.mem"));
assert!(output_path.exists(), "archive must be written to disk");
assert!(
fs::metadata(&output_path).unwrap().len() > 0,
"archive must not be empty"
);
}
#[test]
fn export_mem_fails_without_version() {
let tmp = TempDir::new().unwrap();
let mem = tmp.path().join("unversioned");
let memstead_dir = mem.join(".memstead");
fs::create_dir_all(&memstead_dir).unwrap();
fs::write(
memstead_dir.join("config.json"),
r#"{ "schema": "default@1.0.0" }"#,
)
.unwrap();
init_real_mem_repo_from_disk(tmp.path(), &[(&mem, "unversioned")]);
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "mem", "-o"])
.arg(mem.join("out.mem"))
.assert()
.failure()
.stderr(contains("version"));
}
#[test]
fn mem_set_version_persists_through_mem_repo_backend() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "sender-mem", "1.5.0"])
.assert()
.success()
.stdout(contains("New version: 1.5.0"));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "sender-mem", "2.0.0"])
.assert()
.success()
.stdout(contains("Old version: 1.5.0"))
.stdout(contains("New version: 2.0.0"));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-version", "sender-mem", "not-a-semver"])
.assert()
.failure();
}
#[test]
fn mem_set_description_persists_and_clears() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-description",
"sender-mem",
"Typed knowledge about senders.",
])
.assert()
.success()
.stdout(contains("New: Typed knowledge about senders."));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-description", "sender-mem", "Sharper card text."])
.assert()
.success()
.stdout(contains("Old: Typed knowledge about senders."))
.stdout(contains("New: Sharper card text."));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-description", "sender-mem", ""])
.assert()
.success()
.stdout(contains("New: <cleared>"));
}
#[test]
fn mem_set_version_note_lands_on_commit() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-version",
"sender-mem",
"1.5.0",
"--note",
"bump for the auth release",
])
.assert()
.success()
.stdout(contains("New version: 1.5.0"));
let gitdir = tmp.path().join("mem-repo").join(".git");
let repo = gix::open(&gitdir).expect("open mem-repo gitdir");
let commit = repo
.find_reference("refs/heads/__MEMSTEAD")
.expect("find __MEMSTEAD")
.into_fully_peeled_id()
.expect("peel __MEMSTEAD to id")
.object()
.expect("load __MEMSTEAD object")
.try_into_commit()
.expect("__MEMSTEAD is a commit");
let message = commit.message_raw().expect("commit message").to_string();
assert!(
message.contains("bump for the auth release"),
"version-bump note must land on the __MEMSTEAD commit body; got:\n{message}"
);
}
#[test]
fn mem_set_sync_state_persists_and_reports() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-sync-state",
"sender-mem",
"engine-graph/source-files",
"cafef00d",
])
.assert()
.success()
.stdout(contains("sync state set"))
.stdout(contains("engine-graph/source-files"));
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-sync-state",
"sender-mem",
"engine-graph/source-files",
"f00dcafe",
])
.assert()
.success()
.stdout(contains("sync state overwrote"));
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-sync-state",
"sender-mem",
"engine-graph/source-files",
"",
])
.assert()
.success()
.stdout(contains("sync state cleared"));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-sync-state", "no-such-mem", "k", "v"])
.assert()
.failure();
}
#[test]
fn install_round_trip_entities_discoverable() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
assert!(archive.exists(), "export must produce the archive");
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("Installed `sender-mem`"));
memstead()
.current_dir(receiver.path())
.args(["search", "Alpha"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("sender-mem--alpha"));
memstead()
.current_dir(receiver.path())
.args(["entity", "sender-mem--alpha"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("# Alpha"));
}
#[test]
fn install_refuses_when_archive_shadows_writable() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
let receiver_mem_dir = receiver.path().join("sender-mem");
fs::create_dir_all(&receiver_mem_dir).unwrap();
let memstead_dir = receiver_mem_dir.join(".memstead");
fs::create_dir_all(&memstead_dir).unwrap();
fs::write(
memstead_dir.join("config.json"),
r#"{ "version": "0.1.0", "schema": "default@1.0.0" }"#,
)
.unwrap();
init_real_mem_repo_from_disk(receiver.path(), &[(&receiver_mem_dir, "sender-mem")]);
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.failure()
.stderr(contains("READ_MEM_SHADOWS_WRITABLE"))
.stderr(contains("already exists as a writable mount"));
}
#[test]
fn install_is_idempotent() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("copied into cache"))
.stdout(contains("registered as a workspace-level read-only mount"));
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("already in cache"))
.stdout(contains(
"already registered as a read-mem mount (unchanged)",
));
}
#[test]
fn workspace_dump_and_type_resolve_for_ro_mount_after_install() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
let dump = memstead()
.current_dir(receiver.path())
.args(["workspace", "dump", "--json"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.get_output()
.stdout
.clone();
let dump_str = String::from_utf8(dump).unwrap();
let dump_json: serde_json::Value =
serde_json::from_str(&dump_str).expect("workspace dump must emit valid JSON");
let mems = dump_json["mems"]
.as_array()
.expect("dump must carry a mems array");
let ro_entry = mems
.iter()
.find(|v| v["name"] == "sender-mem")
.expect("installed sender-mem must appear in dump");
assert_eq!(
ro_entry["capability"], "read_only",
"RO mount must carry the `read_only` capability marker; got {ro_entry}"
);
assert_eq!(
ro_entry["schema_ref"], "default@1.0.0",
"RO mount's schema_ref must match the archive's bundled pin (pre-fix \
the dump would have crashed before reaching this assertion); got {ro_entry}"
);
memstead()
.current_dir(receiver.path())
.args(["type", "--mem", "sender-mem"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
}
#[test]
fn mem_title_and_subject_lifecycle() {
let tmp = TempDir::new().unwrap();
let _mem = make_sender_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-title",
"sender-mem",
"Einrichtungsbezogene Impfpflicht Deutschland",
])
.assert()
.success()
.stdout(contains("title updated"));
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "mem", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let row = &json["mems"][0];
assert_eq!(row["name"], "sender-mem");
assert_eq!(row["title"], "Einrichtungsbezogene Impfpflicht Deutschland");
memstead()
.current_dir(tmp.path())
.args(["mem", "list"])
.assert()
.success()
.stdout(contains(
"Einrichtungsbezogene Impfpflicht Deutschland (`sender-mem`)",
));
memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--mem",
"Einrichtungsbezogene Impfpflicht Deutschland",
])
.assert()
.failure()
.stdout(contains("UNKNOWN_MEM"));
memstead()
.current_dir(tmp.path())
.args(["--json", "search", "--mem", "sender-mem"])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-subject",
"sender-mem",
"--scope",
"Die Impfpflicht in Einrichtungen",
"--method",
"Primärquellen, händisch geprüft",
"--exclusion",
"Länderverordnungen nach 2023",
"--exclusion",
"Presseberichte",
])
.assert()
.success()
.stdout(contains("subject updated"));
memstead()
.current_dir(tmp.path())
.args([
"mem",
"set-subject",
"sender-mem",
"--method",
"nur Methode",
])
.assert()
.failure()
.stderr(contains("--scope"));
memstead()
.current_dir(tmp.path())
.args(["--json", "mem", "set-subject", "sender-mem"])
.assert()
.success()
.stdout(predicates::prelude::PredicateBooleanExt::not(contains(
"\"new_subject\"",
)));
memstead()
.current_dir(tmp.path())
.args(["mem", "set-title", "sender-mem", ""])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.args(["mem", "list"])
.assert()
.success()
.stdout(contains("- `sender-mem`"));
}
const DELTA_IDENTITY: &str = "Multi-section fixture entity for the JSON bulk export.\n\nSecond paragraph with `inline code` and *emphasis* to make the\nround-trip non-trivial.";
const DELTA_PURPOSE: &str = "Verifies byte-faithful section content, metadata, relationships\n(including a cross-mem edge), and `_hash` in one document.";
const GAMMA_IDENTITY: &str = "Cross-mem edge target living in the second writable mem.";
fn make_json_export_workspace(root: &Path) {
let sender = root.join("sender-mem");
let second = root.join("second-mem");
for dir in [&sender, &second] {
let store = dir.join(".memstead");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("config.json"),
r#"{ "version": "1.0.0", "schema": "default@1.0.0" }"#,
)
.unwrap();
}
let delta_body = format!(
"---\ntype: spec\ncreated_date: 2026-02-01\nlast_modified: 2026-02-01\nlevel: M1\n---\n# Delta\n\n## Identity\n\n{DELTA_IDENTITY}\n\n## Purpose\n\n{DELTA_PURPOSE}\n\n## Relationships\n\n- **USES**: [[second-mem--gamma]]\n"
);
let gamma_body = format!(
"---\ntype: spec\ncreated_date: 2026-02-02\nlast_modified: 2026-02-02\nlevel: M0\n---\n# Gamma\n\n## Identity\n\n{GAMMA_IDENTITY}\n"
);
fs::write(sender.join("delta.md"), &delta_body).unwrap();
fs::write(second.join("gamma.md"), &gamma_body).unwrap();
init_real_mem_repo_from_disk(root, &[(&sender, "sender-mem"), (&second, "second-mem")]);
commit_mem_branch(root, "sender-mem", &[("delta.md", &delta_body)]);
commit_mem_branch(root, "second-mem", &[("gamma.md", &gamma_body)]);
}
fn export_json(root: &Path, extra: &[&str]) -> serde_json::Value {
let out = memstead()
.current_dir(root)
.args(["export", "--format", "json"])
.args(extra)
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice(&out).expect("--format json must emit one valid JSON document")
}
#[test]
fn export_json_git_branch_round_trips_values() {
let tmp = TempDir::new().unwrap();
make_json_export_workspace(tmp.path());
let doc = export_json(tmp.path(), &["--mem", "sender-mem"]);
assert_eq!(doc["format"], "memstead-export/v1");
let group = &doc["mems"]["sender-mem"];
assert_eq!(group["schema"], "default@1.0.0");
assert_eq!(group["read_only"], false);
assert_eq!(group["entity_count"], 1);
let entities = group["entities"].as_array().expect("entities array");
let delta = entities
.iter()
.find(|e| e["id"] == "sender-mem--delta")
.expect("delta must be exported");
assert_eq!(delta["type"], "spec");
assert_eq!(delta["title"], "Delta");
assert_eq!(delta["mem"], "sender-mem");
assert_eq!(delta["metadata"]["level"], "M1");
assert_eq!(delta["metadata"]["created_date"], "2026-02-01");
assert_eq!(
delta["sections"]["identity"], DELTA_IDENTITY,
"identity section must round-trip byte-faithfully; got {}",
delta["sections"]["identity"]
);
assert_eq!(delta["sections"]["purpose"], DELTA_PURPOSE);
let rels = delta["relationships"].as_array().expect("relationships");
let uses = rels
.iter()
.find(|r| r["rel_type"] == "USES")
.expect("cross-mem USES edge must be exported");
assert_eq!(uses["target"], "second-mem--gamma");
let hash = delta["_hash"].as_str().expect("_hash string");
assert_eq!(hash.len(), 16, "truncated sha-256 hex expected; got {hash}");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn export_json_workspace_wide_groups_every_writable_mem() {
let tmp = TempDir::new().unwrap();
make_json_export_workspace(tmp.path());
let doc = export_json(tmp.path(), &[]);
let mems = doc["mems"].as_object().expect("mems object");
assert!(mems.contains_key("sender-mem"), "sender-mem group missing");
assert!(mems.contains_key("second-mem"), "second-mem group missing");
let gamma = mems["second-mem"]["entities"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "second-mem--gamma")
.expect("gamma must be exported in its own group");
assert_eq!(gamma["title"], "Gamma");
assert_eq!(gamma["sections"]["identity"], GAMMA_IDENTITY);
for (name, group) in mems {
for e in group["entities"].as_array().unwrap() {
assert!(
e.get("_stub_kind").is_none(),
"stub leaked into export group `{name}`: {e}"
);
}
}
}
#[test]
fn export_json_read_only_mount_named_vs_default() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
let wide = memstead()
.current_dir(receiver.path())
.args(["export", "--format", "json"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.get_output()
.stdout
.clone();
let wide: serde_json::Value = serde_json::from_slice(&wide).unwrap();
let mems = wide["mems"].as_object().unwrap();
assert!(
!mems.contains_key("sender-mem"),
"read-only mount must be absent from the workspace-wide default; got {:?}",
mems.keys().collect::<Vec<_>>()
);
assert!(mems.contains_key("receiver-mem"));
let named = memstead()
.current_dir(receiver.path())
.args(["export", "--format", "json", "--mem", "sender-mem"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.get_output()
.stdout
.clone();
let named: serde_json::Value = serde_json::from_slice(&named).unwrap();
let group = &named["mems"]["sender-mem"];
assert_eq!(group["read_only"], true);
let ids: Vec<&str> = group["entities"]
.as_array()
.unwrap()
.iter()
.map(|e| e["id"].as_str().unwrap())
.collect();
assert!(
ids.contains(&"sender-mem--alpha") && ids.contains(&"sender-mem--beta"),
"named RO export must carry the mount's entities; got {ids:?}"
);
}
#[test]
fn export_json_leaves_mem_repo_refs_untouched() {
let tmp = TempDir::new().unwrap();
make_json_export_workspace(tmp.path());
let gitdir = tmp.path().join("mem-repo").join(".git");
let refs = |label: &str| -> String {
let out = std::process::Command::new("git")
.arg("--git-dir")
.arg(&gitdir)
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.output()
.unwrap_or_else(|e| panic!("git for-each-ref ({label}): {e}"));
String::from_utf8(out.stdout).unwrap()
};
let before = refs("before");
assert!(
before.contains("refs/heads/sender-mem"),
"fixture must have seeded the branch; got: {before}"
);
let _ = export_json(tmp.path(), &[]);
let after = refs("after");
assert_eq!(before, after, "export must not move any mem-repo ref");
}
#[test]
fn export_json_folder_mem_round_trips_and_mutates_nothing() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let store = root.join(".memstead");
fs::create_dir_all(store.join("state")).unwrap();
fs::write(
store.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
fs::write(
store.join("state").join("mounts.json"),
r#"{"format":"memstead-mounts-3","mounts":[{"mem":"engine","schema":"default@1.0.0","storage":{"type":"folder","path":"engine-mem"},"capability":"write","lifecycle":"eager","cross_linkable":false}]}"#,
)
.unwrap();
let mem_dir = root.join("engine-mem");
fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0"}"#,
)
.unwrap();
let epsilon_body = format!(
"---\ntype: spec\ncreated_date: 2026-02-03\nlast_modified: 2026-02-03\nlevel: M0\n---\n# Epsilon\n\n## Identity\n\n{DELTA_IDENTITY}\n\n## Purpose\n\n{DELTA_PURPOSE}\n"
);
fs::write(mem_dir.join("epsilon.md"), &epsilon_body).unwrap();
let snapshot = |label: &str| -> Vec<(String, Vec<u8>)> {
let mut files: Vec<(String, Vec<u8>)> = walk_files(&mem_dir)
.into_iter()
.map(|p| {
let rel = p
.strip_prefix(&mem_dir)
.unwrap()
.to_string_lossy()
.into_owned();
let bytes = fs::read(&p).unwrap_or_else(|e| panic!("read {p:?} ({label}): {e}"));
(rel, bytes)
})
.collect();
files.sort();
files
};
let before = snapshot("before");
let doc = export_json(root, &["--mem", "engine"]);
let group = &doc["mems"]["engine"];
assert_eq!(group["read_only"], false);
let epsilon = group["entities"]
.as_array()
.unwrap()
.iter()
.find(|e| e["id"] == "engine--epsilon")
.expect("epsilon must be exported from the folder mount");
assert_eq!(epsilon["title"], "Epsilon");
assert_eq!(epsilon["sections"]["identity"], DELTA_IDENTITY);
assert_eq!(epsilon["sections"]["purpose"], DELTA_PURPOSE);
let after = snapshot("after");
assert_eq!(before, after, "export must not change any mem-content file");
}
fn walk_files(dir: &Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in fs::read_dir(dir).unwrap() {
let p = entry.unwrap().path();
if p.is_dir() {
out.extend(walk_files(&p));
} else {
out.push(p);
}
}
out
}
#[test]
fn export_json_refusals_are_typed() {
let tmp = TempDir::new().unwrap();
make_json_export_workspace(tmp.path());
let unknown = memstead()
.current_dir(tmp.path())
.args(["export", "--format", "json", "--mem", "no-such-mem"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let unknown = String::from_utf8(unknown).unwrap();
assert!(unknown.contains("UNKNOWN_MEM"), "got: {unknown}");
assert!(!unknown.contains("INTERNAL"), "got: {unknown}");
let with_output = memstead()
.current_dir(tmp.path())
.args(["export", "--format", "json", "-o", "dump.json"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let with_output = String::from_utf8(with_output).unwrap();
assert!(with_output.contains("INVALID_INPUT"), "got: {with_output}");
assert!(!with_output.contains("INTERNAL"), "got: {with_output}");
assert!(
!tmp.path().join("dump.json").exists(),
"refusal must not have written the file"
);
}
fn make_rename_workspace(root: &Path) {
let m = |args: &[&str]| {
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(args)
.assert()
.success();
};
m(&["mem-repo", "init", "."]);
m(&["mem", "init", "alpha", "--no-gitignore"]);
m(&["mem", "init", "beta", "--no-gitignore"]);
m(&["workspace", "grant-cross-link", "beta", "alpha"]);
let corpus = serde_json::json!({ "creates": [
{"title": "One", "entity_type": "spec", "mem": "alpha",
"sections": {"identity": "First entity.", "purpose": "Rename fixture."}},
{"title": "Two", "entity_type": "spec", "mem": "alpha",
"sections": {"identity": "Second entity, see [[alpha--one]] full form.", "purpose": "Rename fixture."},
"relations": [{"type": "USES", "to": "alpha--one"}]},
{"title": "Watcher", "entity_type": "spec", "mem": "beta",
"sections": {"identity": "Watches [[alpha--one]] from beta.", "purpose": "Cross-mem referrer."},
"relations": [{"type": "DEPENDS_ON", "to": "alpha--two"}]},
]});
let corpus_path = root.join("corpus.json");
fs::write(&corpus_path, serde_json::to_string(&corpus).unwrap()).unwrap();
m(&["batch-create", "--from", corpus_path.to_str().unwrap()]);
fs::remove_file(&corpus_path).unwrap();
m(&[
"update",
"alpha--one",
"--auto-hash",
"--append",
"purpose= Anchored.",
"--anchor",
r#"{"artifact": "src/lib.rs", "grain": "file", "class": "anchored"}"#,
]);
m(&[
"mem",
"set-sync-state",
"alpha",
"alpha/graph/tree#synced",
"abc123",
]);
}
#[test]
fn mem_rename_git_branch_full_surface() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_rename_workspace(root);
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(["mem", "rename", "alpha", "gamma"])
.assert()
.success()
.stdout(contains("renamed to `gamma`"));
memstead()
.current_dir(root)
.args(["entity", "gamma--one"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["entity", "alpha--one"])
.assert()
.failure()
.stderr(contains("ENTITY_NOT_FOUND"));
memstead()
.current_dir(root)
.args(["entity", "beta--watcher"])
.assert()
.success()
.stdout(contains("[[gamma--one]]"))
.stdout(contains("[[gamma--two]]"))
.stdout(predicates::prelude::PredicateBooleanExt::not(contains(
"alpha--",
)));
let toml = fs::read_to_string(root.join(".memstead/workspace.toml")).unwrap();
assert!(
toml.contains(r#"beta = ["gamma"]"#),
"grant must name the new mem; got:\n{toml}"
);
assert!(
!toml.contains("alpha"),
"no grant may still name the old mem:\n{toml}"
);
let log = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["log", "--oneline", "refs/heads/gamma"])
.output()
.unwrap();
let log = String::from_utf8(log.stdout).unwrap();
assert!(
log.contains("create mem alpha"),
"history must be preserved across the rename; got:\n{log}"
);
let refs = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["for-each-ref", "--format=%(refname)"])
.output()
.unwrap();
let refs = String::from_utf8(refs.stdout).unwrap();
assert!(
!refs.contains("refs/heads/alpha"),
"old branch must be gone:\n{refs}"
);
let sidecar = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["show", "refs/heads/gamma:.memstead/anchors.json"])
.output()
.unwrap();
let sidecar = String::from_utf8(sidecar.stdout).unwrap();
assert!(
sidecar.contains("gamma--one") && !sidecar.contains("alpha--one"),
"anchors must key on the new id; got:\n{sidecar}"
);
let config = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["show", "refs/heads/__MEMSTEAD:mems/gamma/config.json"])
.output()
.unwrap();
let config = String::from_utf8(config.stdout).unwrap();
assert!(
config.contains("gamma/graph/tree#synced") && !config.contains("alpha/"),
"sync-state keys must carry the new mem name; got:\n{config}"
);
memstead()
.current_dir(root)
.args(["health", "--strict"])
.assert()
.success();
}
#[test]
fn mem_rename_refusals_are_typed_and_effect_free() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_rename_workspace(root);
let snapshot = |label: &str| -> (String, String, String) {
let refs = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.output()
.unwrap_or_else(|e| panic!("for-each-ref ({label}): {e}"));
(
String::from_utf8(refs.stdout).unwrap(),
fs::read_to_string(root.join(".memstead/workspace.toml")).unwrap(),
fs::read_to_string(root.join(".memstead/state/mounts.json")).unwrap(),
)
};
let before = snapshot("before");
let refusals: &[(&[&str], &str, bool)] = &[
(&["mem", "rename", "nope", "other"], "UNKNOWN_MEM", true),
(
&["mem", "rename", "alpha", "beta"],
"MEM_NAME_COLLISION",
true,
),
(
&["mem", "rename", "alpha", "Bad Name"],
"INVALID_MEM_NAME",
true,
),
(&["mem", "rename", "alpha", "alpha"], "INVALID_INPUT", true),
(
&["mem", "rename", "alpha", "delta"],
"MEM_PATH_NOT_ALLOWED",
false,
),
];
for (args, code, operator) in refusals {
let mut cmd = memstead();
cmd.current_dir(root);
if *operator {
cmd.env("MEMSTEAD_OPERATOR_MODE", "1");
} else {
cmd.env_remove("MEMSTEAD_OPERATOR_MODE");
}
let out = cmd
.args(*args)
.assert()
.failure()
.get_output()
.stderr
.clone();
let stderr = String::from_utf8(out).unwrap();
assert!(stderr.contains(code), "expected {code}; got: {stderr}");
assert!(!stderr.contains("INTERNAL"), "INTERNAL leaked: {stderr}");
if *code == "MEM_PATH_NOT_ALLOWED" {
assert!(
stderr.contains("mem_management.delete"),
"policy_table disambiguator missing: {stderr}"
);
}
}
let after = snapshot("after");
assert_eq!(
before, after,
"a refused rename must leave the workspace byte-identical"
);
}
#[test]
fn mem_rename_interrupted_half_state_detectable_and_completable() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_rename_workspace(root);
let watcher_body = "---\ntype: spec\ncreated_date: 2026-02-01\nlast_modified: 2026-02-01\nlevel: M0\n---\n# Watcher\n\n## Identity\n\nWatches [[gamma--one]] from beta.\n\n## Purpose\n\nCross-mem referrer.\n\n## Relationships\n\n- **DEPENDS_ON**: [[gamma--two]]\n";
commit_mem_branch(root, "beta", &[("watcher.md", watcher_body)]);
memstead()
.current_dir(root)
.args(["health", "--strict"])
.assert()
.stdout(contains("Stubs: 1"));
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(["mem", "rename", "alpha", "gamma"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["entity", "gamma--one"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["health", "--strict"])
.assert()
.success()
.stdout(contains("Stubs: 0"));
}
#[test]
fn mem_rename_folder_mount() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let store = root.join(".memstead");
fs::create_dir_all(store.join("state")).unwrap();
fs::write(
store.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
memstead_git_branch::test_support::init_real_mem_repo(root, &[]);
fs::write(
store.join("state").join("mounts.json"),
r#"{"format":"memstead-mounts-3","mounts":[{"mem":"engine","schema":"default@1.0.0","storage":{"type":"folder","path":"engine-mem"},"capability":"write","lifecycle":"eager","cross_linkable":false}]}"#,
)
.unwrap();
let mem_dir = root.join("engine-mem");
fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0"}"#,
)
.unwrap();
fs::write(
mem_dir.join("solo.md"),
"---\ntype: spec\ncreated_date: 2026-02-01\nlast_modified: 2026-02-01\nlevel: M0\n---\n# Solo\n\n## Identity\n\nFolder-mount rename fixture.\n\n## Purpose\n\nTesting.\n",
)
.unwrap();
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(["mem", "rename", "engine", "motor"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["entity", "motor--solo"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["entity", "engine--solo"])
.assert()
.failure();
assert!(
root.join("engine-mem").join("solo.md").exists(),
"folder location is not identity — the directory stays in place"
);
let mounts = fs::read_to_string(store.join("state").join("mounts.json")).unwrap();
assert!(
mounts.contains(r#""motor""#) && !mounts.contains(r#""engine""#),
"mounts.json must carry the new name; got:\n{mounts}"
);
}
#[test]
fn mem_rename_read_only_mount_refuses() {
let _guard = cache_guard().lock().unwrap();
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender.path())
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
let out = memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_OPERATOR_MODE", "1")
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["mem", "rename", "sender-mem", "other-name"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let stderr = String::from_utf8(out).unwrap();
assert!(
stderr.contains("READ_ONLY_MOUNT") || stderr.contains("read-only"),
"renaming an RO mount must refuse with the read-only code; got: {stderr}"
);
assert!(!stderr.contains("INTERNAL"), "INTERNAL leaked: {stderr}");
}
fn export_sender_archive(sender_root: &Path, sender_mem: &Path) -> std::path::PathBuf {
let archive = sender_mem.join("sender-mem-1.0.0.mem");
memstead()
.current_dir(sender_root)
.args(["export", "--format", "mem", "-o"])
.arg(&archive)
.assert()
.success();
archive
}
#[test]
fn install_registers_workspace_read_only_mount() {
let _guard = cache_guard().lock().unwrap_or_else(|e| e.into_inner());
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = export_sender_archive(sender.path(), &sender_mem);
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("registered as a workspace-level read-only mount"));
memstead()
.current_dir(receiver.path())
.args(["mem", "list"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("`sender-mem` (read_only)"));
memstead()
.current_dir(receiver.path())
.args(["overview"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("sender-mem"));
let mounts = fs::read_to_string(receiver.path().join(".memstead/state/mounts.json")).unwrap();
assert!(
mounts.contains(r#""sender-mem""#) && mounts.contains(r#""archive""#),
"mounts.json must carry the archive mount; got:\n{mounts}"
);
let cfg = memstead_git_branch::mem_repo_config::read_config(receiver.path(), "receiver-mem")
.expect("receiver config readable");
assert!(
cfg.read_mems.is_empty(),
"install must not write readMems; got {:?}",
cfg.read_mems
);
}
#[test]
fn uninstall_round_trip_cache_survives_and_reads_have_parity() {
let _guard = cache_guard().lock().unwrap_or_else(|e| e.into_inner());
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = export_sender_archive(sender.path(), &sender_mem);
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.args(["search", "Alpha"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("sender-mem--alpha"));
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args([
"workspace",
"grant-cross-link",
"receiver-mem",
"sender-mem",
])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args([
"create",
"--mem",
"receiver-mem",
"--title",
"Bridge",
"--type",
"spec",
"--section",
"identity=Links into [[sender-mem--alpha]] from the writable side.",
"--section",
"purpose=Cross-mem parity probe.",
])
.assert()
.success();
memstead()
.current_dir(receiver.path())
.args(["entity", "receiver-mem--bridge"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("[[sender-mem--alpha]]"));
memstead()
.current_dir(receiver.path())
.arg("uninstall")
.arg("sender-mem")
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.failure()
.stderr(contains("MEM_HAS_INCOMING_REFS"))
.stderr(contains("receiver-mem--bridge"));
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args(["delete", "receiver-mem--bridge"])
.assert()
.success();
memstead()
.current_dir(receiver.path())
.arg("uninstall")
.arg("sender-mem")
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("archive copy retained"));
memstead()
.current_dir(receiver.path())
.args(["search", "Alpha"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(predicates::prelude::PredicateBooleanExt::not(contains(
"sender-mem--alpha",
)));
let cached: Vec<_> = fs::read_dir(cache.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("sender-mem-") && n.ends_with(".mem"))
.collect();
assert_eq!(
cached.len(),
1,
"cache copy must survive uninstall: {cached:?}"
);
memstead()
.current_dir(receiver.path())
.arg("install")
.arg(&archive)
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("already in cache"))
.stdout(contains("registered as a workspace-level read-only mount"));
}
#[test]
fn legacy_read_mems_config_migrates_at_boot() {
let _guard = cache_guard().lock().unwrap_or_else(|e| e.into_inner());
let sender = TempDir::new().unwrap();
let receiver = TempDir::new().unwrap();
let cache = TempDir::new().unwrap();
let sender_mem = make_sender_mem(sender.path());
let _receiver_mem = make_receiver_mem(receiver.path());
let archive = export_sender_archive(sender.path(), &sender_mem);
unsafe {
std::env::set_var("MEMSTEAD_MEM_CACHE", cache.path());
}
let cached = memstead_git_branch::mem_cache::install_to_cache(&archive, &[]).unwrap();
unsafe {
std::env::remove_var("MEMSTEAD_MEM_CACHE");
}
let mut cfg =
memstead_git_branch::mem_repo_config::read_config(receiver.path(), "receiver-mem")
.expect("receiver config readable");
cfg.read_mems.insert(
"sender-mem".to_string(),
memstead_schema::config::ReadMemSpec {
source: memstead_schema::config::ReadMemSource::Local,
cache_key: Some(cached.cache_key.clone()),
},
);
let mut bytes = serde_json::to_vec_pretty(&cfg).unwrap();
bytes.push(b'\n');
memstead_git_branch::mem_repo_config::commit_config(
receiver.path(),
"receiver-mem",
&bytes,
&memstead_git_branch::vcs::CommitContext::internal(),
"test: legacy readMems fixture",
)
.unwrap();
let health = memstead()
.current_dir(receiver.path())
.args(["--json", "health"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.get_output()
.stdout
.clone();
let health = String::from_utf8(health).unwrap();
assert!(
health.contains("READ_MEMS_MIGRATED_TO_MOUNTS") && health.contains("sender-mem"),
"boot must surface the migration warning naming the mem; got:\n{health}"
);
let mounts = fs::read_to_string(receiver.path().join(".memstead/state/mounts.json")).unwrap();
assert!(
mounts.contains(r#""sender-mem""#),
"migrated mount must persist; got:\n{mounts}"
);
let cfg_after =
memstead_git_branch::mem_repo_config::read_config(receiver.path(), "receiver-mem").unwrap();
assert!(
cfg_after.read_mems.is_empty(),
"legacy key must be removed; got {:?}",
cfg_after.read_mems
);
let health2 = memstead()
.current_dir(receiver.path())
.args(["--json", "health"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.get_output()
.stdout
.clone();
let health2 = String::from_utf8(health2).unwrap();
assert!(
!health2.contains("READ_MEMS_MIGRATED_TO_MOUNTS"),
"second boot must not re-warn; got:\n{health2}"
);
memstead()
.current_dir(receiver.path())
.args(["search", "Alpha"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("sender-mem--alpha"));
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args([
"workspace",
"grant-cross-link",
"receiver-mem",
"sender-mem",
])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success();
memstead()
.current_dir(receiver.path())
.env("MEMSTEAD_MEM_CACHE", cache.path())
.args([
"create",
"--mem",
"receiver-mem",
"--title",
"Bridge",
"--type",
"spec",
"--section",
"identity=Links into [[sender-mem--alpha]] post-migration.",
"--section",
"purpose=Migration parity probe.",
])
.assert()
.success();
memstead()
.current_dir(receiver.path())
.args(["entity", "receiver-mem--bridge"])
.env("MEMSTEAD_MEM_CACHE", cache.path())
.assert()
.success()
.stdout(contains("[[sender-mem--alpha]]"));
}
#[test]
fn uninstall_refusals_are_typed_and_effect_free() {
let _guard = cache_guard().lock().unwrap_or_else(|e| e.into_inner());
let receiver = TempDir::new().unwrap();
let _receiver_mem = make_receiver_mem(receiver.path());
let mounts_before =
fs::read_to_string(receiver.path().join(".memstead/state/mounts.json")).unwrap();
for (name, code) in [
("no-such-mem", "UNKNOWN_MEM"),
("receiver-mem", "MEM_NOT_READ_ONLY"),
] {
let out = memstead()
.current_dir(receiver.path())
.arg("uninstall")
.arg(name)
.assert()
.failure()
.get_output()
.stderr
.clone();
let stderr = String::from_utf8(out).unwrap();
assert!(stderr.contains(code), "expected {code}; got: {stderr}");
assert!(!stderr.contains("INTERNAL"), "INTERNAL leaked: {stderr}");
}
let mounts_after =
fs::read_to_string(receiver.path().join(".memstead/state/mounts.json")).unwrap();
assert_eq!(
mounts_before, mounts_after,
"refusals must not touch mount state"
);
}
fn make_anchor_workspace(root: &Path) {
let m = |args: &[&str]| {
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(args)
.assert()
.success();
};
m(&["mem-repo", "init", "."]);
m(&["mem", "init", "hold", "--no-gitignore"]);
m(&[
"create",
"--mem",
"hold",
"--title",
"Holder",
"--type",
"spec",
"--section",
"identity=Anchored fixture entity.",
"--section",
"purpose=Verification states.",
]);
for (name, content) in [
("src-a.txt", "alpha"),
("src-b.txt", "beta"),
("src-c.txt", "gamma"),
("src-d.txt", "delta"),
] {
fs::write(root.join(name), content).unwrap();
}
let h = |content: &str| memstead_base::anchor::prepared_content_hash(content.as_bytes());
let anchors = [
format!(
r#"{{"artifact":"src-a.txt","grain":"file","class":"anchored","hash":"{}","hash_stability":"stable"}}"#,
h("alpha")
),
format!(
r#"{{"artifact":"src-b.txt","grain":"file","class":"anchored","hash":"{}","hash_stability":"stable"}}"#,
h("beta")
),
format!(
r#"{{"artifact":"src-c.txt","grain":"file","class":"anchored","hash":"{}","hash_stability":"unstable"}}"#,
h("gamma")
),
format!(
r#"{{"artifact":"src-d.txt","grain":"file","class":"anchored","hash":"{}","hash_stability":"stable"}}"#,
h("delta")
),
];
let mut args: Vec<String> = vec![
"update".into(),
"hold--holder".into(),
"--auto-hash".into(),
"--append".into(),
"purpose= Anchored.".into(),
];
for a in &anchors {
args.push("--anchor".into());
args.push(a.clone());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
m(&arg_refs);
fs::write(root.join("src-b.txt"), "beta CHANGED").unwrap();
fs::write(root.join("src-c.txt"), "gamma CHANGED").unwrap();
fs::remove_file(root.join("src-d.txt")).unwrap();
}
#[test]
fn verify_anchors_reports_four_states_without_binding() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_anchor_workspace(root);
let refs_before = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.output()
.unwrap()
.stdout;
let out = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "hold"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["resolved"], 1, "full: {v}");
assert_eq!(v["drifted"], 1, "full: {v}");
assert_eq!(v["recheck"], 1, "full: {v}");
assert_eq!(v["unresolvable"], 1, "full: {v}");
let state_of = |artifact: &str| -> String {
v["anchors"]
.as_array()
.unwrap()
.iter()
.find(|a| a["artifact"] == artifact)
.map(|a| a["state"].as_str().unwrap().to_string())
.unwrap_or_else(|| panic!("anchor for {artifact} missing: {v}"))
};
assert_eq!(state_of("src-a.txt"), "resolved");
assert_eq!(state_of("src-b.txt"), "drifted");
assert_eq!(state_of("src-c.txt"), "recheck");
assert_eq!(state_of("src-d.txt"), "unresolvable");
let refs_after = std::process::Command::new("git")
.arg("--git-dir")
.arg(root.join("mem-repo/.git"))
.args(["for-each-ref", "--format=%(refname) %(objectname)"])
.output()
.unwrap()
.stdout;
assert_eq!(
refs_before, refs_after,
"verify-anchors must not move any ref"
);
}
#[test]
fn verify_anchors_multi_binding_mem_no_longer_nulls() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_anchor_workspace(root);
let proj = root.join(".memstead/projections/hold");
fs::create_dir_all(&proj).unwrap();
for (name, pointer) in [("graph-a", "src-a.txt"), ("graph-b", "src-b.txt")] {
fs::write(
proj.join(format!("{name}.json")),
format!(
r#"{{"version":2,"intent":"t","sources":[{{"name":"s","type":"codebase","pointer":"{pointer}","change_detection":"git","scope":[{{"path":"**","mode":"allow"}}]}}],"reference_mems":[],"destination_mem":"hold","deny_paths":[],"coverage_semantics":"exhaustive","operations":{{"build":{{"mode":"discovery","trigger":"loop","batch_size":20}},"sync":{{"trigger":"manual","batch_size":20}}}}}}"#
),
)
.unwrap();
}
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args([
"update",
"hold--holder",
"--auto-hash",
"--append",
"purpose= Url anchor.",
"--anchor",
r#"{"artifact":"https://example.com/spec","grain":"url","class":"informed-by"}"#,
])
.assert()
.success();
let out = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "hold"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["resolved"], 1, "{v}");
assert_eq!(v["drifted"], 1, "{v}");
assert_eq!(v["recheck"], 1, "{v}");
assert_eq!(v["unresolvable"], 2, "{v}");
}
#[test]
fn folder_backend_same_transport_check_reads_unconfirmable() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let ws = root.join("gatews");
fs::create_dir_all(&ws).unwrap();
memstead()
.current_dir(&ws)
.args(["quickstart"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args([
"--role",
"author",
"create",
"--title",
"Gate Probe",
"--type",
"memo",
"--section",
"claim=Recorded.",
"--section",
"context=Gate test.",
])
.assert()
.success();
memstead()
.current_dir(&ws)
.args([
"--role",
"checker",
"check",
"gatews--gate-probe",
"--verdict",
"ok",
])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["--json", "health", "--include", "checks"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
let gate = &v["checks"]["gatews"]["independence"];
assert_eq!(
gate["unconfirmable"]["items"],
serde_json::json!(["gatews--gate-probe"]),
"transport is not identity — neither self_checked nor \
confirmed_independent without a caller-declared identity: {v}"
);
assert_eq!(gate["self_checked"]["count"], 0, "{v}");
assert_eq!(gate["confirmed_independent"]["count"], 0, "{v}");
}
#[test]
fn health_markdown_renders_checks_and_stale_derivations_sections() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("mdws");
fs::create_dir_all(&ws).unwrap();
memstead()
.current_dir(&ws)
.args(["quickstart"])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["health", "--include", "checks,stale_derivations"])
.assert()
.success()
.get_output()
.stdout
.clone();
let md = String::from_utf8(out).unwrap();
assert!(md.contains("## Checks (1 mems)"), "{md}");
assert!(md.contains("never_checked 1"), "{md}");
assert!(md.contains("unconfirmable 0"), "{md}");
assert!(md.contains("## Stale derivations (0 findings)"), "{md}");
let plain = memstead()
.current_dir(&ws)
.args(["health"])
.assert()
.success()
.get_output()
.stdout
.clone();
let plain = String::from_utf8(plain).unwrap();
assert!(
!plain.contains("## Checks") && !plain.contains("## Stale derivations"),
"{plain}"
);
}
#[test]
fn verify_anchors_persists_standalone_findings_across_passes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_anchor_workspace(root);
let out = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "hold"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["findings"]["new"], 2, "{v}");
assert_eq!(v["findings"]["already_seen"], 0, "{v}");
assert_eq!(v["findings"]["items"].as_array().unwrap().len(), 2);
let store_path = root
.join(".memstead/state/findings/hold/standalone.json")
.to_path_buf();
let store = fs::read_to_string(&store_path).expect("standalone store persisted");
let parsed: serde_json::Value = serde_json::from_str(&store).unwrap();
assert_eq!(
parsed["batches"][0]["key"]["binding_hash"], "standalone",
"{store}"
);
let out = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "hold"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["findings"]["new"], 0, "{v}");
assert_eq!(v["findings"]["already_seen"], 2, "{v}");
fs::write(root.join("src-d.txt"), "delta").unwrap();
let out = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "hold"])
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["findings"]["already_seen"], 1, "{v}");
assert_eq!(v["findings"]["new"], 0, "{v}");
}
#[test]
fn verify_anchors_health_axis_and_refusals() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_anchor_workspace(root);
let plain = memstead()
.current_dir(root)
.args(["--json", "health"])
.assert()
.success()
.get_output()
.stdout
.clone();
let plain: serde_json::Value = serde_json::from_slice(&plain).unwrap();
assert!(
plain.get("anchors").is_none(),
"anchors axis must be include-gated: {plain}"
);
let with = memstead()
.current_dir(root)
.args(["--json", "health", "--include", "anchors"])
.assert()
.success()
.get_output()
.stdout
.clone();
let with: serde_json::Value = serde_json::from_slice(&with).unwrap();
let axis = with.get("anchors").expect("axis present under include");
assert_eq!(axis["hold"]["drifted"], 1, "{with}");
assert_eq!(axis["hold"]["unresolvable"], 1, "{with}");
memstead()
.current_dir(root)
.env("MEMSTEAD_OPERATOR_MODE", "1")
.args(["mem", "init", "bare", "--no-gitignore"])
.assert()
.success();
let empty = memstead()
.current_dir(root)
.args(["--json", "verify-anchors", "--mem", "bare"])
.assert()
.success()
.get_output()
.stdout
.clone();
let empty: serde_json::Value = serde_json::from_slice(&empty).unwrap();
assert_eq!(empty["resolved"], 0);
assert_eq!(empty["anchors"].as_array().map(|a| a.len()), Some(0));
let unknown = memstead()
.current_dir(root)
.args(["verify-anchors", "--mem", "nope"])
.assert()
.failure()
.get_output()
.stderr
.clone();
let unknown = String::from_utf8(unknown).unwrap();
assert!(unknown.contains("UNKNOWN_MEM"), "got: {unknown}");
assert!(!unknown.contains("INTERNAL"), "INTERNAL leaked: {unknown}");
}
#[test]
fn mem_init_succeeds_without_a_configured_git_identity() {
let tmp = TempDir::new().unwrap();
let empty_home = TempDir::new().unwrap();
let scrubbed = |args: &[&str]| -> assert_cmd::assert::Assert {
memstead()
.current_dir(tmp.path())
.env("MEMSTEAD_OPERATOR_MODE", "1")
.env("HOME", empty_home.path())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env_remove("GIT_AUTHOR_NAME")
.env_remove("GIT_AUTHOR_EMAIL")
.env_remove("GIT_COMMITTER_NAME")
.env_remove("GIT_COMMITTER_EMAIL")
.args(args)
.assert()
};
scrubbed(&["mem-repo", "init", "."]).success();
scrubbed(&["mem", "init", "alpha", "--no-gitignore"]).success();
let listed = scrubbed(&["mem", "list"])
.success()
.get_output()
.stdout
.clone();
let listed = String::from_utf8(listed).unwrap();
assert!(listed.contains("alpha"), "mem not listed: {listed}");
}