#![cfg(feature = "mem-repo")]
#![cfg(unix)]
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use memstead_git_branch::test_support::init_real_mem_repo;
use tempfile::TempDir;
fn memstead() -> Command {
let mut cmd = Command::cargo_bin("memstead").expect("memstead binary must be built by cargo");
cmd.env("MEMSTEAD_OPERATOR_MODE", "1");
cmd
}
fn seed_workspace(tmp: &Path) -> PathBuf {
let workspace = tmp.join("ws");
fs::create_dir_all(&workspace).unwrap();
let memstead_dir = workspace.join(".memstead");
fs::create_dir_all(&memstead_dir).unwrap();
fs::write(
memstead_dir.join("workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
init_real_mem_repo(&workspace, &[]);
workspace
}
fn assert_mem_in_mounts(workspace: &Path, mem_name: &str) {
let mounts_path = workspace
.join(".memstead")
.join("state")
.join("mounts.json");
let raw = fs::read_to_string(&mounts_path).unwrap_or_else(|e| {
panic!(
"mounts.json must exist at {} after mem init: {e}",
mounts_path.display(),
)
});
assert!(
raw.contains(&format!("\"{mem_name}\"")),
"mounts.json must list `{mem_name}` post-init; got:\n{raw}",
);
}
#[test]
fn memstead_mem_init_creates_mem_via_in_process_engine() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let mut cmd = memstead();
cmd.current_dir(&workspace)
.args(["mem", "init", "test-mem", "--no-gitignore"]);
let output = cmd.assert().success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("test-mem"),
"stdout must name the new mem; got:\n{stdout}",
);
assert!(
stdout.contains("Mem `test-mem` created") || stdout.contains("Mem test-mem"),
"stdout must render a mem-created success block; got:\n{stdout}",
);
assert_mem_in_mounts(&workspace, "test-mem");
}
#[test]
fn memstead_mem_init_hierarchical_name_lands_full_path() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let mut cmd = memstead();
cmd.current_dir(&workspace)
.args(["mem", "init", "demo/engine", "--no-gitignore"]);
cmd.assert().success();
let mounts_path = workspace
.join(".memstead")
.join("state")
.join("mounts.json");
let raw = fs::read_to_string(&mounts_path).unwrap();
assert!(
raw.contains("\"demo/engine\""),
"mounts.json must carry the full hierarchical name as the mem \
identifier (not the bare leaf); got:\n{raw}",
);
assert!(
raw.contains("demo/engine"),
"branch ref must encode the hierarchical path; got:\n{raw}",
);
}
#[test]
fn memstead_mem_init_rejects_legacy_org_path_flag() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let mut cmd = memstead();
cmd.current_dir(&workspace).args([
"mem",
"init",
"engine",
"--org-path",
"demo",
"--no-gitignore",
]);
let assertion = cmd.assert().failure();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("--org-path")
&& (stderr.contains("unexpected") || stderr.contains("unrecognized")),
"removed flag must surface as a clap parse error; got stderr:\n{stderr}",
);
}
#[test]
fn memstead_mem_init_json_mode_emits_structured_envelope() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let mut cmd = memstead();
cmd.current_dir(&workspace)
.args(["--json", "mem", "init", "json-mem", "--no-gitignore"]);
let output = cmd.assert().success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("--json output must parse as JSON: {e}; stdout:\n{stdout}"));
assert_eq!(
parsed.get("name").and_then(|v| v.as_str()),
Some("json-mem"),
"--json envelope must carry `name`",
);
assert!(
parsed.get("schema_ref").and_then(|v| v.as_str()).is_some(),
"--json envelope must carry `schema_ref`",
);
assert!(
parsed
.get("seed_commit_sha")
.and_then(|v| v.as_str())
.is_some(),
"--json envelope must carry `seed_commit_sha`",
);
}
#[test]
fn memstead_mem_init_json_stdout_is_single_document_with_gitignore_step() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".git")).unwrap();
let workspace = seed_workspace(tmp.path());
let mut cmd = memstead();
cmd.current_dir(&workspace)
.args(["--json", "mem", "init", "json-gi-mem"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8(output.stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("--json stdout must be exactly one JSON document: {e}; stdout:\n{stdout}")
});
assert_eq!(
parsed.get("name").and_then(|v| v.as_str()),
Some("json-gi-mem"),
"--json envelope must carry `name`, got: {parsed}",
);
let stderr = String::from_utf8(output.stderr.clone()).unwrap();
assert!(
stderr.contains("outer:"),
"outer-repo provenance must appear on stderr, got stderr:\n{stderr}",
);
}
fn append_cross_link_grant(workspace: &Path, from: &str, to: &str) {
let toml_path = workspace.join(".memstead").join("workspace.toml");
let mut body = fs::read_to_string(&toml_path).unwrap();
body.push_str(&format!("\n[cross_mem_links]\n{from} = [\"{to}\"]\n",));
fs::write(&toml_path, body).unwrap();
}
fn assert_branch_present(workspace: &Path, mem_name: &str) {
let branch_ref = workspace
.join("mem-repo")
.join(".git")
.join("refs")
.join("heads")
.join(mem_name);
let packed_refs = workspace.join("mem-repo").join(".git").join("packed-refs");
let loose_present = branch_ref.exists();
let packed_present = packed_refs
.exists()
.then(|| fs::read_to_string(&packed_refs).unwrap_or_default())
.map(|s| s.contains(&format!("refs/heads/{mem_name}")))
.unwrap_or(false);
assert!(
loose_present || packed_present,
"branch ref `refs/heads/{mem_name}` must be present after unregister; \
loose={loose_present} packed={packed_present}",
);
}
fn assert_branch_absent(workspace: &Path, mem_name: &str) {
let branch_ref = workspace
.join("mem-repo")
.join(".git")
.join("refs")
.join("heads")
.join(mem_name);
let packed_refs = workspace.join("mem-repo").join(".git").join("packed-refs");
let loose_present = branch_ref.exists();
let packed_present = packed_refs
.exists()
.then(|| fs::read_to_string(&packed_refs).unwrap_or_default())
.map(|s| s.contains(&format!("refs/heads/{mem_name}")))
.unwrap_or(false);
assert!(
!loose_present && !packed_present,
"branch ref `refs/heads/{mem_name}` must be pruned after delete; \
loose={loose_present} packed={packed_present}",
);
}
fn assert_mem_not_in_mounts(workspace: &Path, mem_name: &str) {
let mounts_path = workspace
.join(".memstead")
.join("state")
.join("mounts.json");
let raw = fs::read_to_string(&mounts_path).unwrap();
assert!(
!raw.contains(&format!("\"{mem_name}\"")),
"mounts.json must no longer list `{mem_name}`; got:\n{raw}",
);
}
#[test]
fn memstead_mem_unregister_preserves_storage() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "archive-target", "--no-gitignore"])
.assert()
.success();
assert_mem_in_mounts(&workspace, "archive-target");
let output = memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "archive-target"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("unregistered"),
"stdout must render the unregister verb in its heading; got:\n{stdout}",
);
assert_mem_not_in_mounts(&workspace, "archive-target");
assert_branch_present(&workspace, "archive-target");
}
#[test]
fn memstead_mem_delete_prunes_storage() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "scratch", "--no-gitignore"])
.assert()
.success();
let output = memstead()
.current_dir(&workspace)
.args(["mem", "delete", "scratch"])
.assert()
.success();
let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("deleted"),
"stdout must render the delete verb in its heading; got:\n{stdout}",
);
assert_mem_not_in_mounts(&workspace, "scratch");
assert_branch_absent(&workspace, "scratch");
}
#[test]
fn memstead_mem_unregister_is_admitted_under_cross_link_grant() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "target", "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&workspace)
.args(["mem", "init", "referrer", "--no-gitignore"])
.assert()
.success();
append_cross_link_grant(&workspace, "referrer", "target");
memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "target"])
.assert()
.success();
assert_mem_not_in_mounts(&workspace, "target");
assert_branch_present(&workspace, "target");
}
#[test]
fn memstead_mem_delete_refuses_under_cross_link_grant() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "target", "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&workspace)
.args(["mem", "init", "referrer", "--no-gitignore"])
.assert()
.success();
append_cross_link_grant(&workspace, "referrer", "target");
let assertion = memstead()
.current_dir(&workspace)
.args(["mem", "delete", "target"])
.assert()
.failure();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("MEM_REFERENCED_BY_POLICY"),
"policy refusal must carry MEM_REFERENCED_BY_POLICY; got stderr:\n{stderr}",
);
assert!(
stderr.contains("referrer"),
"policy refusal must name the referring mem; got stderr:\n{stderr}",
);
assert_mem_in_mounts(&workspace, "target");
assert_branch_present(&workspace, "target");
}
#[test]
fn memstead_mem_delete_rejects_legacy_delete_files_flag() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "scratch", "--no-gitignore"])
.assert()
.success();
let assertion = memstead()
.current_dir(&workspace)
.args(["mem", "delete", "scratch", "--delete-files"])
.assert()
.failure();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("--delete-files")
&& (stderr.contains("unexpected") || stderr.contains("unrecognized")),
"removed flag must surface as a clap parse error; got stderr:\n{stderr}",
);
}
#[test]
fn memstead_mem_init_reattaches_after_unregister() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let initial = memstead()
.current_dir(&workspace)
.args(["--json", "mem", "init", "preserved", "--no-gitignore"])
.assert()
.success();
let initial_stdout = String::from_utf8(initial.get_output().stdout.clone()).unwrap();
let initial_envelope: serde_json::Value =
serde_json::from_str(&initial_stdout).expect("--json envelope parses");
let initial_seed = initial_envelope["seed_commit_sha"]
.as_str()
.expect("initial create must carry a non-empty seed_commit_sha")
.to_string();
assert!(
!initial_seed.is_empty(),
"initial create must produce a real seed commit SHA",
);
memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "preserved"])
.assert()
.success();
assert_mem_not_in_mounts(&workspace, "preserved");
assert_branch_present(&workspace, "preserved");
let reattach = memstead()
.current_dir(&workspace)
.args(["--json", "mem", "init", "preserved", "--no-gitignore"])
.assert()
.success();
let reattach_stdout = String::from_utf8(reattach.get_output().stdout.clone()).unwrap();
let reattach_envelope: serde_json::Value =
serde_json::from_str(&reattach_stdout).expect("--json envelope parses");
let reattach_seed = reattach_envelope["seed_commit_sha"]
.as_str()
.expect("reattach response must carry seed_commit_sha (even if empty)");
assert!(
reattach_seed.is_empty(),
"reattach must skip the seed commit — empty `seed_commit_sha` is the \
contract signal that the existing branch was adopted; got {reattach_seed:?}",
);
let warnings = reattach_envelope["warnings"]
.as_array()
.expect("reattach response carries a `warnings` array");
let reattach_warning = warnings
.iter()
.find(|w| w.get("code").and_then(|c| c.as_str()) == Some("MEM_REATTACHED_AFTER_UNREGISTER"))
.expect("MEM_REATTACHED_AFTER_UNREGISTER must appear in the warnings array");
assert!(
reattach_warning
.get("message")
.and_then(|m| m.as_str())
.is_some_and(|s| !s.is_empty()),
"MEM_REATTACHED_AFTER_UNREGISTER must carry a human-readable message"
);
assert_mem_in_mounts(&workspace, "preserved");
}
#[test]
fn memstead_mem_init_rejects_multiple_recovery_flags() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let assertion = memstead()
.current_dir(&workspace)
.args([
"mem",
"init",
"scratch",
"--no-gitignore",
"--reattach",
"--force-overwrite",
])
.assert()
.failure();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("--reattach") || stderr.contains("--force-overwrite"),
"mutex error must name at least one conflicting flag; got stderr:\n{stderr}",
);
}
#[test]
fn memstead_mem_init_accepts_explicit_reattach_flag() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "fresh", "--no-gitignore", "--reattach"])
.assert()
.success();
assert_mem_in_mounts(&workspace, "fresh");
assert_branch_present(&workspace, "fresh");
}
#[test]
fn memstead_mem_init_force_overwrite_prunes_and_recreates() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let initial = memstead()
.current_dir(&workspace)
.args(["--json", "mem", "init", "scratch", "--no-gitignore"])
.assert()
.success();
let initial_envelope: serde_json::Value =
serde_json::from_str(&String::from_utf8(initial.get_output().stdout.clone()).unwrap())
.unwrap();
let initial_seed = initial_envelope["seed_commit_sha"]
.as_str()
.unwrap()
.to_string();
assert!(!initial_seed.is_empty());
memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "scratch"])
.assert()
.success();
assert_branch_present(&workspace, "scratch");
let overwritten = memstead()
.current_dir(&workspace)
.args([
"--json",
"mem",
"init",
"scratch",
"--no-gitignore",
"--force-overwrite",
])
.assert()
.success();
let overwrite_envelope: serde_json::Value =
serde_json::from_str(&String::from_utf8(overwritten.get_output().stdout.clone()).unwrap())
.unwrap();
let new_seed = overwrite_envelope["seed_commit_sha"]
.as_str()
.expect("overwrite must produce a non-null seed_commit_sha")
.to_string();
assert!(
!new_seed.is_empty(),
"force-overwrite must produce a fresh seed commit; got empty (which is \
the reattach signal — means the orchestrator took the wrong recovery \
branch)",
);
let _ = initial_seed;
assert_mem_in_mounts(&workspace, "scratch");
}
#[test]
fn memstead_mem_init_hard_cleanup_first_refuses_against_residue() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "scratch", "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "scratch"])
.assert()
.success();
let assertion = memstead()
.current_dir(&workspace)
.args([
"mem",
"init",
"scratch",
"--no-gitignore",
"--hard-cleanup-first",
])
.assert()
.failure();
let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap();
assert!(
stderr.contains("MEM_STORAGE_RESIDUE_DETECTED"),
"hard-cleanup-first must surface MEM_STORAGE_RESIDUE_DETECTED; \
got stderr:\n{stderr}",
);
assert_branch_present(&workspace, "scratch");
}
#[test]
fn memstead_mem_init_residue_isolation_is_path_aware() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
memstead()
.current_dir(&workspace)
.args(["mem", "init", "team-a/shared", "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&workspace)
.args(["mem", "unregister", "team-a/shared"])
.assert()
.success();
assert_branch_present(&workspace, "team-a/shared");
let output = memstead()
.current_dir(&workspace)
.args(["--json", "mem", "init", "team-b/shared", "--no-gitignore"])
.assert()
.success();
let envelope: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.get_output().stdout.clone()).unwrap())
.unwrap();
let seed = envelope["seed_commit_sha"].as_str().unwrap_or("");
assert!(
!seed.is_empty(),
"fresh create at `team-b/shared` must produce a real seed commit — \
empty seed signals a reattach, which would mean the probe leaked \
across org paths",
);
assert_branch_present(&workspace, "team-a/shared");
assert_branch_present(&workspace, "team-b/shared");
}
fn memstead_no_env() -> Command {
let mut cmd = Command::cargo_bin("memstead").expect("memstead binary must be built by cargo");
cmd.env_remove("MEMSTEAD_OPERATOR_MODE");
cmd
}
fn seed_workspace_with_create_allowlist(tmp: &Path) -> PathBuf {
let workspace = seed_workspace(tmp);
let toml_path = workspace.join(".memstead").join("workspace.toml");
let existing = fs::read_to_string(&toml_path).unwrap();
fs::write(
&toml_path,
format!(
"{existing}\n\
[[mem_management.create]]\n\
pattern = \"test\"\n\
schemas = [\"default@1.0.0\"]\n\n\
[[mem_management.create]]\n\
pattern = \"other\"\n\
schemas = [\"default@1.0.0\"]\n",
),
)
.unwrap();
workspace
}
#[test]
fn mem_init_refuses_outside_allowlist_by_default() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace_with_create_allowlist(tmp.path());
let output = memstead_no_env()
.current_dir(&workspace)
.args(["--json", "mem", "init", "forbidden", "--no-gitignore"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let body = std::str::from_utf8(&output).expect("stdout UTF-8");
let env: serde_json::Value = serde_json::from_str(body.trim()).expect("--json envelope parses");
assert_eq!(
env["code"], "MEM_PATH_NOT_ALLOWED",
"expected typed refusal, got: {env}",
);
}
#[test]
fn mem_init_admits_allowlisted_name_by_default() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace_with_create_allowlist(tmp.path());
memstead_no_env()
.current_dir(&workspace)
.args(["mem", "init", "test", "--no-gitignore"])
.assert()
.success();
assert_mem_in_mounts(&workspace, "test");
}
#[test]
fn mem_init_operator_mode_flag_bypasses_allowlist() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace_with_create_allowlist(tmp.path());
memstead_no_env()
.current_dir(&workspace)
.args([
"mem",
"init",
"forbidden",
"--no-gitignore",
"--operator-mode",
])
.assert()
.success();
assert_mem_in_mounts(&workspace, "forbidden");
}
#[test]
fn mem_init_operator_mode_env_var_bypasses_allowlist() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace_with_create_allowlist(tmp.path());
let mut cmd = memstead_no_env();
cmd.env("MEMSTEAD_OPERATOR_MODE", "1")
.current_dir(&workspace)
.args(["mem", "init", "forbidden", "--no-gitignore"]);
cmd.assert().success();
assert_mem_in_mounts(&workspace, "forbidden");
}
#[test]
fn mem_delete_preserves_allowlist_rules_so_recreate_succeeds() {
let tmp = TempDir::new().unwrap();
let workspace = seed_workspace(tmp.path());
let toml_path = workspace.join(".memstead").join("workspace.toml");
let existing = fs::read_to_string(&toml_path).unwrap();
fs::write(
&toml_path,
format!(
"{existing}\n\
[cross_mem_links]\n\
other = [\"keep\"]\n\n\
[[mem_management.create]]\n\
pattern = \"other\"\n\
schemas = [\"default@1.0.0\"]\n\n\
[[mem_management.delete]]\n\
pattern = \"other\"\n",
),
)
.unwrap();
memstead_no_env()
.current_dir(&workspace)
.args(["mem", "init", "other", "--no-gitignore"])
.assert()
.success();
assert_mem_in_mounts(&workspace, "other");
memstead_no_env()
.current_dir(&workspace)
.args(["mem", "delete", "other"])
.assert()
.success();
let after = fs::read_to_string(&toml_path).unwrap();
assert_eq!(
after.matches("pattern = \"other\"").count(),
2,
"delete must preserve the create+delete allowlist rules for `other`; got:\n{after}",
);
assert!(
!after.contains("other = [\"keep\"]"),
"delete must scrub the deleted mem's dangling cross-link grant; got:\n{after}",
);
memstead_no_env()
.current_dir(&workspace)
.args(["mem", "init", "other", "--no-gitignore"])
.assert()
.success();
assert_mem_in_mounts(&workspace, "other");
}