use std::path::Path;
use assert_cmd::Command;
use tempfile::TempDir;
fn memstead() -> Command {
Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}
fn stdout_of(assert: assert_cmd::assert::Assert) -> String {
String::from_utf8(assert.get_output().stdout.clone()).expect("stdout is UTF-8")
}
fn stderr_of(assert: assert_cmd::assert::Assert) -> String {
String::from_utf8(assert.get_output().stderr.clone()).expect("stderr is UTF-8")
}
#[test]
fn quickstart_fresh_dir_bootstraps_workspace_seed_and_wiring() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("my-fresh-graph");
let assert = memstead()
.args(["quickstart", "--json"])
.arg(&root)
.assert()
.success();
let payload: serde_json::Value =
serde_json::from_str(&stdout_of(assert)).expect("quickstart --json emits JSON");
assert_eq!(payload["name"], "my-fresh-graph");
assert_eq!(payload["schema"], "default@1.3.0");
assert_eq!(payload["agents_defaulted"], true);
assert_eq!(payload["agents"][0]["target"], "claude-code");
assert!(root.join(".memstead").join("workspace.toml").is_file());
assert!(root.join(".memstead").join("config.json").is_file());
let seed_id = payload["seed_entity"].as_str().expect("seed entity id");
assert_eq!(seed_id, "my-fresh-graph--welcome-to-memstead");
assert!(root.join("welcome-to-memstead.md").is_file());
let mcp: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(".mcp.json")).unwrap()).unwrap();
let command = mcp["mcpServers"]["memstead"]["command"]
.as_str()
.expect("server entry has a command");
assert!(
command.contains("memstead-mcp"),
"command must launch memstead-mcp, got: {command}",
);
assert!(
payload["next_action"].as_str().unwrap().contains("Restart"),
"next action must name the restart, got: {}",
payload["next_action"],
);
memstead()
.current_dir(&root)
.arg("overview")
.assert()
.success();
}
#[test]
fn quickstart_tolerates_dotfiles_and_readme_without_ingesting() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
std::fs::write(root.join("README"), "my project\n").unwrap();
std::fs::write(root.join("LICENSE"), "MIT\n").unwrap();
std::fs::create_dir(root.join(".git")).unwrap();
memstead().arg("quickstart").arg(&root).assert().success();
assert_eq!(
std::fs::read_to_string(root.join("README")).unwrap(),
"my project\n"
);
let assert = memstead()
.current_dir(&root)
.args(["list", "--json"])
.assert()
.success();
let listed: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
let hits = listed["hits"]
.as_array()
.unwrap_or_else(|| panic!("list --json carries hits[]; got {listed}"));
assert_eq!(hits.len(), 1, "seed entity only; got {hits:?}");
}
#[test]
fn quickstart_refuses_markdown_readme_naming_the_ingestion_risk() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("README.md"), "# my project\n").unwrap();
let err = stderr_of(
memstead()
.arg("quickstart")
.arg(tmp.path())
.assert()
.failure(),
);
assert!(err.contains("README.md"), "names the file; got: {err}");
assert!(
err.contains("adopt"),
"explains the ingestion risk; got: {err}"
);
assert!(
err.contains("memstead quickstart"),
"carries the alternative; got: {err}"
);
assert!(!tmp.path().join(".memstead").exists(), "no half-init");
assert_eq!(
std::fs::read_to_string(tmp.path().join("README.md")).unwrap(),
"# my project\n",
"the README is untouched",
);
}
#[test]
fn quickstart_refuses_conflicting_content_without_half_init() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::write(root.join("main.py"), "print()\n").unwrap();
let assert = memstead().arg("quickstart").arg(&root).assert().failure();
let err = stderr_of(assert);
assert!(err.contains("TARGET_NOT_EMPTY"), "typed code; got: {err}");
assert!(err.contains("main.py"), "names the conflict; got: {err}");
assert!(
err.contains("memstead quickstart"),
"names the exact alternative; got: {err}"
);
assert!(!root.join(".memstead").exists());
assert!(!root.join(".mcp.json").exists());
}
#[test]
fn quickstart_refuses_foreign_memstead_dir_and_ancestor_workspace() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".memstead")).unwrap();
std::fs::write(tmp.path().join(".memstead").join("junk"), "x").unwrap();
let err = stderr_of(
memstead()
.arg("quickstart")
.arg(tmp.path())
.assert()
.failure(),
);
assert!(
err.contains("FOREIGN_MEMSTEAD_DIR"),
"typed code; got: {err}"
);
assert!(
err.contains("memstead quickstart"),
"carries next command; got: {err}"
);
let outer = TempDir::new().unwrap();
memstead()
.arg("quickstart")
.arg(outer.path())
.assert()
.success();
let inner = outer.path().join("inner");
std::fs::create_dir(&inner).unwrap();
let err = stderr_of(memstead().arg("quickstart").arg(&inner).assert().failure());
assert!(
err.contains("WORKSPACE_ALREADY_EXISTS_ABOVE"),
"typed code; got: {err}"
);
assert!(
err.contains("memstead overview"),
"viable next command; got: {err}"
);
assert!(
err.contains("memstead quickstart"),
"separate-graph alternative; got: {err}"
);
assert!(
!err.contains("mem init"),
"no dead-end suggestion; got: {err}"
);
assert!(
!inner.join(".memstead").exists(),
"no half-init in the nested target"
);
let err = stderr_of(
memstead()
.arg("quickstart")
.arg(outer.path())
.assert()
.failure(),
);
assert!(
err.contains("WORKSPACE_ALREADY_INITIALISED"),
"typed code; got: {err}"
);
assert!(
err.contains("memstead overview"),
"carries next command; got: {err}"
);
}
#[test]
fn quickstart_never_overwrites_existing_mcp_server_entry() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
std::fs::write(
root.join(".mcp.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"mcpServers": {
"memstead": { "command": "/custom/memstead-mcp", "args": ["--flag"] },
"other": { "command": "/bin/other" },
}
}))
.unwrap(),
)
.unwrap();
let assert = memstead()
.args(["quickstart", "--json"])
.arg(&root)
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
assert!(
payload["agents"][0]["action"]
.as_str()
.unwrap()
.contains("left untouched"),
"report says the entry was left alone; got {payload}",
);
let mcp: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
mcp["mcpServers"]["memstead"]["command"],
"/custom/memstead-mcp"
);
assert_eq!(mcp["mcpServers"]["memstead"]["args"][0], "--flag");
assert_eq!(mcp["mcpServers"]["other"]["command"], "/bin/other");
}
#[test]
fn quickstart_agent_flags_wire_cursor_gemini_codex() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let assert = memstead()
.args([
"quickstart",
"--json",
"--agent",
"cursor",
"--agent",
"gemini",
"--agent",
"codex",
])
.arg(&root)
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
assert_eq!(payload["agents_defaulted"], false);
let cursor: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(".cursor/mcp.json")).unwrap()).unwrap();
assert!(cursor["mcpServers"]["memstead"]["command"].is_string());
let gemini: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join(".gemini/settings.json")).unwrap())
.unwrap();
assert!(gemini["mcpServers"]["memstead"]["command"].is_string());
let codex_action = payload["agents"][2]["action"].as_str().unwrap();
assert!(
codex_action.contains("codex mcp add memstead --"),
"got: {codex_action}"
);
assert!(!root.join(".codex").exists());
assert!(!root.join(".mcp.json").exists());
}
#[test]
fn quickstart_underivable_name_refuses_with_flag_command_non_tty() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("日本語");
std::fs::create_dir(&root).unwrap();
let err = stderr_of(memstead().arg("quickstart").arg(&root).assert().failure());
assert!(err.contains("--name"), "refusal names the flag; got: {err}");
assert!(
err.contains("memstead quickstart --name"),
"exact command; got: {err}"
);
assert!(!root.join(".memstead").exists(), "no half-init");
memstead()
.args(["quickstart", "--name", "nihongo"])
.arg(&root)
.assert()
.success();
assert!(root.join(".memstead").join("workspace.toml").is_file());
}
#[test]
fn schema_new_scaffold_validates_unmodified() {
let tmp = TempDir::new().unwrap();
let out = stdout_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success(),
);
assert!(out.contains("memstead schema validate acme"), "got: {out}");
#[cfg(feature = "mem-repo")]
assert!(out.contains("memstead schema install acme"), "got: {out}");
#[cfg(not(feature = "mem-repo"))]
assert!(
out.contains("memstead schema install ../acme"),
"got: {out}"
);
assert!(
out.contains("acme@0.1.0"),
"pin step names the version; got: {out}",
);
assert!(tmp.path().join("acme/schema.yaml").is_file());
assert!(tmp.path().join("acme/types/note.yaml").is_file());
memstead()
.current_dir(tmp.path())
.args(["schema", "validate", "acme"])
.assert()
.success();
}
#[cfg(feature = "mem-repo")]
#[test]
fn schema_new_follow_up_commands_end_in_pinned_mem_accepting_create() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("myws");
memstead()
.args(["init", "--name", "myws", "--schema", "default@1.0.0"])
.arg(&ws)
.assert()
.success();
let out = stdout_of(
memstead()
.current_dir(&ws)
.args(["schema", "new", "acme"])
.assert()
.success(),
);
assert!(
out.contains("memstead mem set-schema myws acme@0.1.0"),
"pin step names the workspace's mem; got: {out}",
);
assert!(
!out.contains("memstead delete"),
"no seed in an init workspace, so no delete step; got: {out}",
);
memstead()
.current_dir(&ws)
.args(["schema", "validate", "acme"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args(["schema", "install", "acme"])
.assert()
.success();
let pin_out = stdout_of(
memstead()
.current_dir(&ws)
.args(["mem", "set-schema", "myws", "acme@0.1.0"])
.assert()
.success(),
);
assert!(
pin_out.contains("Switched"),
"empty mem switches atomically; got: {pin_out}"
);
memstead()
.current_dir(&ws)
.args([
"create",
"--type",
"note",
"--title",
"First note",
"--section",
"summary=It works.",
])
.assert()
.success();
}
#[cfg(feature = "mem-repo")]
#[test]
fn schema_new_follow_up_from_quickstart_workspace_ends_pinned() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("my-graph");
memstead().arg("quickstart").arg(&ws).assert().success();
let out = stdout_of(
memstead()
.current_dir(&ws)
.args(["schema", "new", "acme"])
.assert()
.success(),
);
let seed_id = "my-graph--welcome-to-memstead";
assert!(
out.contains(&format!("memstead delete {seed_id}")),
"follow-up includes the seed delete step; got: {out}",
);
assert!(
out.contains("memstead mem set-schema my-graph acme@0.1.0"),
"pin step names the quickstart mem; got: {out}",
);
memstead()
.current_dir(&ws)
.args(["schema", "validate", "acme"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args(["schema", "install", "acme"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args(["delete", seed_id])
.assert()
.success();
let pin_out = stdout_of(
memstead()
.current_dir(&ws)
.args(["mem", "set-schema", "my-graph", "acme@0.1.0"])
.assert()
.success(),
);
assert!(
pin_out.contains("Switched"),
"seedless mem switches atomically, no migration; got: {pin_out}",
);
memstead()
.current_dir(&ws)
.args([
"create",
"--type",
"note",
"--title",
"First note",
"--section",
"summary=It works.",
])
.assert()
.success();
}
#[cfg(feature = "mem-repo")]
#[test]
fn schema_install_resolves_retained_builtin_versions() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("retained");
memstead().arg("quickstart").arg(&ws).assert().success();
memstead()
.current_dir(&ws)
.args(["schema", "install", "planning@0.3.0"])
.assert()
.success();
let err = stderr_of(
memstead()
.current_dir(&ws)
.args(["schema", "install", "planning@9.9.9"])
.assert()
.failure(),
);
assert!(
err.contains("planning@9.9.9"),
"unregistered version refuses, naming the pin; got: {err}"
);
}
#[test]
fn quickstart_malformed_agent_config_refuses_before_any_write() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".mcp.json"), "{not json").unwrap();
let err = stderr_of(
memstead()
.arg("quickstart")
.arg(tmp.path())
.assert()
.failure(),
);
assert!(
err.contains("not valid JSON"),
"names the defect; got: {err}"
);
assert!(
err.contains("re-run: memstead quickstart"),
"carries the retry; got: {err}"
);
assert!(
!tmp.path().join(".memstead").exists(),
"nothing was created"
);
std::fs::remove_file(tmp.path().join(".mcp.json")).unwrap();
memstead()
.arg("quickstart")
.arg(tmp.path())
.assert()
.success();
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".mcp.json"), r#"{"mcpServers": []}"#).unwrap();
let err = stderr_of(
memstead()
.arg("quickstart")
.arg(tmp.path())
.assert()
.failure(),
);
assert!(err.contains("mcpServers"), "names the defect; got: {err}");
assert!(
err.contains("re-run: memstead quickstart"),
"carries the retry; got: {err}"
);
assert!(
!tmp.path().join(".memstead").exists(),
"nothing was created"
);
}
#[cfg(not(feature = "mem-repo"))]
#[test]
fn schema_new_lean_follow_up_ends_in_working_fresh_mem() {
let tmp = TempDir::new().unwrap();
let out = stdout_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success(),
);
assert!(
out.contains("memstead init --name acme-mem --schema acme@0.1.0"),
"lean follow-up routes through a fresh init; got: {out}",
);
assert!(
out.contains("memstead schema install ../acme"),
"install step targets the new workspace; got: {out}",
);
assert!(
!out.contains("mem set-schema"),
"lean never prints the full-only subcommand; got: {out}",
);
memstead()
.current_dir(tmp.path())
.args(["schema", "validate", "acme"])
.assert()
.success();
let fresh = tmp.path().join("acme-mem");
std::fs::create_dir(&fresh).unwrap();
memstead()
.current_dir(&fresh)
.args(["init", "--name", "acme-mem", "--schema", "acme@0.1.0"])
.assert()
.success();
memstead()
.current_dir(&fresh)
.args(["schema", "install", "../acme"])
.assert()
.success();
memstead()
.current_dir(&fresh)
.arg("overview")
.assert()
.success();
memstead()
.current_dir(&fresh)
.args([
"create",
"--type",
"note",
"--title",
"First note",
"--section",
"summary=It works.",
])
.assert()
.success();
}
#[cfg(not(feature = "mem-repo"))]
#[test]
fn schema_new_lean_follow_up_from_inside_workspace_lands_outside() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("my-graph");
memstead().arg("quickstart").arg(&ws).assert().success();
let out = stdout_of(
memstead()
.current_dir(&ws)
.args(["schema", "new", "acme"])
.assert()
.success(),
);
let quoted = |line_marker: &str| -> std::path::PathBuf {
let line = out
.lines()
.find(|l| l.contains(line_marker))
.unwrap_or_else(|| panic!("no step containing `{line_marker}`; got: {out}"));
let start = line
.find('"')
.unwrap_or_else(|| panic!("no quoted path in: {line}"));
let rest = &line[start + 1..];
let end = rest
.find('"')
.unwrap_or_else(|| panic!("unterminated quote in: {line}"));
std::path::PathBuf::from(&rest[..end])
};
let fresh = quoted("memstead init --name acme-mem");
let pkg = quoted("memstead schema install");
let ws_canon = std::fs::canonicalize(&ws).unwrap();
assert!(
!fresh.starts_with(&ws_canon) && !fresh.starts_with(&ws),
"fresh-mem dir {} must not nest inside the workspace {}",
fresh.display(),
ws.display(),
);
std::fs::create_dir_all(&fresh).unwrap();
memstead()
.current_dir(&fresh)
.args(["init", "--name", "acme-mem", "--schema", "acme@0.1.0"])
.assert()
.success();
memstead()
.current_dir(&fresh)
.args(["schema", "install"])
.arg(&pkg)
.assert()
.success();
memstead()
.current_dir(&fresh)
.arg("overview")
.assert()
.success();
memstead()
.current_dir(&fresh)
.args([
"create",
"--type",
"note",
"--title",
"First note",
"--section",
"summary=It works.",
])
.assert()
.success();
}
#[test]
fn schema_new_package_installs_into_folder_workspace() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["init", "--name", "myws", "--schema", "default@1.0.0"])
.arg(&ws)
.assert()
.success();
memstead()
.current_dir(&ws)
.args(["schema", "new", "acme"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args(["schema", "install", "acme"])
.assert()
.success();
assert!(
ws.join(".memstead/schemas/acme@0.1.0/schema.yaml")
.is_file()
);
assert!(
ws.join(".memstead/schemas/acme@0.1.0/types/note.yaml")
.is_file()
);
}
#[test]
fn schema_new_refusals_carry_next_commands() {
let tmp = TempDir::new().unwrap();
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success();
let before = std::fs::read_to_string(tmp.path().join("acme/schema.yaml")).unwrap();
let err = stderr_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.failure(),
);
assert!(
err.contains("SCHEMA_PACKAGE_EXISTS"),
"typed code; got: {err}"
);
assert!(
err.contains("memstead schema validate acme"),
"next command; got: {err}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("acme/schema.yaml")).unwrap(),
before,
"the existing package is untouched",
);
let err = stderr_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "Acme Corp!"])
.assert()
.failure(),
);
assert!(err.contains("lowercase"), "states the rule; got: {err}");
assert!(
err.contains("memstead schema new acme-corp"),
"suggested correction as a runnable command; got: {err}",
);
std::fs::create_dir(tmp.path().join("busy")).unwrap();
std::fs::write(tmp.path().join("busy/x.txt"), "x").unwrap();
let err = stderr_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "busy"])
.assert()
.failure(),
);
assert!(err.contains("TARGET_NOT_EMPTY"), "typed code; got: {err}");
assert!(err.contains("x.txt"), "names the finding; got: {err}");
}
#[test]
fn generated_artifacts_speak_mem_vocabulary_only() {
let retired_noun = ["va", "ult"].concat();
let tmp = TempDir::new().unwrap();
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success();
let scaffold = format!(
"{}{}",
std::fs::read_to_string(tmp.path().join("acme/schema.yaml")).unwrap(),
std::fs::read_to_string(tmp.path().join("acme/types/note.yaml")).unwrap(),
);
assert!(
!scaffold.to_lowercase().contains(&retired_noun),
"scaffold speaks mem only"
);
let root = tmp.path().join("qs");
let out = stdout_of(memstead().arg("quickstart").arg(&root).assert().success());
assert!(
!out.to_lowercase().contains(&retired_noun),
"quickstart report speaks mem only"
);
}
#[test]
fn every_refusal_on_these_paths_names_a_next_command() {
let tmp = TempDir::new().unwrap();
let dirty = tmp.path().join("dirty");
std::fs::create_dir(&dirty).unwrap();
std::fs::write(dirty.join("code.rs"), "x").unwrap();
let cases: Vec<String> = vec![
stderr_of(memstead().arg("quickstart").arg(&dirty).assert().failure()),
{
let weird = tmp.path().join("统一");
std::fs::create_dir(&weird).unwrap();
stderr_of(memstead().arg("quickstart").arg(&weird).assert().failure())
},
{
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success();
stderr_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.failure(),
)
},
stderr_of(
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "BAD NAME"])
.assert()
.failure(),
),
];
for (i, err) in cases.iter().enumerate() {
assert!(
err.contains("memstead "),
"refusal #{i} must include an exact next command; got: {err}",
);
}
}
fn assert_filesystem_shape_disclosure(out: &str, ctx: &str) {
let mut needles = vec![
"filesystem-mem",
"cannot install mems from the registry",
"memstead mem-repo init",
];
if cfg!(feature = "mem-repo") {
needles.push("memstead install");
needles.push("UNSUPPORTED_WORKSPACE_SHAPE");
} else {
needles.push("this lean build does not carry them");
}
for needle in needles {
assert!(
out.contains(needle),
"{ctx}: shape disclosure must name `{needle}`; got:\n{out}",
);
}
}
#[test]
fn quickstart_receipt_discloses_the_shape_it_picked() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("disclosed-graph");
let out = stdout_of(
memstead()
.args(["quickstart", "--agent", "claude-code"])
.arg(&root)
.assert()
.success(),
);
assert_filesystem_shape_disclosure(&out, "quickstart receipt");
}
#[test]
fn init_receipt_discloses_the_shape_it_picked() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("strict-graph");
let out = stdout_of(
memstead()
.args([
"init",
"--name",
"strict-graph",
"--schema",
"default@1.3.0",
])
.arg(&root)
.assert()
.success(),
);
assert_filesystem_shape_disclosure(&out, "init receipt");
}
#[cfg(feature = "mem-repo")]
#[test]
fn mem_repo_init_discloses_its_shape_symmetrically() {
let tmp = TempDir::new().unwrap();
let out = stdout_of(
memstead()
.args(["mem-repo", "init"])
.arg(tmp.path())
.assert()
.success(),
);
for needle in ["mem-repo", "git", "memstead quickstart"] {
assert!(
out.contains(needle),
"mem-repo init receipt must name `{needle}`; got:\n{out}",
);
}
}
#[test]
fn json_receipts_carry_the_whole_disclosure_not_just_the_label() {
fn assert_disclosure(payload: &serde_json::Value, want_shape: &str, ctx: &str) {
let d = &payload["workspace_shape_disclosure"];
assert_eq!(d["shape"], want_shape, "{ctx}: shape; got {payload}");
for key in ["summary", "cannot", "other_shape", "other_shape_command"] {
let v = d[key].as_str().unwrap_or_default();
assert!(
!v.is_empty(),
"{ctx}: `{key}` must be present and non-empty; got {d}",
);
}
assert_ne!(
d["other_shape"], want_shape,
"{ctx}: the other shape must differ from this one; got {d}",
);
}
let tmp = TempDir::new().unwrap();
let assert = memstead()
.args(["quickstart", "--json", "--agent", "claude-code"])
.arg(tmp.path().join("json-qs"))
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
assert_disclosure(&payload, "filesystem-mem", "quickstart --json");
let assert = memstead()
.args([
"init",
"--json",
"--name",
"json-init",
"--schema",
"default@1.3.0",
])
.arg(tmp.path().join("json-init"))
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
assert_disclosure(&payload, "filesystem-mem", "init --json");
}
#[cfg(feature = "mem-repo")]
#[test]
fn mem_repo_init_json_carries_the_whole_disclosure() {
let tmp = TempDir::new().unwrap();
let assert = memstead()
.args(["mem-repo", "init", "--json"])
.arg(tmp.path())
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
let d = &payload["workspace_shape_disclosure"];
assert_eq!(d["shape"], "mem-repo", "got {payload}");
assert_eq!(d["other_shape"], "filesystem-mem", "got {d}");
assert!(
d["other_shape_command"]
.as_str()
.unwrap_or_default()
.contains("memstead quickstart"),
"must name the other shape's command; got {d}",
);
}
#[test]
fn every_verb_the_receipt_names_is_runnable_or_flagged_as_absent() {
let tmp = TempDir::new().unwrap();
let out = stdout_of(
memstead()
.args(["quickstart", "--agent", "claude-code"])
.arg(tmp.path().join("named-cmds"))
.assert()
.success(),
);
let help = stdout_of(memstead().arg("--help").assert().success());
let disowned =
out.contains("this lean build has no") || out.contains("this lean build does not carry");
for verb in ["install", "mem-repo", "quickstart", "overview", "delete"] {
if !out.contains(&format!("memstead {verb}")) {
continue;
}
let listed = help.lines().any(|l| l.trim_start().starts_with(verb));
assert!(
listed || disowned,
"the receipt names `memstead {verb}`, this build's help does not list it, and the \
receipt never says the build lacks it \u{2014} the reader would hit `unrecognized \
subcommand`.\n--- receipt ---\n{out}\n--- help ---\n{help}",
);
}
}
#[cfg(feature = "mem-repo")]
#[test]
fn mem_repo_only_subcommand_still_refuses_after_disclosure() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("refusing-graph");
memstead()
.args(["quickstart", "--agent", "claude-code"])
.arg(&root)
.assert()
.success();
let assert = memstead()
.current_dir(&root)
.args(["install", "acme/notes", "--json"])
.assert()
.failure();
let body = stdout_of(assert);
let envelope: serde_json::Value =
serde_json::from_str(body.trim()).expect("--json refusal is JSON");
assert_eq!(
envelope["code"], "UNSUPPORTED_WORKSPACE_SHAPE",
"install must still refuse by shape; got: {envelope}",
);
let message = envelope["message"].as_str().unwrap_or_default();
assert!(
message.contains("mem-repo"),
"refusal must still name the recovering shape; got: {message}",
);
}
#[test]
fn quickstart_receipt_names_in_session_verification_and_the_restart() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("verifiable-graph");
let assert = memstead()
.args(["quickstart", "--json", "--agent", "claude-code"])
.arg(&root)
.assert()
.success();
let payload: serde_json::Value =
serde_json::from_str(&stdout_of(assert)).expect("quickstart --json emits JSON");
let next = payload["next_action"].as_str().unwrap_or_default();
assert!(
next.contains("Restart") && next.contains("registers"),
"the restart must still be named for what it does; got: {next}",
);
let verify = payload["verify_now"]
.as_array()
.expect("receipt carries in-session verification steps");
let rendered = verify
.iter()
.map(|v| v["command"].as_str().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("--version"),
"verification must exercise the binary the wiring points at; got:\n{rendered}",
);
assert!(
rendered.contains("memstead overview"),
"verification must name a read of the graph itself; got:\n{rendered}",
);
let wired = payload["mcp_command"].as_str().expect("mcp_command");
let status = std::process::Command::new(wired)
.arg("--version")
.status()
.expect("the wired memstead-mcp binary must be runnable");
assert!(status.success(), "`{wired} --version` must succeed");
let root2 = tmp.path().join("verifiable-graph-md");
let out = stdout_of(
memstead()
.args(["quickstart", "--agent", "claude-code"])
.arg(&root2)
.assert()
.success(),
);
assert!(
out.contains("--version") && out.contains("Restart"),
"markdown receipt must carry both the in-session check and the restart; got:\n{out}",
);
}
#[test]
fn the_receipts_printed_commands_run_verbatim_from_the_callers_cwd() {
fn commands_in_markdown(out: &str) -> Vec<String> {
out.split('`')
.skip(1)
.step_by(2)
.map(str::trim)
.filter(|s| {
s.contains(' ')
&& (s.starts_with("cd ") || s.starts_with("codex ") || s.contains("memstead"))
})
.filter(|s| !s.contains("quickstart") && !s.contains('<'))
.map(str::to_string)
.collect()
}
fn path_for(has_memstead: bool) -> String {
if has_memstead {
format!(
"{}:{}",
Path::new(env!("CARGO_BIN_EXE_memstead"))
.parent()
.unwrap()
.display(),
std::env::var("PATH").unwrap_or_default(),
)
} else {
"/usr/bin:/bin".to_string()
}
}
fn run(command: &str, cwd: &Path, path_has_memstead: bool) {
let path = path_for(path_has_memstead);
let out = std::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(cwd)
.env("PATH", path)
.output()
.expect("spawn sh");
assert!(
out.status.success(),
"the receipt printed `{command}`, which fails when run as printed \
(PATH carries memstead: {path_has_memstead}):\n--- stdout ---\n{}\n\
--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let cases = [
("My Graph", true, false),
("-dashed-graph", true, false),
("bob's graph", true, false),
("offpath-graph", false, false),
("awkward-binary-graph", false, true),
];
for (dir, on_path, awkward_binary) in cases {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
std::fs::create_dir_all(&outer).unwrap();
let bin = if awkward_binary {
let dir = tmp.path().join("bob's bin dir");
std::fs::create_dir_all(&dir).unwrap();
for name in ["memstead", "memstead-mcp"] {
let src = Path::new(env!("CARGO_BIN_EXE_memstead"))
.parent()
.unwrap()
.join(name);
if src.is_file() {
std::fs::copy(&src, dir.join(name)).unwrap();
}
}
dir.join("memstead")
} else {
Path::new(env!("CARGO_BIN_EXE_memstead")).to_path_buf()
};
let assert = Command::new(&bin)
.current_dir(&outer)
.env("PATH", path_for(on_path))
.args(["quickstart", "--agent", "claude-code", "--"])
.arg(dir)
.assert()
.success();
let out = stdout_of(assert);
let disowned = out.contains("this lean build has no")
|| out.contains("this lean build does not carry");
for command in commands_in_markdown(&out) {
if disowned && command.contains("mem-repo") {
continue;
}
run(&command, &outer, on_path);
}
}
let tmp = TempDir::new().unwrap();
let outer = tmp.path().join("outer");
std::fs::create_dir_all(&outer).unwrap();
let assert = memstead()
.current_dir(&outer)
.args([
"quickstart",
"--json",
"--agent",
"claude-code",
"--agent",
"codex",
"--",
"My Graph",
])
.assert()
.success();
let payload: serde_json::Value = serde_json::from_str(&stdout_of(assert)).unwrap();
let steps = payload["verify_now"]
.as_array()
.expect("verify_now is an array of steps");
assert!(!steps.is_empty(), "got {payload}");
for s in steps {
let c = s["command"].as_str().unwrap_or_default();
assert!(
!c.contains('`') && !c.starts_with("- "),
"machine surface must carry a bare command, got: {c}",
);
run(c, &outer, true);
}
run(
payload["seed_entity_delete_command"]
.as_str()
.expect("seed delete command"),
&outer,
true,
);
let next = payload["next_action"].as_str().unwrap_or_default();
let (_, tail) = next
.rsplit_once("then try: ")
.unwrap_or_else(|| panic!("next_action names a follow-up command; got: {next}"));
run(tail, &outer, true);
let codex_action = payload["agents"]
.as_array()
.and_then(|a| a.iter().find(|w| w["target"] == "codex"))
.map(|w| w["action"].as_str().unwrap_or_default().to_string())
.expect("codex wiring action");
let codex_cmd = codex_action.split('`').nth(1).unwrap_or_else(|| {
panic!("codex action carries a backticked command; got: {codex_action}")
});
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!("set -- {codex_cmd}; echo $#"))
.output()
.expect("spawn sh");
let argc: usize = String::from_utf8_lossy(&out.stdout).trim().parse().unwrap();
assert_eq!(
argc, 6,
"`{codex_cmd}` must parse as exactly `codex mcp add memstead -- <path>` (6 words); \
an unquoted path with a space would split into more",
);
for key in ["workspace_root", "config_path"] {
let v = payload[key].as_str().unwrap_or_default();
assert!(
Path::new(v).is_absolute(),
"`{key}` must be absolute for a machine consumer, got: {v}",
);
}
}
#[test]
fn create_relation_lands_edges_on_a_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("edge-graph");
memstead()
.args(["quickstart", "--agent", "claude-code"])
.arg(&root)
.assert()
.success();
memstead()
.current_dir(&root)
.args([
"create",
"--title",
"Edge Source",
"--type",
"concept",
"--section",
"definition=A concept that points at the seed entity.",
"--section",
"explanation=Its only job is to carry one inline relation, so the edge is \
observable after creation.",
"--relation",
"CONTRASTS_WITH:edge-graph--welcome-to-memstead",
])
.assert()
.success();
let out = stdout_of(
memstead()
.current_dir(&root)
.args(["entity", "edge-graph--edge-source", "--include-relations"])
.assert()
.success(),
);
assert!(
out.contains("welcome-to-memstead"),
"the inline relation must be readable after creation; got:\n{out}",
);
}
#[test]
fn create_help_no_longer_claims_a_mem_repo_only_relation_limit() {
let out = stdout_of(memstead().args(["create", "--help"]).assert().success());
assert!(
!out.contains("Mem-repo workspaces only"),
"create --help must not claim a lifted restriction; got:\n{out}",
);
}
#[test]
fn help_lists_quickstart_and_schema_new() {
let out = stdout_of(memstead().arg("--help").assert().success());
assert!(
out.contains("quickstart"),
"top-level help lists quickstart; got: {out}"
);
let out = stdout_of(memstead().args(["schema", "--help"]).assert().success());
assert!(out.contains("new"), "schema help lists new; got: {out}");
}
#[test]
fn scaffold_package_is_exactly_two_files() {
let tmp = TempDir::new().unwrap();
memstead()
.current_dir(tmp.path())
.args(["schema", "new", "acme"])
.assert()
.success();
let mut files: Vec<String> = walk(tmp.path().join("acme").as_path());
files.sort();
assert_eq!(
files,
vec!["schema.yaml".to_string(), "types/note.yaml".to_string()]
);
}
fn walk(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().to_string();
if entry.path().is_dir() {
for sub in walk(&entry.path()) {
out.push(format!("{name}/{sub}"));
}
} else {
out.push(name);
}
}
out
}