#![cfg(feature = "mem-repo")]
use std::fs;
use std::path::Path;
use assert_cmd::Command;
use memstead_git_branch::test_support::init_real_mem_repo_from_disk;
use predicates::prelude::*;
use predicates::str::contains;
use tempfile::TempDir;
fn seed_cli_test_mem(root: &Path) -> std::path::PathBuf {
let dir = root.join("cli-test");
fs::create_dir_all(&dir).unwrap();
make_test_mem(&dir);
init_real_mem_repo_from_disk(root, &[(&dir, "cli-test")]);
dir
}
fn make_test_mem(dir: &Path) {
let store = dir.join(".memstead");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("config.json"),
r#"{ "schema": "default@1.0.0" }"#,
)
.unwrap();
fs::write(
dir.join("alpha.md"),
r#"---
type: spec
created_date: 2026-01-01
last_modified: 2026-01-01
level: M0
---
# Alpha
## Identity
The alpha entity used to exercise CLI read commands.
## Purpose
Verifies memstead CLI integration end-to-end.
## Relationships
- **USES**: [[beta]]
"#,
)
.unwrap();
fs::write(
dir.join("beta.md"),
r#"---
type: spec
created_date: 2026-01-02
last_modified: 2026-01-02
level: M0
---
# Beta
## Identity
The beta entity, used by alpha via USES.
## Purpose
Provides a second entity so relations and path commands have something to trace.
"#,
)
.unwrap();
}
fn memstead() -> Command {
Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}
#[test]
fn status_markdown() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("status")
.assert()
.success()
.stdout(contains("# Graph status"))
.stdout(contains("Nodes: 2"));
}
#[test]
fn status_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
memstead()
.current_dir(tmp.path())
.args(["init", "--name", "demo", "--schema", "default@1.0.0"])
.assert()
.success();
memstead()
.current_dir(tmp.path())
.arg("status")
.assert()
.success()
.stdout(contains("# Graph status"))
.stdout(contains("Nodes: 0"));
}
#[test]
fn status_json() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "status"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&output).expect("valid JSON");
assert_eq!(parsed["total_nodes"], 2);
assert_eq!(parsed["real_nodes"], 2);
}
#[test]
fn entity_markdown() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["entity", "cli-test--alpha"])
.assert()
.success()
.stdout(contains("# Alpha"))
.stdout(contains("## Identity"))
.stdout(contains("_hash:"));
}
fn seed_filesystem_mem(tmp: &TempDir) {
memstead()
.current_dir(tmp.path())
.args(["init", "--name", "demo", "--schema", "default@1.0.0"])
.assert()
.success();
fs::write(
tmp.path().join("alpha.md"),
r#"---
type: spec
created_date: 2026-01-01
last_modified: 2026-01-01
level: M0
---
# Alpha
## Identity
A filesystem-mem entity exercising CLI parity.
## Purpose
Lets the read-side CLI commands round-trip without the mem-repo path.
"#,
)
.unwrap();
}
#[test]
fn list_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.arg("list")
.assert()
.success()
.stdout(contains("demo--alpha"));
}
#[test]
fn search_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.args(["search", "Alpha"])
.assert()
.success()
.stdout(contains("Alpha"));
}
#[test]
fn relations_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.args(["relations", "demo--alpha"])
.assert()
.success()
.stdout(contains("demo--alpha"));
}
#[test]
fn overview_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.arg("overview")
.assert()
.success();
}
#[test]
fn overview_json_promotes_mode_chunks_and_hints_as_siblings() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
let output = memstead()
.current_dir(tmp.path())
.args(["--json", "overview"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&output).expect("valid JSON");
assert!(
parsed.get("markdown").and_then(|v| v.as_str()).is_some(),
"markdown field must remain for the human-rendered view: {parsed}"
);
let mode = parsed
.get("overview_mode")
.and_then(|v| v.as_str())
.expect("overview_mode promoted as a sibling");
assert!(
matches!(mode, "complete" | "reduced" | "overbudget"),
"overview_mode must be a known value, got: {mode}"
);
assert!(
parsed
.get("total_chunks")
.and_then(|v| v.as_u64())
.is_some(),
"total_chunks must be a numeric sibling: {parsed}"
);
assert!(
parsed.get("hints").map(|v| v.is_array()).unwrap_or(false),
"hints must be an array sibling: {parsed}"
);
}
#[test]
fn context_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.args(["context", "demo--alpha"])
.assert()
.success();
}
#[test]
fn health_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
seed_filesystem_mem(&tmp);
memstead()
.current_dir(tmp.path())
.arg("health")
.assert()
.success()
.stdout(contains("# Graph health"));
}
#[test]
fn entity_works_on_filesystem_mem_workspace() {
let tmp = TempDir::new().unwrap();
memstead()
.current_dir(tmp.path())
.args(["init", "--name", "demo", "--schema", "default@1.0.0"])
.assert()
.success();
fs::write(
tmp.path().join("alpha.md"),
r#"---
type: spec
created_date: 2026-01-01
last_modified: 2026-01-01
level: M0
---
# Alpha
## Identity
A filesystem-mem entity exercising CLI parity.
## Purpose
Lets `memstead entity` round-trip without the mem-repo path.
"#,
)
.unwrap();
memstead()
.current_dir(tmp.path())
.args(["entity", "demo--alpha"])
.assert()
.success()
.stdout(contains("# Alpha"))
.stdout(contains("## Identity"))
.stdout(contains("_hash:"));
}
#[test]
fn entity_not_found_exit_code_3() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["entity", "cli-test--does-not-exist"])
.assert()
.failure()
.code(3)
.stderr(contains("Entity not found"));
}
#[test]
fn unknown_mem_exit_code_3() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
for args in [
vec!["search", "x", "--mem", "nope"],
vec!["list", "--mem", "nope"],
vec!["reload", "--mem", "nope"],
vec!["changes", "--since", "HEAD", "--mem", "nope"],
] {
memstead()
.current_dir(tmp.path())
.args(&args)
.assert()
.failure()
.code(3);
}
}
#[test]
fn entity_not_found_json_envelope() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
let assert = memstead()
.current_dir(tmp.path())
.args(["--json", "entity", "cli-test--does-not-exist"])
.assert()
.failure()
.code(3);
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let envelope: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON envelope");
assert_eq!(envelope["code"], "ENTITY_NOT_FOUND");
assert!(
envelope["message"]
.as_str()
.unwrap()
.contains("Entity not found")
);
}
#[test]
fn relations_markdown() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["relations", "cli-test--alpha"])
.assert()
.success()
.stdout(contains("## Outgoing"))
.stdout(contains("USES"));
}
#[test]
fn search_finds_entity() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["search", "alpha"])
.assert()
.success()
.stdout(contains("Alpha"));
}
#[test]
fn list_all() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("list")
.assert()
.success()
.stdout(contains("Alpha"))
.stdout(contains("Beta"));
}
#[test]
fn overview_runs() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("overview")
.assert()
.success();
}
#[test]
fn overview_with_include_renders_rich_content_without_full_only_warning() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args([
"overview",
"--include",
"mem_distribution,community_bridges,dangling_links",
])
.assert()
.success()
.stdout(contains("## Schemas"))
.stdout(contains("## Mems"))
.stdout(predicates::str::contains("OVERVIEW_RICH_CONTENT_FULL_ONLY").not())
.stdout(contains("`memstead type <name>`"));
}
#[test]
fn type_named() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["type", "spec"])
.assert()
.success()
.stdout(contains("# Type: spec"))
.stdout(contains("## Sections"));
}
#[test]
fn health_summary() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.arg("health")
.assert()
.success()
.stdout(contains("# Graph health"))
.stdout(contains("Entities: 2"));
}
fn seed_strict_health_workspace(root: &Path, with_violation: bool) {
let schema_dir = root
.join(".memstead")
.join("schemas")
.join("strictdecision");
fs::create_dir_all(schema_dir.join("types")).unwrap();
fs::write(
schema_dir.join("schema.yaml"),
r#"name: strictdecision
version: 0.1.0
description: Minimal schema pinning required_outgoing for the CLI --strict test.
when_to_use: Used only by memstead-cli health-strict integration tests.
types:
- decision
relationships:
mode: strict
definitions:
- name: PART_OF
description: hierarchy
default_weight: 3.0
acyclic: true
- name: REFERENCES
description: inline link
default_weight: 0.5
- name: CHOSEN
description: decision picked option
default_weight: 3.0
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#,
)
.unwrap();
fs::write(
schema_dir.join("types").join("decision.yaml"),
r#"name: decision
description: A choice with required CHOSEN edge.
when_to_use: tests
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: PART_OF
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
required_outgoing:
- relationships: [CHOSEN]
cardinality: at_least_one
"#,
)
.unwrap();
let mem_dir = root.join("strictmem");
fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{ "schema": "strictdecision@0.1.0" }"#,
)
.unwrap();
if with_violation {
fs::write(
mem_dir.join("violator.md"),
r#"---
type: decision
created_date: 2026-01-01
last_modified: 2026-01-01
---
# Violator
## Body
A decision entity authored without any CHOSEN edge — exercises the
`MISSING_REQUIRED_OUTGOING` health surface.
"#,
)
.unwrap();
} else {
for (name, other) in [("first", "second"), ("second", "first")] {
fs::write(
mem_dir.join(format!("{name}.md")),
format!(
"---\ntype: decision\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# {name}\n\n## Body\n\nA compliant decision.\n\n## Relationships\n\n- **CHOSEN**: [[strictmem--{other}]]\n"
),
)
.unwrap();
}
}
init_real_mem_repo_from_disk(root, &[(&mem_dir, "strictmem")]);
}
fn seed_signals_workspace(root: &Path, rebutters: usize) {
let schema_dir = root.join(".memstead").join("schemas").join("sigschema");
fs::create_dir_all(schema_dir.join("types")).unwrap();
fs::write(
schema_dir.join("schema.yaml"),
r#"name: sigschema
version: 0.1.0
description: Minimal schema declaring an edge_load signal for the CLI tests.
when_to_use: Used only by memstead-cli signals integration tests.
types:
- claim
- objection
relationships:
mode: strict
definitions:
- name: PART_OF
description: hierarchy
default_weight: 3.0
- name: REBUTS
description: attack
default_weight: 3.0
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#,
)
.unwrap();
let body = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
fs::write(
schema_dir.join("types").join("claim.yaml"),
format!(
"name: claim\ndescription: t\nwhen_to_use: tests\n{body}signals:\n - name: attack_load\n kind: edge_load\n relationships: [REBUTS]\n direction: in\n thresholds:\n - at_least: 1\n level: notice\n - at_least: 2\n level: warn\n"
),
)
.unwrap();
fs::write(
schema_dir.join("types").join("objection.yaml"),
format!("name: objection\ndescription: t\nwhen_to_use: tests\n{body}"),
)
.unwrap();
let mem_dir = root.join("sigmem");
fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{ "schema": "sigschema@0.1.0" }"#,
)
.unwrap();
fs::write(
mem_dir.join("claim.md"),
"---\ntype: claim\n---\n# Claim\n\n## Body\n\nthe attacked claim.\n",
)
.unwrap();
for i in 0..rebutters {
fs::write(
mem_dir.join(format!("obj-{i}.md")),
format!(
"---\ntype: objection\n---\n# Obj {i}\n\n## Body\n\nan objection.\n\n## Relationships\n\n- **REBUTS**: [[sigmem--claim]]\n"
),
)
.unwrap();
}
init_real_mem_repo_from_disk(root, &[(&mem_dir, "sigmem")]);
}
#[test]
fn health_strict_signals_warn_fails_notice_passes() {
let tmp = TempDir::new().unwrap();
seed_signals_workspace(tmp.path(), 2);
let assert = memstead()
.current_dir(tmp.path())
.args(["health", "--include", "signals", "--strict"])
.assert()
.failure()
.code(1)
.stderr(contains("signals: 1"));
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("attack_load") && stdout.contains("warn"),
"axis rendered before the exit: {stdout}"
);
assert!(
stdout.contains("sigmem--obj-0"),
"contributors ship with the number: {stdout}"
);
let tmp = TempDir::new().unwrap();
seed_signals_workspace(tmp.path(), 1);
memstead()
.current_dir(tmp.path())
.args(["health", "--include", "signals", "--strict"])
.assert()
.success();
}
fn seed_violator_workspace(root: &Path) {
let schema_dir = root.join(".memstead").join("schemas").join("debate");
fs::create_dir_all(schema_dir.join("types")).unwrap();
fs::write(
schema_dir.join("schema.yaml"),
r#"name: debate
version: 0.1.0
description: sealed-violator fixture for the CLI health projection tests.
when_to_use: Used only by memstead-cli integration tests.
types:
- question
relationships:
mode: strict
definitions:
- name: PART_OF
description: hier
default_weight: 3.0
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#,
)
.unwrap();
fs::write(
schema_dir.join("types").join("question.yaml"),
r#"name: question
description: t
when_to_use: tests
sections:
- key: answers
heading: Answers argued
required: true
search_weight: 10.0
write_rules: []
- key: notes
heading: Notes
required: false
search_weight: 3.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- answers
- notes
hierarchy_relationship: PART_OF
no_self_loop_relationships: []
updatable_fields:
- title
- answers
health_required_fields:
- answers
staleness_threshold_days: 90
write_rules: []
"#,
)
.unwrap();
let mem_dir = root.join("debatemem");
fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{ "schema": "debate@0.1.0" }"#,
)
.unwrap();
fs::write(
mem_dir.join("mismatch.md"),
"---\ntype: question\n---\n# Mismatch\n\n## Answers argued\n\nPresent.\n",
)
.unwrap();
fs::write(
mem_dir.join("absent.md"),
"---\ntype: question\n---\n# Absent\n",
)
.unwrap();
init_real_mem_repo_from_disk(root, &[(&mem_dir, "debatemem")]);
}
#[test]
fn health_missing_fields_carries_issue_codes() {
let tmp = TempDir::new().unwrap();
seed_violator_workspace(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "health", "--include", "missing_fields"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).expect("health --json is JSON");
let entries = json["missing_fields"].as_array().expect("include renders");
let entry_for = |id: &str| {
entries
.iter()
.find(|e| e["id"] == format!("debatemem--{id}"))
.unwrap_or_else(|| panic!("entry for {id}: {entries:?}"))
};
let mismatch = entry_for("mismatch");
assert_eq!(mismatch["missing"], serde_json::json!(["answers"]));
assert_eq!(mismatch["issues"][0]["code"], "SECTION_HEADING_MISMATCH");
assert!(
mismatch["issues"][0]["message"]
.as_str()
.unwrap()
.contains("is not missing"),
"message rides beside the code: {mismatch}"
);
let absent = entry_for("absent");
assert_eq!(absent["missing"], serde_json::json!(["answers"]));
assert_eq!(absent["issues"][0]["code"], "MISSING");
assert!(
absent["issues"][0]["message"]
.as_str()
.unwrap()
.contains("is empty"),
"message rides beside the code: {absent}"
);
}
#[test]
fn health_include_config_renders_workspace_config_projection() {
let tmp = TempDir::new().unwrap();
seed_strict_health_workspace(tmp.path(), false);
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "health", "--include", "config"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).expect("health --json is JSON");
for key in ["mems", "mutations", "plugin"] {
assert!(
json.get(key).is_some(),
"--include config must render `{key}`: {json}"
);
}
let mems = json["mems"].as_array().expect("mems detail array");
assert!(
mems.iter().any(|m| m["name"] == "strictmem"),
"per-mem detail names the writable mem: {mems:?}"
);
assert!(
json["mutations"].get("require_notes").is_some(),
"mutations posture rides the projection: {json}"
);
let out = memstead()
.current_dir(tmp.path())
.args(["--json", "health"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).expect("health --json is JSON");
for key in ["mems", "mutations", "plugin"] {
assert!(
json.get(key).is_none(),
"no config block without the opt-in: {json}"
);
}
}
#[test]
fn health_strict_exits_zero_when_no_violations() {
let tmp = TempDir::new().unwrap();
seed_strict_health_workspace(tmp.path(), false);
memstead()
.current_dir(tmp.path())
.args([
"health",
"--include",
"missing_required_outgoing",
"--strict",
])
.assert()
.success();
}
#[test]
fn health_strict_exits_one_when_violations_present() {
let tmp = TempDir::new().unwrap();
seed_strict_health_workspace(tmp.path(), true);
let assert = memstead()
.current_dir(tmp.path())
.args([
"health",
"--include",
"missing_required_outgoing",
"--strict",
])
.assert()
.failure()
.code(1)
.stderr(contains("strict mode"))
.stderr(contains("missing_required_outgoing: 1"));
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("Missing required outgoing"),
"violation report still rendered to stdout before non-zero exit; got:\n{stdout}"
);
}
#[test]
fn search_range_filter_narrows_and_surfaces_the_typed_codes() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("cli-test");
fs::create_dir_all(&dir).unwrap();
make_test_mem(&dir);
let plan = tmp.path().join("cli-plan");
fs::create_dir_all(plan.join(".memstead")).unwrap();
fs::write(
plan.join(".memstead").join("config.json"),
r#"{ "schema": "planning@0.1.0" }"#,
)
.unwrap();
init_real_mem_repo_from_disk(tmp.path(), &[(&dir, "cli-test"), (&plan, "cli-plan")]);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--range-filter",
"created_date_after=2025-01-01",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json["hits"].as_array().is_some_and(|r| !r.is_empty()),
"in-range date filter keeps the hit: {json}"
);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--range-filter",
"created_date_before=2020-01-01",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
json["hits"].as_array().is_some_and(|r| r.is_empty()),
"out-of-range date filter drops the hit: {json}"
);
memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--filter",
"level=M0",
"--range-filter",
"created_date_after=2025-01-01",
])
.assert()
.success()
.stdout(contains("alpha"));
memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--range-filter",
"bogus=1",
])
.assert()
.success()
.stdout(contains("RANGE_FILTER_KEY_MALFORMED"));
memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--mem",
"cli-plan",
"--type",
"step",
"--range-filter",
"decided_on_after=2020-01-01",
])
.assert()
.success()
.stdout(contains("RANGE_FILTER_TYPE_SCOPED"));
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--range-filter",
"min_nonexistent=1",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
out.windows(b"UNKNOWN_RANGE_FILTER_FIELD".len())
.any(|w| w == b"UNKNOWN_RANGE_FILTER_FIELD"),
"unknown field surfaces its code: {json}"
);
assert!(
json["hits"].as_array().is_some_and(|r| !r.is_empty()),
"unknown range field leaves results unfiltered, not empty: {json}"
);
memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"--type",
"spec",
"--range-filter",
"min_level=1",
])
.assert()
.success()
.stdout(contains("FIELD_NOT_RANGE_FILTERABLE"));
}
#[test]
fn workspace_override_flag_env_precedence_and_refusal() {
let ws = TempDir::new().unwrap();
seed_cli_test_mem(ws.path());
let elsewhere = TempDir::new().unwrap();
memstead()
.current_dir(elsewhere.path())
.env_remove("MEMSTEAD_WORKSPACE")
.args([
"--workspace",
ws.path().to_str().unwrap(),
"entity",
"cli-test--alpha",
])
.assert()
.success()
.stdout(contains("Alpha"));
memstead()
.current_dir(elsewhere.path())
.env("MEMSTEAD_WORKSPACE", ws.path())
.args(["entity", "cli-test--alpha"])
.assert()
.success()
.stdout(contains("Alpha"));
memstead()
.current_dir(elsewhere.path())
.env("MEMSTEAD_WORKSPACE", elsewhere.path())
.args([
"--workspace",
ws.path().to_str().unwrap(),
"entity",
"cli-test--alpha",
])
.assert()
.success()
.stdout(contains("Alpha"));
memstead()
.current_dir(ws.path())
.env_remove("MEMSTEAD_WORKSPACE")
.args([
"--workspace",
elsewhere.path().to_str().unwrap(),
"entity",
"cli-test--alpha",
])
.assert()
.failure()
.stderr(
contains("WORKSPACE_NOT_INITIALISED").and(contains(elsewhere.path().to_str().unwrap())),
);
memstead()
.current_dir(ws.path())
.env_remove("MEMSTEAD_WORKSPACE")
.args(["entity", "cli-test--alpha"])
.assert()
.success()
.stdout(contains("Alpha"));
}
#[test]
fn search_direction_and_expand_via_flags() {
let tmp = TempDir::new().unwrap();
seed_cli_test_mem(tmp.path());
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"exercise",
"--expand-via",
"USES",
"--direction",
"out",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let beta = json["hits"]
.as_array()
.unwrap()
.iter()
.find(|h| h["id"] == "cli-test--beta")
.unwrap_or_else(|| panic!("out-expansion reaches beta: {json}"));
assert_eq!(beta["expansion"]["via_edge"], "USES");
assert_eq!(
beta["expansion"]["via_direction"], "out",
"the reached entity reports its traversal direction: {beta}"
);
let out = memstead()
.current_dir(tmp.path())
.args([
"--json",
"search",
"exercise",
"--expand-via",
"USES",
"--direction",
"in",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(
!json["hits"]
.as_array()
.unwrap()
.iter()
.any(|h| h["id"] == "cli-test--beta"),
"in-expansion must not reach a descendant: {json}"
);
memstead()
.current_dir(tmp.path())
.args(["search", "alpha", "--direction", "sideways"])
.assert()
.failure()
.stderr(
contains("sideways")
.and(contains("out"))
.and(contains("in"))
.and(contains("both")),
);
}
#[test]
fn search_finds_identifier_shaped_metadata_value() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("cli-test");
fs::create_dir_all(&dir).unwrap();
make_test_mem(&dir);
fs::write(
dir.join("akte.md"),
r#"---
type: spec
aktenzeichen: 20/54/033
---
# Akte
## Identity
Die Akte selbst.
## Purpose
Nachweis für Suche in Metadaten.
"#,
)
.unwrap();
init_real_mem_repo_from_disk(tmp.path(), &[(&dir, "cli-test")]);
memstead()
.current_dir(tmp.path())
.args(["--json", "search", "20/54/033"])
.assert()
.success()
.stdout(contains("cli-test--akte").and(contains("\"metadata\"")));
}
#[test]
fn due_brief_cli_wiring() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["due", "--today", "2026-08-10"])
.assert()
.success()
.stdout(contains("# Due brief — 2026-08-10, window 90d"))
.stdout(contains("No mounted mem's schema declares a due axis"));
memstead()
.current_dir(tmp.path())
.args(["due", "--within", "90w"])
.assert()
.failure()
.stderr(contains("INVALID_INPUT"))
.stderr(contains("<N>d"));
memstead()
.current_dir(tmp.path())
.args(["due", "--help"])
.assert()
.success()
.stdout(contains("90d"));
}
#[test]
fn llms_txt_export_pins_the_document_shape() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["export", "--help"])
.assert()
.success()
.stdout(contains("llms-txt"));
let out = memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt"])
.assert()
.success()
.get_output()
.stdout
.clone();
let doc = String::from_utf8(out).unwrap();
assert!(doc.starts_with("# cli-test"), "header names the mem: {doc}");
for field in [
"Mem: ",
"Subject: ",
"Schema: ",
"Entities: ",
"Provenance: ",
] {
assert!(doc.contains(field), "header carries `{field}`: {doc}");
}
assert!(
doc.contains("Every non-stub entity of this Memstead graph follows, once"),
"the header states its own contract: {doc}"
);
assert!(
!doc.contains("this deployment vouches"),
"a CLI export never claims a deployment's provenance: {doc}"
);
memstead()
.current_dir(tmp.path())
.args(["relate", "cli-test--alpha", "USES", "cli-test--phantom"])
.assert()
.success();
let with_stub = String::from_utf8(
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
!with_stub.contains("# Phantom"),
"the stub entity has no rendered body in the document: {with_stub}"
);
assert!(
with_stub.contains("Entities: 2"),
"the count excludes the stub, matching the header's own promise: {with_stub}"
);
for (title, blurb) in [
("Amber", "Sorts between alpha and beta."),
("Delta", "Ordering fixture."),
("Echo", "Ordering fixture."),
("Gamma", "Ordering fixture."),
("Iota", "Ordering fixture."),
("Zeta", "Ordering fixture."),
] {
memstead()
.current_dir(tmp.path())
.args([
"create",
"--mem",
"cli-test",
"--title",
title,
"--type",
"spec",
"--section",
&format!("identity={blurb}"),
"--section",
"purpose=Ordering fixture.",
])
.assert()
.success();
}
let ordered = String::from_utf8(
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
let positions: Vec<usize> = [
"# Alpha", "# Amber", "# Beta", "# Delta", "# Echo", "# Gamma", "# Iota", "# Zeta",
]
.iter()
.map(|h| {
ordered
.find(h)
.unwrap_or_else(|| panic!("{h} present in: {ordered}"))
})
.collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(
positions, sorted,
"entities render in lexical id order over all eight: {ordered}"
);
assert!(doc.contains("_Type: spec_"), "type line rendered: {doc}");
assert_eq!(
doc.matches("# Alpha\n").count(),
1,
"each entity appears exactly once: {doc}"
);
assert!(doc.contains("\n---\n"), "entities are separated: {doc}");
assert!(
!doc.contains("[["),
"no raw wiki-link syntax survives: {doc}"
);
assert!(
!with_stub.contains("[["),
"an unresolvable reference is named in plain text, not left as \
wiki-link syntax: {with_stub}"
);
assert!(
with_stub.contains("cli-test--phantom"),
"and it is still named, so the reference is not silently dropped: \
{with_stub}"
);
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt", "--mem", "nope"])
.assert()
.failure()
.stderr(contains("UNKNOWN_MEM"));
}
#[test]
fn llms_txt_export_emits_only_the_two_pinned_link_forms() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("cli-test");
fs::create_dir_all(&dir).unwrap();
make_test_mem(&dir);
fs::write(
dir.join("beta.md"),
r#"---
type: spec
created_date: 2026-01-01
last_modified: 2026-01-01
level: M0
---
# Beta
## Identity
Beta references [[alpha]] by bare slug.
## Purpose
Exercises link resolution.
"#,
)
.unwrap();
init_real_mem_repo_from_disk(tmp.path(), &[(&dir, "cli-test")]);
let relative = String::from_utf8(
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
relative.contains("(entity/cli-test--alpha)"),
"without --base-url the link is document-relative: {relative}"
);
assert!(
!relative.contains("(/entity/"),
"and never root-relative — that is a third form: {relative}"
);
let absolute = String::from_utf8(
memstead()
.current_dir(tmp.path())
.args([
"export",
"--format",
"llms-txt",
"--base-url",
"https://example.com",
])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
absolute.contains("(https://example.com/entity/cli-test--alpha)"),
"with --base-url the link is absolute: {absolute}"
);
assert!(
!absolute.contains("[["),
"the bare slug resolved rather than surviving raw: {absolute}"
);
}
#[test]
fn llms_txt_foreign_slug_passes_resolve_and_never_guess() {
let tmp = TempDir::new().unwrap();
let mk = |name: &str, entities: &[(&str, &str)]| {
let dir = tmp.path().join(name);
fs::create_dir_all(dir.join(".memstead")).unwrap();
fs::write(
dir.join(".memstead/config.json"),
r#"{ "schema": "default@1.0.0" }"#,
)
.unwrap();
for (slug, body) in entities {
fs::write(
dir.join(format!("{slug}.md")),
format!(
"---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n\
# {slug}\n\n## Identity\n\n{body}\n\n## Purpose\n\nFixture.\n"
),
)
.unwrap();
}
dir
};
let home = mk("home", &[("seed", "Placeholder.")]);
let alpha = mk("alpha", &[("shared", "A."), ("only-alpha", "B.")]);
let beta = mk("beta", &[("shared", "C.")]);
init_real_mem_repo_from_disk(
tmp.path(),
&[(&home, "home"), (&alpha, "alpha"), (&beta, "beta")],
);
memstead()
.current_dir(tmp.path())
.args([
"create",
"--mem",
"home",
"--title",
"Hub",
"--type",
"spec",
"--section",
"identity=Refs [[shared]] and [[only-alpha]] and [[ghost]].",
"--section",
"purpose=Fixture.",
])
.assert()
.success();
let doc = String::from_utf8(
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "llms-txt", "--mem", "home"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap();
assert!(
doc.contains("(entity/alpha--only-alpha)"),
"a slug unique to one foreign mem resolves there: {doc}"
);
assert!(
!doc.contains("[[") && doc.contains("shared"),
"a slug two foreign mems both own is named in plain text, never \
guessed and never left as syntax: {doc}"
);
assert!(
doc.contains("ghost"),
"a reference to nothing is still named, just not linked: {doc}"
);
assert!(
!doc.contains("entity/home--shared") && !doc.contains("entity/home--ghost"),
"no link points at a stub the document excludes: {doc}"
);
}
#[test]
fn html_export_cli_wiring() {
let tmp = TempDir::new().unwrap();
let _mem = seed_cli_test_mem(tmp.path());
memstead()
.current_dir(tmp.path())
.args(["export", "--help"])
.assert()
.success()
.stdout(contains("html"));
memstead()
.current_dir(tmp.path())
.args(["--json", "export", "--format", "html", "-o", "out.html"])
.assert()
.success()
.stdout(contains("\"format\": \"html\""));
let html = std::fs::read_to_string(tmp.path().join("out.html")).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(!html.contains("<img"), "self-contained");
memstead()
.current_dir(tmp.path())
.args(["export", "--format", "html", "--mem", "nope"])
.assert()
.failure()
.stderr(contains("UNKNOWN_MEM"));
}