memstead-cli 0.18.1

Command-line interface for Memstead — query and mutate typed entity graphs from the shell. Default build produces the full `memstead` binary (multi-mem, git-backed); `--no-default-features` builds the lean folder-only surface.
Documentation
#![cfg(feature = "mem-repo")]
// `memstead mem set-schema` ships only in the full build.

//! A type's `resolution` declaration (backlog-decisions plan B5): the
//! `open_questions` health axis lists, per mem, the open entities whose
//! condition section is empty (`resolution_missing`) and the open entities
//! whose condition nobody has checked under the declared kind
//! (`resolution_unchecked`), byte-identical between the CLI JSON and the
//! MCP `structured_content`; a closed entity is never listed; a check of
//! another `x-` kind does not count; and a declaration naming a section
//! the type does not declare refuses at `schema install` naming it.

use std::fs;
use std::io::{BufRead, BufReader, Write as _};
use std::path::Path;
use std::process::{Command as StdCommand, Stdio};

use assert_cmd::Command;
use tempfile::TempDir;

fn memstead() -> Command {
    Command::cargo_bin("memstead").expect("memstead binary must be built by cargo")
}

const MANIFEST: &str = r#"name: inquiry
version: 0.1.0
description: resolution fixture
when_to_use: tests
types:
  - question
relationships:
  mode: strict
  definitions:
    - name: _default
      description: fallback
      default_weight: 1.0
community:
  resolution: 1.0
  seed: 42
"#;

fn question_type(condition_section: &str, check_kind: &str) -> String {
    format!(
        "name: question\ndescription: an open unknown\nwhen_to_use: Here\nsections:\n  - key: question\n    heading: Question\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\n  - key: condition\n    heading: Condition\n    required: false\n    search_weight: 5.0\n    catch_all: false\n    write_rules: []\nmetadata_fields:\n  - key: status\n    description: s\n    field_type: string\n    default_value: open\n    enum_values: [open, answered]\nresolution:\n  status_field: status\n  open_values: [open]\n  condition_section: {condition_section}\n  check_kind: {check_kind}\ntitle_weight: 100.0\ntext_fields:\n  - question\nhierarchy_relationship: _default\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - question\n  - condition\n  - status\nhealth_required_fields:\n  - question\nstaleness_threshold_days: 90\nwrite_rules: []\n"
    )
}

fn write_package(dir: &Path, condition_section: &str, check_kind: &str) {
    fs::create_dir_all(dir.join("types")).unwrap();
    fs::write(dir.join("schema.yaml"), MANIFEST).unwrap();
    fs::write(
        dir.join("types").join("question.yaml"),
        question_type(condition_section, check_kind),
    )
    .unwrap();
}

/// A folder workspace whose mem `qs` pins a schema declaring `resolution`
/// on `question` (condition section `condition`, check kind `check_kind`).
fn workspace(check_kind: &str) -> TempDir {
    let ws = TempDir::new().unwrap();
    memstead()
        .current_dir(ws.path())
        .args([
            "init",
            "--name",
            "qs",
            "--schema",
            "default@1.3.0",
            "--quiet",
        ])
        .assert()
        .success();
    write_package(
        &ws.path()
            .join(".memstead")
            .join("schemas")
            .join("inquiry@0.1.0"),
        "condition",
        check_kind,
    );
    memstead()
        .current_dir(ws.path())
        .args(["mem", "set-schema", "qs", "inquiry@0.1.0", "--quiet"])
        .assert()
        .success();
    ws
}

fn question(ws: &Path, title: &str, status: &str, condition: Option<&str>) {
    let mut args: Vec<String> = vec![
        "create".into(),
        "--quiet".into(),
        "--type".into(),
        "question".into(),
        "--title".into(),
        title.into(),
        "--section".into(),
        "question=what?".into(),
        "--metadata".into(),
        format!("status={status}"),
        "--identity".into(),
        "author-one".into(),
        "--role".into(),
        "author".into(),
    ];
    if let Some(c) = condition {
        args.push("--section".into());
        args.push(format!("condition={c}"));
    }
    memstead().current_dir(ws).args(&args).assert().success();
}

fn check(ws: &Path, entity: &str, kind: &str, identity: &str) {
    memstead()
        .current_dir(ws)
        .args([
            "check",
            entity,
            "--verdict",
            "ok",
            "--kind",
            kind,
            "--method",
            "test",
            "--identity",
            identity,
            "--role",
            "checker",
            "--quiet",
        ])
        .assert()
        .success();
}

