use assert_cmd::Command;
use memstead_base::binding::{BindingV1, BuildMode};
use memstead_base::pipeline::IngestTrigger;
use serde_json::Value;
use std::path::Path;
use tempfile::TempDir;
fn memstead() -> Command {
Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}
fn write_store(root: &Path, rel: &str, contents: &str) {
let path = root.join(".memstead").join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn bare_workspace() -> TempDir {
let tmp = TempDir::new().unwrap();
write_store(tmp.path(), "workspace.toml", "");
tmp
}
fn fixture(mode: &str, deny: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_store(root, "workspace.toml", "");
write_store(
root,
"mediums/engine/src.json",
r#"{"name":"src","type":"codebase","pointer":"../public"}"#,
);
write_store(
root,
"facets/engine/source-tree.json",
r#"{"name":"source-tree","medium":"src","scope":[{"path":"../public/**/*.rs","mode":"allow"}]}"#,
);
write_store(
root,
"projections/engine/graph.json",
r#"{"intent":"the engine graph","source_facets":["source-tree"],"reference_mems":["plugin"],"destination_mem":"engine","rules":{"routing":"r"}}"#,
);
write_store(
root,
"ingests/engine-graph.json",
&format!(
r#"{{"projection":"engine/graph","mode":"{mode}","trigger":"loop","batch_size":20,"deny_paths":[{deny}],"post_actions":{{"archive_source":true}}}}"#
),
);
tmp
}
fn read_binding(root: &Path) -> BindingV1 {
let bytes = std::fs::read(root.join(".memstead/projections/engine/graph.json")).unwrap();
serde_json::from_slice(&bytes).expect("promoted projection file must parse as a v1 binding")
}
#[test]
fn migrate_promotes_projection_to_v1_binding() {
let tmp = fixture("discovery", r#""dev","**/VISION.md""#);
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).expect("--json migrate must emit JSON");
assert_eq!(env["ok"], true);
assert_eq!(env["migrated"], 1);
assert_eq!(env["bindings"][0], "engine/graph");
let b = read_binding(root);
assert_eq!(b.version, 1);
assert_eq!(b.destination_mem, "engine");
assert_eq!(b.intent.as_deref(), Some("the engine graph"));
assert_eq!(b.reference_mems, vec!["plugin".to_string()]);
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert_eq!(
b.operations.build.as_ref().unwrap().trigger,
IngestTrigger::Loop
);
assert_eq!(b.operations.build.as_ref().unwrap().batch_size, 20);
assert_eq!(
b.operations.build.as_ref().unwrap().post_actions,
Some(serde_json::json!({ "archive_source": true }))
);
assert!(b.operations.sync.is_none());
assert!(b.operations.verify.is_none());
assert_eq!(
b.deny_paths,
vec!["dev/**".to_string(), "**/VISION.md".to_string()]
);
let json = serde_json::to_string(&b).unwrap();
let back: BindingV1 = serde_json::from_str(&json).unwrap();
assert_eq!(back, b);
assert!(!root.join(".memstead/ingests/engine-graph.json").exists());
let warnings = env["warnings"].as_array().unwrap();
assert!(
warnings
.iter()
.any(|w| w["kind"] == "note" && w["message"].as_str().unwrap_or("").contains("dev/**")),
"expected a deny-dialect note, got {warnings:?}"
);
}
#[test]
fn migrate_dry_run_writes_nothing() {
let tmp = fixture("discovery", "");
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate", "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["dry_run"], true);
assert_eq!(env["migrated"], 1);
assert!(root.join(".memstead/ingests/engine-graph.json").exists());
let raw =
std::fs::read_to_string(root.join(".memstead/projections/engine/graph.json")).unwrap();
assert!(
!raw.contains("\"version\""),
"gen-2 shape must be untouched"
);
assert!(!raw.contains("operations"), "gen-2 shape must be untouched");
}
#[test]
fn migrate_legal_codebase_binding_validates_clean() {
let tmp = fixture("discovery", "");
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate", "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
let warnings = env["warnings"].as_array().unwrap();
assert!(
warnings.iter().all(|w| w["kind"] != "capability"),
"a legal codebase binding must not surface a capability refusal: {warnings:?}"
);
}
#[test]
fn migrate_surfaces_preparation_capability_warning() {
let tmp = fixture("discovery", "");
let root = tmp.path();
write_store(
root,
"facets/engine/source-tree.json",
r#"{"name":"source-tree","medium":"src","scope":[{"path":"../public/**/*.rs","mode":"allow"}],"preparation":"pdf-to-markdown"}"#,
);
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate", "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
let warnings = env["warnings"].as_array().unwrap();
assert!(
warnings.iter().any(|w| w["kind"] == "capability"
&& w["message"]
.as_str()
.unwrap_or("")
.contains("pdf-to-markdown")),
"expected a preparation capability warning, got {warnings:?}"
);
}
#[test]
fn migrate_refinement_mode_refuses_typed() {
let tmp = fixture("refinement", "");
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_MIGRATE_REFINEMENT");
assert!(root.join(".memstead/ingests/engine-graph.json").exists());
}
#[test]
fn migrate_dangling_ref_refuses_typed() {
let tmp = fixture("discovery", "");
let root = tmp.path();
write_store(
root,
"ingests/engine-graph.json",
r#"{"projection":"engine/missing","mode":"discovery","trigger":"loop","batch_size":20,"deny_paths":[]}"#,
);
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_MIGRATE_DANGLING_REF");
}
#[test]
fn migrate_outside_workspace_is_typed() {
let tmp = TempDir::new().unwrap();
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "projection", "migrate"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "WORKSPACE_NOT_INITIALISED");
assert_ne!(env["code"], "INTERNAL");
}
fn triple_bytes(root: &Path, mem: &str, stem: &str) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let m = root.join(format!(".memstead/mediums/{mem}/{stem}.json"));
let f = root.join(format!(".memstead/facets/{mem}/{stem}.json"));
let p = root.join(format!(".memstead/projections/{mem}/{stem}.json"));
(
std::fs::read(m).unwrap(),
std::fs::read(f).unwrap(),
std::fs::read(p).unwrap(),
)
}
#[test]
fn init_codebase_scaffolds_all_three_with_full_operations() {
let tmp = bare_workspace();
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"init",
"--mem",
"engine",
"--source",
"../public",
"--medium-type",
"codebase",
"--intent",
"model the engine",
"--name",
"graph",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).expect("--json init must emit JSON");
assert_eq!(env["binding"], "engine/graph");
assert_eq!(
env["created"],
serde_json::json!([
".memstead/mediums/engine/graph.json",
".memstead/facets/engine/graph.json",
".memstead/projections/engine/graph.json",
])
);
assert_eq!(
env["operations"],
serde_json::json!(["build", "sync", "verify"])
);
assert_eq!(env["warnings"], serde_json::json!([]));
let keys: Vec<&str> = env
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, vec!["binding", "created", "operations", "warnings"]);
assert!(root.join(".memstead/mediums/engine/graph.json").is_file());
assert!(root.join(".memstead/facets/engine/graph.json").is_file());
assert!(
root.join(".memstead/projections/engine/graph.json")
.is_file()
);
let bytes = std::fs::read(root.join(".memstead/projections/engine/graph.json")).unwrap();
let b: BindingV1 = serde_json::from_slice(&bytes).expect("scaffold must be a v1 binding");
assert_eq!(b.version, 1);
assert_eq!(b.destination_mem, "engine");
assert_eq!(b.intent.as_deref(), Some("model the engine"));
assert_eq!(b.source_facets, vec!["graph".to_string()]);
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert!(b.operations.sync.is_some());
assert!(b.operations.verify.is_some());
assert_eq!(
b.prune.as_ref().unwrap().guarantee,
memstead_base::binding::PruneGuarantee::NeverClobber
);
let round = serde_json::to_string(&b).unwrap();
let back: BindingV1 = serde_json::from_str(&round).unwrap();
assert_eq!(back, b);
}
#[test]
fn init_output_matches_the_v1_schema_golden() {
let tmp = bare_workspace();
let root = tmp.path();
memstead()
.current_dir(root)
.args([
"projection",
"init",
"--mem",
"docs",
"--source",
"../src",
"--medium-type",
"codebase",
"--intent",
"Keep the reference mem true to the source tree",
"--name",
"guide",
])
.assert()
.success();
let emitted: Value = serde_json::from_slice(
&std::fs::read(root.join(".memstead/projections/docs/guide.json")).unwrap(),
)
.unwrap();
let golden_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
"../../plugins/claude-code/schemas/memstead-plugin/v1/examples/binding.from-init.json",
);
let golden: Value = serde_json::from_slice(&std::fs::read(&golden_path).unwrap_or_else(|e| {
panic!(
"golden fixture unreadable at {}: {e}",
golden_path.display()
)
}))
.unwrap();
assert_eq!(
emitted,
golden,
"`projection init` output drifted from the committed v1 binding golden \
({}). Update the golden AND re-check binding.schema.json — the two must \
move together.",
golden_path.display()
);
}
#[test]
fn init_filesystem_scaffolds_full_operations() {
let tmp = bare_workspace();
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"init",
"--mem",
"docs",
"--source",
"../docs",
"--medium-type",
"filesystem",
"--name",
"manual",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["binding"], "docs/manual");
assert_eq!(
env["operations"],
serde_json::json!(["build", "sync", "verify"])
);
assert_eq!(env["warnings"], serde_json::json!([]));
}
#[test]
fn init_web_source_scaffolds_build_only_with_warning() {
let tmp = bare_workspace();
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"init",
"--mem",
"research",
"--source",
"https://example.com/docs",
"--medium-type",
"web",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["binding"], "research/docs");
assert_eq!(env["operations"], serde_json::json!(["build"]));
let warnings = env["warnings"].as_array().unwrap();
assert!(!warnings.is_empty(), "web must warn about the deferral");
assert!(
warnings
.iter()
.any(|w| w.as_str().unwrap_or("").contains("out of scope")
&& w.as_str().unwrap_or("").contains("operator decision 7")),
"expected a deferral warning, got {warnings:?}"
);
let bytes = std::fs::read(root.join(".memstead/projections/research/docs.json")).unwrap();
let b: BindingV1 = serde_json::from_slice(&bytes).unwrap();
assert!(b.operations.sync.is_none());
assert!(b.operations.verify.is_none());
}
#[test]
fn init_existing_binding_refuses_without_touching_disk() {
let tmp = bare_workspace();
let root = tmp.path();
let args = [
"projection",
"init",
"--mem",
"engine",
"--source",
"../public",
"--medium-type",
"codebase",
"--name",
"graph",
];
memstead().current_dir(root).args(args).assert().success();
let before = triple_bytes(root, "engine", "graph");
let output = memstead()
.current_dir(root)
.args(
["--json"]
.iter()
.chain(args.iter())
.copied()
.collect::<Vec<_>>(),
)
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_EXISTS");
assert_eq!(env["details"]["binding"], "engine/graph");
let after = triple_bytes(root, "engine", "graph");
assert_eq!(before, after, "refused init must not touch disk");
}
#[test]
fn init_outside_workspace_is_typed() {
let tmp = TempDir::new().unwrap();
let output = memstead()
.current_dir(tmp.path())
.args([
"--json",
"projection",
"init",
"--mem",
"m",
"--source",
"../x",
"--medium-type",
"codebase",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "WORKSPACE_NOT_INITIALISED");
assert_ne!(env["code"], "INTERNAL");
}
fn migrated_build_only_workspace() -> TempDir {
let tmp = fixture("discovery", "");
memstead()
.current_dir(tmp.path())
.args(["projection", "migrate"])
.assert()
.success();
tmp
}
#[test]
fn enable_sync_adds_block_to_codebase_binding() {
let tmp = migrated_build_only_workspace();
let root = tmp.path();
let before = read_binding(root);
assert!(
before.operations.sync.is_none(),
"precondition: no sync block"
);
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "sync", "engine/graph"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).expect("--json enable must emit JSON");
assert_eq!(env["binding"], "engine/graph");
assert_eq!(env["enabled"], "sync");
assert_eq!(env["operations"], serde_json::json!(["build", "sync"]));
let after = read_binding(root);
let sync = after
.operations
.sync
.as_ref()
.expect("sync block was added");
assert_eq!(sync.trigger, IngestTrigger::Manual);
assert_eq!(
sync.batch_size,
before.operations.build.as_ref().unwrap().batch_size
);
assert!(after.operations.verify.is_none());
assert_eq!(after.version, before.version);
assert_eq!(after.intent, before.intent);
assert_eq!(after.source_facets, before.source_facets);
assert_eq!(after.reference_mems, before.reference_mems);
assert_eq!(after.destination_mem, before.destination_mem);
assert_eq!(after.deny_paths, before.deny_paths);
assert_eq!(after.coverage_semantics, before.coverage_semantics);
assert_eq!(after.rules, before.rules);
assert_eq!(after.operations.build, before.operations.build);
let json = serde_json::to_string(&after).unwrap();
let back: BindingV1 = serde_json::from_str(&json).unwrap();
assert_eq!(back, after);
}
#[test]
fn enable_sync_on_web_refuses_and_leaves_file_identical() {
let tmp = bare_workspace();
let root = tmp.path();
memstead()
.current_dir(root)
.args([
"projection",
"init",
"--mem",
"research",
"--source",
"https://example.com/docs",
"--medium-type",
"web",
])
.assert()
.success();
let path = root.join(".memstead/projections/research/docs.json");
let before = std::fs::read(&path).unwrap();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "sync", "research/docs"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_CAPABILITY_UNSUPPORTED");
assert!(
env["message"]
.as_str()
.unwrap_or("")
.contains("out of scope"),
"capability refusal must state the gap: {env}"
);
let after = std::fs::read(&path).unwrap();
assert_eq!(before, after, "refused enable must not touch disk");
}
#[test]
fn enable_already_present_op_refuses() {
let tmp = migrated_build_only_workspace();
let root = tmp.path();
let path = root.join(".memstead/projections/engine/graph.json");
let before = std::fs::read(&path).unwrap();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "build", "engine/graph"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_OP_ALREADY_ENABLED");
assert_eq!(env["details"]["operation"], "build");
assert_eq!(std::fs::read(&path).unwrap(), before, "refusal is a no-op");
memstead()
.current_dir(root)
.args(["projection", "enable", "sync", "engine/graph"])
.assert()
.success();
let with_sync = std::fs::read(&path).unwrap();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "sync", "engine/graph"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_OP_ALREADY_ENABLED");
assert_eq!(
std::fs::read(&path).unwrap(),
with_sync,
"re-enable is a no-op and does not corrupt the binding"
);
let b = read_binding(root);
assert!(b.operations.sync.is_some());
assert!(b.operations.verify.is_none());
}
#[test]
fn enable_missing_binding_is_not_found() {
let tmp = bare_workspace();
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "sync", "engine/nope"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_NOT_FOUND");
assert_eq!(env["details"]["binding"], "engine/nope");
}
#[test]
fn enable_malformed_binding_id_refuses() {
let tmp = bare_workspace();
let root = tmp.path();
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "enable", "verify", "noslash"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "PROJECTION_INVALID_NAME");
}
#[test]
fn enable_outside_workspace_is_typed() {
let tmp = TempDir::new().unwrap();
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "projection", "enable", "sync", "engine/graph"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["code"], "WORKSPACE_NOT_INITIALISED");
assert_ne!(env["code"], "INTERNAL");
}
fn git(repo: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example")
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn git_head(repo: &Path) -> String {
String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string()
}
fn advance_workspace() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_store(
root,
"workspace.toml",
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
);
write_store(
root,
"state/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}]}"#,
);
write_store(
root,
"mediums/engine/graph.json",
r#"{"name":"graph","type":"codebase","pointer":"src","change_detection":"git"}"#,
);
write_store(
root,
"facets/engine/source-tree.json",
r#"{"name":"source-tree","medium":"graph","scope":[{"path":"src/**/*.rs","mode":"allow"}]}"#,
);
write_store(
root,
"projections/engine/graph.json",
r#"{"version":1,"intent":"model the engine","source_facets":["source-tree"],"reference_mems":[],"destination_mem":"engine","deny_paths":[],"coverage_semantics":"exhaustive","operations":{"build":{"mode":"discovery","trigger":"loop","batch_size":20},"sync":{"trigger":"manual","batch_size":20}}}"#,
);
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
git(&src, &["init", "-q"]);
std::fs::write(src.join("a.rs"), "one").unwrap();
std::fs::write(src.join("b.rs"), "bee").unwrap();
git(&src, &["add", "a.rs", "b.rs"]);
git(&src, &["commit", "-qm", "base"]);
let baseline = git_head(&src);
std::fs::write(src.join("a.rs"), "one-longer").unwrap();
std::fs::remove_file(src.join("b.rs")).unwrap();
git(&src, &["add", "-A"]);
git(&src, &["commit", "-qm", "head1"]);
let mem_meta = root.join("engine-mem").join(".memstead");
std::fs::create_dir_all(&mem_meta).unwrap();
std::fs::write(
mem_meta.join("config.json"),
format!(
r#"{{"format":1,"schema":"default@1.0.0","syncState":{{"engine/graph/source-tree#synced":"{baseline}"}}}}"#
),
)
.unwrap();
tmp
}
#[test]
fn advance_records_dispositions_completes_and_gates_unknown() {
let tmp = advance_workspace();
let root = tmp.path();
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
r#"{"src/a.rs": "worked"}"#,
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).expect("advance --json must emit JSON");
assert_eq!(env["binding"], "engine/graph");
assert_eq!(env["completed"], false);
assert_eq!(env["pending"], 1);
assert_eq!(env["disposed"], 1);
assert_eq!(env["remainder"]["deleted"], serde_json::json!(["src/b.rs"]));
assert_eq!(env["remainder"]["modified"], serde_json::json!([]));
let store_path = root.join(".memstead/state/advance/engine/graph.json");
let before = std::fs::read(&store_path).unwrap();
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
r#"{"src/never.rs": "worked"}"#,
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_ADVANCE_UNKNOWN_ARTIFACT");
let after = std::fs::read(&store_path).unwrap();
assert_eq!(before, after, "refused advance must not touch the store");
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
r#"{"src/b.rs": "worked"}"#,
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["completed"], true);
assert_eq!(env["pending"], 0);
assert_eq!(env["disposed"], 2, "a.rs (persisted) + b.rs (this call)");
assert_eq!(
env["tokens_written"],
serde_json::json!(["engine/graph/source-tree#synced"])
);
assert!(!store_path.exists());
}
#[test]
fn advance_missing_binding_is_typed() {
let tmp = bare_workspace();
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"projection",
"advance",
"engine/nope",
"--dispositions",
"{}",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_NOT_FOUND");
assert_ne!(env["code"], "INTERNAL");
}
#[test]
fn advance_invalid_dispositions_is_typed() {
let tmp = bare_workspace();
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
"not-json",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_INVALID_DISPOSITIONS");
}
#[test]
fn exclude_records_authored_exclusion_and_gates_non_member() {
let tmp = advance_workspace();
let root = tmp.path();
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"exclude",
"engine/graph",
"--exclusions",
r#"{"src/a.rs": "mined; warrants no entity"}"#,
])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).expect("exclude --json must emit JSON");
assert_eq!(env["binding"], "engine/graph");
assert_eq!(env["added"], 1);
assert_eq!(env["excluded"], 1);
let store: Value = serde_json::from_slice(
&std::fs::read(root.join(".memstead/state/advance/engine/graph.json")).unwrap(),
)
.unwrap();
assert_eq!(store["exclusions"]["src/a.rs"], "mined; warrants no entity");
let before = std::fs::read(root.join(".memstead/state/advance/engine/graph.json")).unwrap();
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"exclude",
"engine/graph",
"--exclusions",
r#"{"src/not-a-file.rs": "x"}"#,
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER");
let after = std::fs::read(root.join(".memstead/state/advance/engine/graph.json")).unwrap();
assert_eq!(before, after, "refused call must not touch the ledger");
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"exclude",
"engine/graph",
"--exclusions",
"not-json",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_INVALID_EXCLUSIONS");
}
#[test]
fn advance_outside_workspace_is_typed() {
let tmp = TempDir::new().unwrap();
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
"{}",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "WORKSPACE_NOT_INITIALISED");
assert_ne!(env["code"], "INTERNAL");
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_renders_for_scaffolded_binding() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args([
"projection",
"init",
"--mem",
"ws",
"--source",
"../src",
"--medium-type",
"codebase",
"--name",
"code",
])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["projection", "brief", "ws/code"])
.assert()
.success()
.get_output()
.stdout
.clone();
let brief = String::from_utf8(out).unwrap();
assert!(
brief.contains("ws/code"),
"brief must name the canonical binding id; got:\n{brief}"
);
assert!(
brief.contains("## Situation"),
"a discovery brief carries the Situation block; got:\n{brief}"
);
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_all_empty_store_reports_no_bindings() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["--json", "projection", "brief", "--all"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["no_bindings"], Value::Bool(true));
assert!(
env.get("skipped").is_none(),
"empty store must NOT report the backing-off `skipped` outcome; got:\n{env}"
);
let out = memstead()
.current_dir(&ws)
.args(["projection", "brief", "--all"])
.assert()
.success()
.get_output()
.stdout
.clone();
let md = String::from_utf8(out).unwrap();
assert!(
md.contains("No bindings configured"),
"empty store gets a distinct no-bindings message; got:\n{md}"
);
assert!(
!md.contains("backing off"),
"empty store must not use the backing-off message; got:\n{md}"
);
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_verify_renders_measurement_only() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args([
"projection",
"init",
"--mem",
"ws",
"--source",
"../src",
"--medium-type",
"codebase",
"--name",
"code",
])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["projection", "brief", "ws/code", "--verify"])
.assert()
.success()
.get_output()
.stdout
.clone();
let brief = String::from_utf8(out).unwrap();
assert!(brief.contains("## Verify — measure fidelity, do not mutate"));
assert!(
brief.contains("Verify writes **nothing** into the destination mem"),
"C1 refusal present; got:\n{brief}"
);
assert!(
!brief.contains("## How to repair"),
"verify brief must not carry repair instructions; got:\n{brief}"
);
assert!(!brief.contains("## Open findings to repair"));
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_sync_renders_sole_writer_with_conservatism() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
memstead()
.current_dir(&ws)
.args([
"projection",
"init",
"--mem",
"ws",
"--source",
"../src",
"--medium-type",
"codebase",
"--name",
"code",
])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["projection", "brief", "ws/code", "--sync"])
.assert()
.success()
.get_output()
.stdout
.clone();
let brief = String::from_utf8(out).unwrap();
assert!(brief.contains("## Sync — repair the graph to match the source"));
assert!(brief.contains("sole maintenance writer"));
assert!(brief.contains("Sync commits nothing."));
assert!(
brief.contains("## First sync — adopting `ws`"),
"fresh mem gets adopt framing; got:\n{brief}"
);
assert!(brief.contains("## How to repair — be conservative"));
assert!(brief.contains("A dropped dependency FLAGS, it does not auto-remove."));
assert!(brief.contains("`[commit <hash>]` log-style entries"));
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_verify_sync_require_a_binding() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
for flag in ["--verify", "--sync"] {
let out = memstead()
.current_dir(&ws)
.args(["--json", "projection", "brief", flag])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_BRIEF_BINDING_REQUIRED");
assert_ne!(env["code"], "INTERNAL");
}
}
#[cfg(feature = "mem-repo")]
#[test]
fn brief_missing_binding_refuses() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
memstead()
.args(["mem-repo", "init", ws.to_str().unwrap(), "--no-gitignore"])
.assert()
.success();
let out = memstead()
.current_dir(&ws)
.args(["--json", "projection", "brief", "engine/nope"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "PROJECTION_NOT_FOUND");
assert_ne!(env["code"], "INTERNAL");
}
#[test]
fn brief_outside_workspace_is_typed() {
let tmp = TempDir::new().unwrap();
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "projection", "brief", "engine/graph"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(env["code"], "WORKSPACE_NOT_INITIALISED");
assert_ne!(env["code"], "INTERNAL");
}
#[test]
fn migrate_gen1_root_folder_promotes_to_v1_binding() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_store(root, "workspace.toml", "");
let write_root = |rel: &str, contents: &str| {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
};
write_root(
"scopes/engine/src.json",
r#"{"type":"codebase","scope":{"tree":[{"path":"../public/**/*.rs","mode":"allow"}]}}"#,
);
write_root(
"projections/engine/graph.json",
r#"{"intent":"the engine graph","sources":[{"scope_ref":"src"}],"destinations":[{"mem":"engine"}]}"#,
);
write_root(
"ingests/engine-graph.json",
r#"{"projection":"engine/graph","mode":"discovery","trigger":"loop","batch_size":20,"deny_paths":[]}"#,
);
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["migrated"], 1);
assert_eq!(env["bindings"][0], "engine/graph");
let b = read_binding(root);
assert_eq!(b.version, 1);
assert_eq!(b.destination_mem, "engine");
assert_eq!(b.source_facets, vec!["src".to_string()]);
assert_eq!(
b.operations.build.as_ref().unwrap().mode,
BuildMode::Discovery
);
assert!(!root.join(".memstead/ingests/engine-graph.json").exists());
assert!(root.join(".memstead/mediums/engine/src.json").exists());
assert!(root.join(".memstead/facets/engine/src.json").exists());
}
#[test]
fn migrate_gen1_dry_run_writes_nothing() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_store(root, "workspace.toml", "");
let write_root = |rel: &str, contents: &str| {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
};
write_root(
"scopes/engine/src.json",
r#"{"type":"codebase","scope":{"tree":[{"path":"../public/**/*.rs","mode":"allow"}]}}"#,
);
write_root(
"projections/engine/graph.json",
r#"{"intent":"the engine graph","sources":[{"scope_ref":"src"}],"destinations":[{"mem":"engine"}]}"#,
);
write_root(
"ingests/engine-graph.json",
r#"{"projection":"engine/graph","mode":"discovery","trigger":"loop","batch_size":20,"deny_paths":[]}"#,
);
let output = memstead()
.current_dir(root)
.args(["--json", "projection", "migrate", "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(env["dry_run"], true);
assert_eq!(env["migrated"], 1);
assert!(
!root
.join(".memstead/projections/engine/graph.json")
.exists()
);
assert!(!root.join(".memstead/mediums/engine/src.json").exists());
}
#[test]
fn brief_refuses_absent_build_then_enable_build_remedy_succeeds() {
let tmp = advance_workspace();
let root = tmp.path();
write_store(
root,
"projections/engine/graph.json",
r#"{"version":1,"intent":"model the engine","source_facets":["source-tree"],"reference_mems":[],"destination_mem":"engine","deny_paths":[],"coverage_semantics":"exhaustive","operations":{"verify":{"trigger":"manual","batch_size":20}}}"#,
);
let out = memstead()
.current_dir(root)
.args(["--json", "projection", "brief", "engine/graph"])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).expect("brief refusal must emit JSON");
assert_eq!(env["code"], "PROJECTION_BUILD_NOT_ENABLED");
assert!(
env["message"]
.as_str()
.unwrap_or("")
.contains("memstead projection enable build engine/graph"),
"message must carry the verbatim remedy: {env}",
);
memstead()
.current_dir(root)
.args(["projection", "enable", "build", "engine/graph"])
.assert()
.success();
memstead()
.current_dir(root)
.args(["projection", "brief", "engine/graph"])
.assert()
.success();
}
#[test]
fn advance_refuses_absent_sync_then_enable_sync_remedy_succeeds() {
let tmp = advance_workspace();
let root = tmp.path();
write_store(
root,
"projections/engine/graph.json",
r#"{"version":1,"intent":"model the engine","source_facets":["source-tree"],"reference_mems":[],"destination_mem":"engine","deny_paths":[],"coverage_semantics":"exhaustive","operations":{"build":{"mode":"discovery","trigger":"loop","batch_size":20}}}"#,
);
assert!(read_binding(root).operations.sync.is_none());
let out = memstead()
.current_dir(root)
.args([
"--json",
"projection",
"advance",
"engine/graph",
"--dispositions",
"{}",
])
.assert()
.failure()
.get_output()
.stdout
.clone();
let env: Value = serde_json::from_slice(&out).expect("advance refusal must emit JSON");
assert_eq!(env["code"], "PROJECTION_SYNC_NOT_ENABLED");
assert!(
env["message"]
.as_str()
.unwrap_or("")
.contains("memstead projection enable sync engine/graph"),
"message must carry the verbatim remedy: {env}",
);
memstead()
.current_dir(root)
.args(["projection", "enable", "sync", "engine/graph"])
.assert()
.success();
memstead()
.current_dir(root)
.args([
"projection",
"advance",
"engine/graph",
"--dispositions",
"{}",
])
.assert()
.success();
}
#[test]
fn brief_succeeds_with_no_verify_block() {
let tmp = advance_workspace();
memstead()
.current_dir(tmp.path())
.args(["projection", "brief", "engine/graph"])
.assert()
.success();
}
#[test]
fn migrate_consumes_reconcile_cursors_seeds_synced_and_deletes_it() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
write_store(
root,
"workspace.toml",
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
);
write_store(
root,
"state/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}]}"#,
);
let mem_meta = root.join("engine-mem").join(".memstead");
std::fs::create_dir_all(&mem_meta).unwrap();
std::fs::write(
mem_meta.join("config.json"),
br#"{"format":1,"schema":"default@1.0.0"}"#,
)
.unwrap();
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.rs"), "x").unwrap();
write_store(
root,
"mediums/engine/src.json",
r#"{"name":"src","type":"codebase","pointer":"src"}"#,
);
write_store(
root,
"facets/engine/source-tree.json",
r#"{"name":"source-tree","medium":"src","scope":[{"path":"src/**/*.rs","mode":"allow"}]}"#,
);
write_store(
root,
"projections/engine/graph.json",
r#"{"intent":"engine graph","source_facets":["source-tree"],"reference_mems":[],"destination_mem":"engine"}"#,
);
write_store(
root,
"ingests/engine-graph.json",
r#"{"projection":"engine/graph","mode":"discovery","trigger":"loop","batch_size":20}"#,
);
let src_abs = std::fs::canonicalize(&src).unwrap();
write_store(
root,
"reconcile-cursors.json",
&format!(r#"{{"engine:{}":"cafebabe0000"}}"#, src_abs.display()),
);
memstead()
.current_dir(root)
.args(["projection", "migrate"])
.assert()
.success();
let cfg: Value =
serde_json::from_slice(&std::fs::read(mem_meta.join("config.json")).unwrap()).unwrap();
assert_eq!(
cfg["syncState"]["engine/graph/source-tree#synced"], "cafebabe0000",
"migrate seeded #synced from the absolute-keyed cursor sha: {cfg}",
);
assert!(
!root.join(".memstead/reconcile-cursors.json").exists(),
"reconcile-cursors.json must be deleted by the migration",
);
}
#[test]
fn migrate_without_cursor_leaves_never_synced() {
let tmp = migrated_build_only_workspace();
let root = tmp.path();
assert!(!root.join(".memstead/reconcile-cursors.json").exists());
}