fn cli_axis(ws: &Path) -> serde_json::Value {
    let out = memstead()
        .current_dir(ws)
        .args(["health", "--include", "open_questions", "--json", "--quiet"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    v["open_questions"].clone()
}

fn mcp_axis(ws: &Path) -> serde_json::Value {
    let bin = assert_cmd::cargo::cargo_bin("memstead-mcp");
    let mut child = StdCommand::new(bin)
        .current_dir(ws)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("memstead-mcp spawns");
    let mut stdin = child.stdin.take().unwrap();
    let mut reader = BufReader::new(child.stdout.take().unwrap());
    let mut send = |line: &str| {
        stdin.write_all(line.as_bytes()).unwrap();
        stdin.write_all(b"\n").unwrap();
        stdin.flush().unwrap();
    };
    let mut recv = |id: u64| -> serde_json::Value {
        loop {
            let mut line = String::new();
            if reader.read_line(&mut line).unwrap() == 0 {
                panic!("mcp exited before answering id {id}");
            }
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
                && v["id"] == serde_json::json!(id)
            {
                return v;
            }
        }
    };
    send(
        r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
    );
    recv(1);
    send(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
    send(
        r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"memstead_health","arguments":{"include":["open_questions"]}}}"#,
    );
    let reply = recv(2);
    let _ = child.kill();
    let _ = child.wait();
    reply["result"]["structuredContent"]["open_questions"].clone()
}

fn ids(axis: &serde_json::Value, kind: &str) -> Vec<String> {
    axis["qs"][kind]["items"]
        .as_array()
        .map(|a| {
            a.iter()
                .map(|i| i["id"].as_str().unwrap().to_string())
                .collect()
        })
        .unwrap_or_default()
}

/// B5 AC1: missing, unchecked, and checked read apart; the closed entity
/// is absent; CLI and MCP agree byte for byte.
#[test]
fn the_axis_reads_missing_unchecked_and_checked_apart() {
    let ws = workspace("verification");
    let root = ws.path();
    question(root, "No condition", "open", None);
    question(root, "Unchecked", "open", Some("when the benchmark lands"));
    question(root, "Checked", "open", Some("when the benchmark lands"));
    question(root, "Closed", "answered", None);
    check(root, "qs--checked", "verification", "checker-two");

    let cli = cli_axis(root);
    assert_eq!(
        ids(&cli, "resolution_missing"),
        vec!["qs--no-condition"],
        "{cli}"
    );
    assert_eq!(
        ids(&cli, "resolution_unchecked"),
        vec!["qs--unchecked"],
        "{cli}"
    );
    assert_eq!(
        cli["qs"]["resolution_unchecked"]["items"][0]["check_kind"],
        "verification"
    );
    assert_eq!(cli["qs"]["resolution_missing"]["count"], 1);
    assert!(!cli.to_string().contains("qs--closed"), "{cli}");
    assert!(!cli.to_string().contains("\"qs--checked\""), "{cli}");
    assert_eq!(cli["qs"]["total_open"], 2, "{cli}");

    let mcp = mcp_axis(root);
    assert_eq!(
        serde_json::to_string(&cli).unwrap(),
        serde_json::to_string(&mcp).unwrap()
    );
}

/// B5 AC1 refusal complement, second half: an `x-` check of a kind other
/// than the declared one does not count as checked; the declared `x-`
/// kind does.
#[test]
fn a_foreign_check_counts_only_under_the_declared_kind() {
    let ws = workspace("x-review");
    let root = ws.path();
    question(root, "Reviewed", "open", Some("when two people agree"));
    question(root, "Walked", "open", Some("when two people agree"));
    check(root, "qs--reviewed", "x-review", "checker-two");
    check(root, "qs--walked", "x-step-walk", "checker-two");
    let cli = cli_axis(root);
    assert_eq!(
        ids(&cli, "resolution_unchecked"),
        vec!["qs--walked"],
        "{cli}"
    );
    assert_eq!(
        cli["qs"]["resolution_unchecked"]["items"][0]["check_kind"],
        "x-review"
    );
}

/// B5 AC1 refusal complement, first half: a declaration naming a section
/// the type does not declare refuses at install, naming it.
#[test]
fn a_declaration_naming_an_undeclared_section_refuses_at_install() {
    let ws = TempDir::new().unwrap();
    memstead()
        .current_dir(ws.path())
        .args([
            "init",
            "--name",
            "qs",
            "--schema",
            "default@1.3.0",
            "--quiet",
        ])
        .assert()
        .success();
    let pkg = ws.path().join("bad-inquiry");
    write_package(&pkg, "nowhere", "verification");
    let out = memstead()
        .current_dir(ws.path())
        .args(["schema", "install", pkg.to_str().unwrap(), "--quiet"])
        .output()
        .unwrap();
    assert!(!out.status.success());
    let text =
        String::from_utf8_lossy(&out.stderr).to_string() + &String::from_utf8_lossy(&out.stdout);
    assert!(text.contains("resolution declaration is invalid"), "{text}");
    assert!(
        text.contains("`condition_section` names no section"),
        "{text}"
    );
    assert!(text.contains("nowhere"), "{text}");
    // `schema validate` says the same without a workspace.
    let out = memstead()
        .current_dir(ws.path())
        .args(["schema", "validate", pkg.to_str().unwrap(), "--quiet"])
        .output()
        .unwrap();
    assert!(!out.status.success());
}