use std::process::Command;
use mnemo_engine::{KdfParams, Memory, MemoryType, Mnemo, MnemoConfig};
use tempfile::tempdir;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_mnemo")
}
fn fast_cfg(dimensions: usize) -> MnemoConfig {
MnemoConfig {
dimensions,
kdf: KdfParams::fast(),
..Default::default()
}
}
fn seed_db() -> (tempfile::TempDir, std::path::PathBuf, &'static str, String) {
let dir = tempdir().unwrap();
let path = dir.path().join("smoke.mnemo");
let pp = "smoke-pw";
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
let semantic_id = db
.remember(
Memory::new(
"the user prefers tea",
MemoryType::Semantic,
vec![1.0, 0.0, 0.0, 0.0],
)
.with_agent("assistant")
.with_importance(0.9),
)
.unwrap();
db.remember(
Memory::new(
"ran the deploy script",
MemoryType::Episodic,
vec![0.0, 1.0, 0.0, 0.0],
)
.with_agent("assistant")
.with_importance(0.4),
)
.unwrap();
db.remember(
Memory::new(
"escalate via pager",
MemoryType::Procedural,
vec![0.0, 0.0, 1.0, 0.0],
)
.with_agent("ops")
.with_importance(0.6),
)
.unwrap();
db.flush().unwrap();
db.close().unwrap();
(dir, path, pp, semantic_id.to_string())
}
fn run_cli(args: &[&str], pp: &str) -> (std::process::ExitStatus, String, String) {
let out = Command::new(bin())
.args(args)
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("failed to invoke mnemo binary");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
(out.status, stdout, stderr)
}
#[test]
fn list_table_shows_three_memories() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, stderr) =
run_cli(&["list", path.to_str().unwrap(), "--format", "table"], pp);
assert!(status.success(), "list failed: {stderr}");
assert!(
stdout.contains("showing 3 of 3 memories"),
"header missing: {stdout}"
);
assert!(
stdout.contains("the user prefers tea"),
"content missing: {stdout}"
);
assert!(stdout.contains("[semantic]"), "type tag missing: {stdout}");
}
#[test]
fn list_filters_by_type_and_agent() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"list",
path.to_str().unwrap(),
"--type",
"procedural",
"--agent",
"ops",
],
pp,
);
assert!(status.success());
assert!(stdout.contains("escalate via pager"));
assert!(!stdout.contains("prefers tea"));
assert!(!stdout.contains("deploy script"));
}
#[test]
fn list_json_emits_total_and_count() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"list",
path.to_str().unwrap(),
"--format",
"json",
"--limit",
"2",
],
pp,
);
assert!(status.success());
let v: serde_json::Value = serde_json::from_str(&stdout).expect("json output");
assert_eq!(v["total"], 3);
assert_eq!(v["count"], 2);
let memories = v["memories"].as_array().expect("memories array");
assert_eq!(memories.len(), 2);
assert!(
memories[0].get("vector").is_none(),
"vector should be omitted"
);
}
#[test]
fn list_jsonl_with_vector_includes_embedding() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"list",
path.to_str().unwrap(),
"--format",
"jsonl",
"--vector",
],
pp,
);
assert!(status.success());
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 3);
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line");
assert!(v["vector"].as_array().is_some(), "missing vector: {line}");
}
}
#[test]
fn get_by_id_table_and_json() {
let (_dir, path, pp, semantic_id) = seed_db();
let (status, stdout, _) = run_cli(
&["get", path.to_str().unwrap(), &semantic_id, "--verbose"],
pp,
);
assert!(status.success());
assert!(
stdout.contains(&semantic_id),
"id missing in table output: {stdout}"
);
assert!(stdout.contains("the user prefers tea"));
assert!(stdout.contains("imp=0.90"));
assert!(stdout.contains("scope"));
let (status, stdout, _) = run_cli(
&[
"get",
path.to_str().unwrap(),
&semantic_id,
"--format",
"json",
],
pp,
);
assert!(status.success());
let v: serde_json::Value = serde_json::from_str(&stdout).expect("json from get");
assert_eq!(v["content"], "the user prefers tea");
assert_eq!(v["memory_type"], "Semantic");
assert!(
v.get("vector").is_none(),
"vector should be omitted by default"
);
}
#[test]
fn get_missing_id_fails_cleanly() {
let (_dir, path, pp, _) = seed_db();
let (status, _stdout, stderr) = run_cli(
&["get", path.to_str().unwrap(), "01ARZ3NDEKTSV4RRFFQ69G5FAV"],
pp,
);
assert!(!status.success(), "missing id should exit non-zero");
assert!(
stderr.contains("not found"),
"expected NotFound message, got: {stderr}"
);
}
#[test]
fn recall_returns_query_nearest_first() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query",
"0.9,0.1,0.0,0.0",
"--top-k",
"2",
],
pp,
);
assert!(status.success());
let first_line = stdout.lines().next().unwrap_or("");
assert!(
first_line.contains("the user prefers tea"),
"expected tea first: {stdout}"
);
assert!(first_line.starts_with("score="));
}
#[test]
fn recall_json_carries_score_and_similarity() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query",
"0.9,0.1,0.0,0.0",
"--top-k",
"1",
"--format",
"json",
],
pp,
);
assert!(status.success());
let v: serde_json::Value = serde_json::from_str(&stdout).expect("json from recall");
assert_eq!(v["count"], 1);
let hits = v["hits"].as_array().expect("hits array");
assert!(hits[0]["score"].is_number());
assert!(hits[0]["similarity"].is_number());
assert_eq!(hits[0]["memory"]["content"], "the user prefers tea");
}
#[test]
fn recall_filters_by_type_excludes_other_kinds() {
let (_dir, path, pp, _) = seed_db();
let (status, stdout, _) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query",
"1.0,0.0,0.0,0.0",
"--type",
"procedural",
],
pp,
);
assert!(status.success());
assert!(stdout.contains("escalate via pager"));
assert!(!stdout.contains("prefers tea"));
assert!(!stdout.contains("deploy script"));
}
#[test]
fn recall_query_file_json_works() {
let (dir, path, pp, _) = seed_db();
let qf = dir.path().join("q.json");
std::fs::write(&qf, "[1.0, 0.0, 0.0, 0.0]").unwrap();
let (status, stdout, stderr) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query-file",
qf.to_str().unwrap(),
"--top-k",
"1",
],
pp,
);
assert!(status.success(), "recall failed: {stderr}");
assert!(stdout.contains("the user prefers tea"), "got: {stdout}");
}
#[test]
fn recall_query_file_whitespace_works() {
let (dir, path, pp, _) = seed_db();
let qf = dir.path().join("q.txt");
std::fs::write(&qf, "1.0\n0.0\n0.0\n0.0\n").unwrap();
let (status, stdout, stderr) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query-file",
qf.to_str().unwrap(),
"--top-k",
"1",
],
pp,
);
assert!(status.success(), "recall failed: {stderr}");
assert!(stdout.contains("the user prefers tea"), "got: {stdout}");
}
#[test]
fn recall_requires_query_source() {
let (_dir, path, pp, _) = seed_db();
let (status, _stdout, stderr) =
run_cli(&["recall", path.to_str().unwrap(), "--top-k", "1"], pp);
assert!(!status.success(), "recall without query should fail");
assert!(
stderr.contains("--query") || stderr.contains("query"),
"stderr should mention missing query, got: {stderr}"
);
}
#[test]
fn mcp_serve_initialize_list_and_call_tools() {
use std::io::Write;
use std::process::{Command, Stdio};
let dir = tempdir().unwrap();
let path = dir.path().join("mcp.mnemo");
let pp = "smoke-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember(
Memory::new(
"the user prefers tea",
MemoryType::Semantic,
vec![1.0, 0.0, 0.0, 0.0],
)
.with_importance(0.9),
)
.unwrap();
db.flush().unwrap();
}
let mut out_opt = None;
let mut last_stderr = String::new();
for attempt in 0..5u32 {
if attempt > 0 {
let backoff_ms = 100u64 << (attempt - 1);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
let mut child = Command::new(bin())
.args(["serve", path.to_str().unwrap(), "--mcp"])
.env("MNEMO_PASSPHRASE", pp)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn mnemo serve --mcp");
let stdin = child.stdin.as_mut().expect("child stdin missing");
let script = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"stats","arguments":{}}}"#,
];
for line in script {
writeln!(stdin, "{line}").unwrap();
}
drop(child.stdin.take());
let run = child.wait_with_output().expect("child wait failed");
if !run.status.success() {
let stderr_snapshot = String::from_utf8_lossy(&run.stderr).into_owned();
if stderr_snapshot.contains("file already locked") {
last_stderr = stderr_snapshot;
continue;
}
out_opt = Some(run);
break;
}
out_opt = Some(run);
break;
}
let out = out_opt.unwrap_or_else(|| {
panic!(
"mnemo serve --mcp lost the fork/flock race on all 5 attempts \
(bounded retry exhausted); last stderr:\n{last_stderr}"
)
});
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"server exited non-zero: status={:?} stderr={stderr}",
out.status
);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
lines.len() >= 3,
"expected ≥3 response lines (init, tools/list, stats), got {}: {stdout}",
lines.len()
);
let init: serde_json::Value = serde_json::from_str(lines[0]).expect("init parse");
assert_eq!(init["jsonrpc"], "2.0");
assert_eq!(init["id"], 1);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "mnemo");
let list: serde_json::Value = serde_json::from_str(lines[1]).expect("tools/list parse");
let tools = list["result"]["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in [
"about",
"remember",
"recall",
"forget",
"list",
"snapshot_list",
"stats",
] {
assert!(
names.contains(&expected),
"missing tool '{expected}' in {names:?}"
);
}
let stats: serde_json::Value = serde_json::from_str(lines[2]).expect("stats parse");
let content = &stats["result"]["content"][0]["text"];
assert!(
content.is_string(),
"stats content should be a text block: {stats}"
);
let stats_body: serde_json::Value =
serde_json::from_str(content.as_str().unwrap()).expect("stats body parse");
assert_eq!(
stats_body["memories"], 1,
"seeded exactly one live memory: {stats_body}"
);
}
#[test]
fn mcp_serve_refuses_without_passphrase() {
use std::process::{Command, Stdio};
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-nopw.mnemo");
{
let mut db = Mnemo::create(&path, "smoke-pw", fast_cfg(4)).unwrap();
db.remember(Memory::new(
"x",
MemoryType::Semantic,
vec![1.0, 0.0, 0.0, 0.0],
))
.unwrap();
db.flush().unwrap();
}
let out = Command::new(bin())
.args(["serve", path.to_str().unwrap(), "--mcp"])
.env_remove("MNEMO_PASSPHRASE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn failed");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"expected non-zero exit without passphrase"
);
assert!(
stderr.contains("MNEMO_PASSPHRASE"),
"stderr should mention the env var, got: {stderr}"
);
}
fn python_cmd() -> &'static str {
if cfg!(windows) {
"python"
} else {
"python3"
}
}
fn write_py_script(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p
}
const FAKE_EMBED_PY: &str = r#"
import sys, json, time
DIMS = 4
def encode(s):
axis = sum(bytearray(s.encode("utf-8"))) % DIMS
v = [0.0] * DIMS
v[axis] = float(len(s)) + 1.0 # non-zero, text-dependent
return v
for line in sys.stdin:
s = json.loads(line)
if s == "HANG":
time.sleep(3600)
elif s == "JUNK":
print("not-valid-json", flush=True)
elif s == "DOUBLE":
print(json.dumps(encode(s)), flush=True)
print(json.dumps(encode("stowaway")), flush=True)
else:
print(json.dumps(encode(s)), flush=True)
"#;
fn fake_vector_for(text: &str) -> Vec<f32> {
let axis = text.bytes().map(|b| b as usize).sum::<usize>() % 4;
let mut v = vec![0.0f32; 4];
v[axis] = text.len() as f32 + 1.0;
v
}
const BANNER_EMBED_PY: &str = r#"
import sys, json
print("[banner] model loaded", flush=True)
DIMS = 4
for line in sys.stdin:
s = json.loads(line)
axis = sum(bytearray(s.encode("utf-8"))) % DIMS
v = [0.0] * DIMS
v[axis] = float(len(s)) + 1.0
print(json.dumps(v), flush=True)
"#;
const CRASHING_EMBED_PY: &str = r#"
import sys, json
for line in sys.stdin:
_ = json.loads(line)
sys.exit(1)
"#;
const WRONG_WIDTH_EMBED_PY: &str = r#"
import sys, json
for line in sys.stdin:
_ = json.loads(line)
print(json.dumps([1.0, 2.0]), flush=True)
"#;
const ZERO_VECTOR_EMBED_PY: &str = r#"
import sys, json
for line in sys.stdin:
_ = json.loads(line)
print(json.dumps([0.0, 0.0, 0.0, 0.0]), flush=True)
"#;
const NON_FINITE_VECTOR_EMBED_PY: &str = r#"
import sys, json
for line in sys.stdin:
_ = json.loads(line)
print(json.dumps([1.0, 1e40, 2.0, 3.0]), flush=True)
"#;
const STARTUP_MARKER_EMBED_PY: &str = r#"
import sys, json, os
with open(os.environ["MNEMO_TEST_MARKER"], "a") as f:
f.write("start\n")
DIMS = 4
for line in sys.stdin:
s = json.loads(line)
axis = sum(bytearray(s.encode("utf-8"))) % DIMS
v = [0.0] * DIMS
v[axis] = float(len(s)) + 1.0
print(json.dumps(v), flush=True)
"#;
fn embed_cmd_arg(script_path: &std::path::Path) -> String {
format!("{} \"{}\"", python_cmd(), script_path.display())
}
#[test]
fn cli_remember_and_recall_text_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("em.mnemo");
let pp = "em-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember(Memory::scaffold_manifest(4)).unwrap();
db.flush().unwrap();
}
let script = write_py_script(dir.path(), "fake.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let out = Command::new(bin())
.args([
"remember",
path.to_str().unwrap(),
"--text",
"alpha",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
"--embed-normalize",
"true",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo remember");
assert!(
out.status.success(),
"mnemo remember failed: status={:?} stdout={} stderr={}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let ulid = String::from_utf8(out.stdout).unwrap();
assert!(
ulid.trim().len() >= 26,
"expected a ULID on stdout, got {ulid:?}"
);
let out = Command::new(bin())
.args([
"recall",
path.to_str().unwrap(),
"--text",
"alpha",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
"--top-k",
"5",
"--format",
"json",
"--no-track",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo recall");
assert!(
out.status.success(),
"mnemo recall failed: status={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("alpha"),
"recall --text output should contain the stored content, got: {stdout}"
);
}
#[test]
fn cli_recall_text_without_embed_cmd_errors_cleanly() {
let dir = tempdir().unwrap();
let path = dir.path().join("em-noec.mnemo");
let pp = "em-pw";
{
let _ = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
}
let out = Command::new(bin())
.args([
"recall",
path.to_str().unwrap(),
"--text",
"alpha",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo recall");
assert!(
!out.status.success(),
"recall --text without --embed-cmd should fail"
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.to_lowercase().contains("embed"),
"error should mention embedder, got: {combined}"
);
}
#[test]
fn cli_remember_text_crashing_embedder_errors_cleanly() {
let dir = tempdir().unwrap();
let path = dir.path().join("em-crash.mnemo");
let pp = "em-pw";
{
let _ = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
}
let script = write_py_script(dir.path(), "crash.py", CRASHING_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let out = Command::new(bin())
.args([
"remember",
path.to_str().unwrap(),
"--text",
"boom",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo remember");
assert!(
!out.status.success(),
"remember with a crashing embedder should fail"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.to_lowercase().contains("embed-cmd") || stderr.to_lowercase().contains("embedder"),
"stderr should mention the embedder failure, got: {stderr}"
);
}
#[test]
fn cli_remember_text_wrong_width_errors_cleanly() {
let dir = tempdir().unwrap();
let path = dir.path().join("em-ww.mnemo");
let pp = "em-pw";
{
let _ = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
}
let script = write_py_script(dir.path(), "ww.py", WRONG_WIDTH_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let out = Command::new(bin())
.args([
"remember",
path.to_str().unwrap(),
"--text",
"two-dims",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo remember");
assert!(
!out.status.success(),
"remember with wrong-width embedder should fail"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("dim") || stderr.contains("2"),
"stderr should mention the dimension mismatch, got: {stderr}"
);
}
#[test]
fn mcp_serve_with_embedder_text_params_and_persistent_child() {
use std::io::Write;
use std::process::{Command, Stdio};
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-em.mnemo");
let pp = "em-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember(Memory::scaffold_manifest(4)).unwrap();
db.flush().unwrap();
}
let marker = dir.path().join("startup.marker");
let script = write_py_script(dir.path(), "startup.py", STARTUP_MARKER_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let mut out_opt = None;
let mut last_stderr = String::new();
for attempt in 0..5u32 {
if attempt > 0 {
let backoff_ms = 100u64 << (attempt - 1);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
let mut child = Command::new(bin())
.args([
"serve",
path.to_str().unwrap(),
"--mcp",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
])
.env("MNEMO_PASSPHRASE", pp)
.env("MNEMO_TEST_MARKER", &marker)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn mnemo serve --mcp");
let stdin = child.stdin.as_mut().expect("child stdin missing");
let script = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"remember","arguments":{"content":"alpha","memory_type":"semantic","text":"alpha"}}}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"remember","arguments":{"content":"beta","memory_type":"semantic","text":"beta"}}}"#,
r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"recall","arguments":{"text":"alpha","top_k":5}}}"#,
];
for line in script {
writeln!(stdin, "{line}").unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().expect("child wait");
last_stderr = String::from_utf8_lossy(&out.stderr).to_string();
if out.status.success() {
out_opt = Some(out);
break;
}
if !last_stderr.contains("file already locked") {
panic!(
"mnemo serve --mcp failed (attempt {attempt}): status={:?} stderr={last_stderr}",
out.status
);
}
}
let out = out_opt.unwrap_or_else(|| {
panic!("mnemo serve --mcp never acquired the lock: stderr={last_stderr}")
});
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"id\":2"),
"expected a response to remember call id 2, got stdout: {stdout}"
);
assert!(
stdout.contains("\"id\":4"),
"expected a response to recall call id 4, got stdout: {stdout}"
);
assert!(
stdout.contains("alpha"),
"expected 'alpha' in recall output, got: {stdout}"
);
let marker_text = std::fs::read_to_string(&marker).unwrap_or_default();
let starts = marker_text.lines().filter(|l| *l == "start").count();
assert_eq!(
starts, 1,
"embedder child should have started once; marker file: {marker_text:?}"
);
}
#[test]
fn mcp_text_param_without_embedder_returns_helpful_error() {
use std::io::Write;
use std::process::{Command, Stdio};
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-noem.mnemo");
let pp = "em-pw";
{
let _ = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
}
let mut out_opt = None;
let mut last_stderr = String::new();
for attempt in 0..5u32 {
if attempt > 0 {
let backoff_ms = 100u64 << (attempt - 1);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
let mut child = Command::new(bin())
.args(["serve", path.to_str().unwrap(), "--mcp"])
.env("MNEMO_PASSPHRASE", pp)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mnemo serve --mcp");
let stdin = child.stdin.as_mut().unwrap();
let script = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"recall","arguments":{"text":"anything"}}}"#,
];
for line in script {
writeln!(stdin, "{line}").unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().expect("child wait");
last_stderr = String::from_utf8_lossy(&out.stderr).to_string();
if out.status.success() {
out_opt = Some(out);
break;
}
if !last_stderr.contains("file already locked") {
panic!(
"mnemo serve --mcp failed (attempt {attempt}): status={:?} stderr={last_stderr}",
out.status
);
}
}
let out = out_opt.unwrap_or_else(|| {
panic!("mnemo serve --mcp never acquired the lock: stderr={last_stderr}")
});
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--embed-cmd") || stdout.contains("embedder"),
"expected a helpful error mentioning --embed-cmd, got: {stdout}"
);
}
#[test]
fn cli_remember_text_hang_times_out_bounded() {
let dir = tempdir().unwrap();
let path = dir.path().join("hang.mnemo");
let pp = "em-pw";
{
let _ = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
}
let script = write_py_script(dir.path(), "hang.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let start = std::time::Instant::now();
let out = Command::new(bin())
.args([
"remember",
path.to_str().unwrap(),
"--text",
"HANG",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
"--embed-timeout",
"2",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo remember");
let elapsed = start.elapsed();
assert!(
!out.status.success(),
"remember on HANG should error (child never replies)"
);
assert!(
elapsed < std::time::Duration::from_secs(20),
"hang must trip the --embed-timeout bound; elapsed={elapsed:?}"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("timed out") || stderr.contains("embed-cmd"),
"stderr should name the timeout, got: {stderr}"
);
}
#[test]
fn embed_cmd_recovers_after_junk_reply_with_correct_vector() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "junk.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(5));
let err = em.embed(&["JUNK"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("malformed JSON") || msg.contains("respawn"),
"JUNK should raise a protocol error, got: {msg}"
);
let out = em.embed(&["abc"]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0], fake_vector_for("abc"));
}
#[test]
fn embed_cmd_recovers_after_double_reply_with_correct_vector() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "double.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(5));
let out = em.embed(&["DOUBLE"]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0], fake_vector_for("DOUBLE"));
std::thread::sleep(std::time::Duration::from_millis(200));
let next = em.embed(&["abc"]);
match next {
Ok(v) => {
assert_eq!(v.len(), 1);
assert_eq!(
v[0],
fake_vector_for("abc"),
"next call MUST return abc's vector, never a leftover"
);
}
Err(e) => {
let msg = format!("{e}");
assert!(
msg.contains("respawn") || msg.contains("outside a reply"),
"if first call errors it must be a clear protocol error, got: {msg}"
);
let recovery = em.embed(&["abc"]).unwrap();
assert_eq!(recovery[0], fake_vector_for("abc"));
}
}
}
#[test]
fn embed_cmd_rejects_all_zero_vector_and_respawns() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "zero.py", ZERO_VECTOR_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(5));
let err = em.embed(&["anything"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("all-zero") && msg.contains("respawn"),
"zero vector must surface as the dedicated protocol error, got: {msg}"
);
let err2 = em.embed(&["abc"]).unwrap_err();
assert!(format!("{err2}").contains("all-zero"));
}
#[test]
fn embed_cmd_rejects_non_finite_vector_and_respawns() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "non_finite.py", NON_FINITE_VECTOR_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(5));
let err = em.embed(&["anything"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("non-finite") && msg.contains("respawn"),
"±∞ reply must surface as the dedicated protocol error, got: {msg}"
);
}
#[test]
fn embed_cmd_detects_stdout_banner_before_requests() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "banner.py", BANNER_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(5));
std::thread::sleep(std::time::Duration::from_millis(200));
let err = em.embed(&["anything"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("stdout") || msg.contains("outside a reply") || msg.contains("malformed"),
"banner should surface as a stdout-protocol error, got: {msg}"
);
}
#[test]
fn embed_cmd_two_thousand_text_batch_does_not_deadlock() {
use mnemo_engine::{EmbedCmd, Embedder, EmbedderIdentity};
let dir = tempdir().unwrap();
let script = write_py_script(dir.path(), "batch.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let identity = EmbedderIdentity {
name: "fake-4d".into(),
dimensions: 4,
normalize: true,
};
let em = EmbedCmd::new(&ecmd, identity)
.unwrap()
.with_timeout(std::time::Duration::from_secs(30));
let texts: Vec<String> = (0..2000).map(|i| format!("text-{i:04}")).collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
let out = em.embed(&refs).unwrap();
assert_eq!(out.len(), 2000);
for i in [0, 123, 999, 1999] {
assert_eq!(
out[i],
fake_vector_for(&texts[i]),
"vector at index {i} must correspond to its input text"
);
}
}
#[test]
fn mcp_hang_bounded_and_server_survives_for_next_call() {
use std::io::Write;
use std::process::{Command, Stdio};
let dir = tempdir().unwrap();
let path = dir.path().join("mcp-hang.mnemo");
let pp = "em-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember(Memory::scaffold_manifest(4)).unwrap();
db.flush().unwrap();
}
let script = write_py_script(dir.path(), "hang.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let mut out_opt = None;
let mut last_stderr = String::new();
for attempt in 0..5u32 {
if attempt > 0 {
let backoff_ms = 100u64 << (attempt - 1);
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
let mut child = Command::new(bin())
.args([
"serve",
path.to_str().unwrap(),
"--mcp",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
"--embed-timeout",
"2",
])
.env("MNEMO_PASSPHRASE", pp)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn mnemo serve --mcp");
let stdin = child.stdin.as_mut().unwrap();
let script = [
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"remember","arguments":{"content":"will_hang","memory_type":"semantic","text":"HANG"}}}"#,
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"remember","arguments":{"content":"survives","memory_type":"semantic","text":"survives"}}}"#,
];
for line in script {
writeln!(stdin, "{line}").unwrap();
}
drop(child.stdin.take());
let start = std::time::Instant::now();
let out = child.wait_with_output().expect("child wait");
let elapsed = start.elapsed();
last_stderr = String::from_utf8_lossy(&out.stderr).to_string();
if out.status.success() {
assert!(
elapsed < std::time::Duration::from_secs(30),
"server should return before 30s even with a HANG; elapsed={elapsed:?}"
);
out_opt = Some(out);
break;
}
if !last_stderr.contains("file already locked") {
panic!(
"mnemo serve --mcp failed (attempt {attempt}): status={:?} stderr={last_stderr}",
out.status
);
}
}
let out = out_opt.unwrap_or_else(|| {
panic!("mnemo serve --mcp never acquired the lock: stderr={last_stderr}")
});
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"id\":2") && (stdout.contains("timed out") || stdout.contains("error")),
"expected an RPC error on the HANG call id 2, got stdout: {stdout}"
);
assert!(
stdout.contains("\"id\":3"),
"expected a response to call id 3 after the HANG; got: {stdout}"
);
assert!(
!stdout
.lines()
.any(|l| l.contains("\"id\":3") && l.contains("error")),
"call id 3 (post-HANG) should have succeeded, not errored: {stdout}"
);
}
#[test]
fn cli_index_rebuild_runs_rebuild_index() {
let dir = tempdir().unwrap();
let path = dir.path().join("rb.mnemo");
let pp = "bp1-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
for i in 0..40 {
let v = vec![((i % 4) + 1) as f32, 1.0, 2.0, 3.0];
db.remember(Memory::new(format!("m{i}"), MemoryType::Semantic, v))
.unwrap();
}
db.flush().unwrap();
db.build_index().unwrap();
db.flush().unwrap();
db.close().unwrap();
}
let (status, stdout, stderr) = run_cli(&["index", path.to_str().unwrap(), "--rebuild"], pp);
assert!(
status.success(),
"mnemo index --rebuild failed: status={status:?} stdout={stdout} stderr={stderr}"
);
assert!(
stdout.contains("rebuilt ANN index on"),
"expected 'rebuilt' prefix in stdout, got: {stdout}"
);
let db = Mnemo::open(&path, pp).unwrap();
assert!(db.has_index(), "rebuilt index must persist across reopen");
assert_eq!(db.len(), 40);
}
#[test]
fn cli_index_rebuild_rejects_tuning_overrides() {
let (_dir, path, pp, _) = seed_db();
let (status, _stdout, stderr) = run_cli(
&[
"index",
path.to_str().unwrap(),
"--rebuild",
"--n-probe",
"5",
],
pp,
);
assert!(!status.success(), "expected non-zero exit, got {status:?}");
assert!(
stderr.contains("--rebuild uses the auto-defaults"),
"expected the 'rebuild uses the auto-defaults' error, got: {stderr}"
);
}
#[test]
fn cli_index_drop_and_rebuild_are_mutually_exclusive() {
let (_dir, path, pp, _) = seed_db();
let (status, _stdout, stderr) = run_cli(
&["index", path.to_str().unwrap(), "--drop", "--rebuild"],
pp,
);
assert!(
!status.success(),
"clap must reject --drop --rebuild together"
);
assert!(
stderr.contains("cannot be used with")
|| stderr.contains("conflicts")
|| stderr.contains("--rebuild") && stderr.contains("--drop"),
"expected a clap 'conflicts' message, got: {stderr}"
);
}
#[test]
fn recall_falls_back_to_read_only_when_write_lock_held() {
let (_dir, path, pp, _) = seed_db();
let reader = Mnemo::open_read_only(&path, pp).expect("open read-only reader");
let (status, stdout, stderr) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query",
"[1.0,0.0,0.0,0.0]",
"--top-k",
"1",
],
pp,
);
drop(reader);
assert!(
status.success(),
"recall must succeed via read-only fallback when the exclusive lock \
is unavailable; got status={status:?}\nstdout:\n{stdout}\nstderr:\n{stderr}",
);
assert!(
stderr.contains("another writer holds the lock")
&& stderr.contains("read-only")
&& stderr.contains("--no-track"),
"expected the one-line stderr fallback note, got stderr:\n{stderr}",
);
assert!(
stdout.contains("the user prefers tea"),
"expected recall output to include the top hit; got:\n{stdout}",
);
}
#[test]
fn recall_text_falls_back_to_read_only_when_write_lock_held() {
let dir = tempdir().unwrap();
let path = dir.path().join("em-rot.mnemo");
let pp = "em-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember(Memory::scaffold_manifest(4)).unwrap();
db.remember(
Memory::new("alpha", MemoryType::Semantic, fake_vector_for("alpha"))
.with_importance(0.5),
)
.unwrap();
db.flush().unwrap();
}
let script = write_py_script(dir.path(), "fake-rot.py", FAKE_EMBED_PY);
let ecmd = embed_cmd_arg(&script);
let reader = Mnemo::open_read_only(&path, pp).expect("open read-only reader");
let out = Command::new(bin())
.args([
"recall",
path.to_str().unwrap(),
"--text",
"alpha",
"--embed-cmd",
&ecmd,
"--embed-name",
"fake-4d",
"--embed-normalize",
"true",
"--top-k",
"1",
])
.env("MNEMO_PASSPHRASE", pp)
.output()
.expect("spawn mnemo recall --text");
drop(reader);
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"recall --text must succeed via read-only fallback; got status={:?}\n\
stdout:\n{stdout}\nstderr:\n{stderr}",
out.status,
);
assert!(
stderr.contains("another writer holds the lock")
&& stderr.contains("read-only")
&& stderr.contains("--no-track"),
"expected the one-line stderr fallback note (--text), got stderr:\n{stderr}",
);
assert!(
stdout.contains("alpha"),
"expected recall --text output to include the top hit; got stdout:\n{stdout}",
);
}
#[test]
fn recall_tracking_flushes_access_count_to_disk() {
let (dir, path, pp, _) = seed_db();
let before = {
let mut db = Mnemo::open_read_only(&path, pp).expect("open-ro before");
let mems = db.memories().expect("memories before");
let mem = mems
.into_iter()
.find(|m| m.content == "the user prefers tea")
.expect("tea memory missing in seed");
mem.access_count
};
let (status, stdout, stderr) = run_cli(
&[
"recall",
path.to_str().unwrap(),
"--query",
"[1.0,0.0,0.0,0.0]",
"--top-k",
"1",
],
pp,
);
assert!(
status.success(),
"tracking recall failed: status={status:?}\nstdout:\n{stdout}\nstderr:\n{stderr}",
);
assert!(
stdout.contains("the user prefers tea"),
"expected recall output to include the tea memory; got:\n{stdout}",
);
assert!(
!stderr.contains("another writer holds the lock"),
"unexpected lock-fallback note on a tracking recall with no contention: {stderr}",
);
let after = {
let mut db = Mnemo::open_read_only(&path, pp).expect("open-ro after");
let mems = db.memories().expect("memories after");
let mem = mems
.into_iter()
.find(|m| m.content == "the user prefers tea")
.expect("tea memory missing after recall");
mem.access_count
};
assert!(
after > before,
"tracking recall must persist an access_count bump to disk; \
before={before}, after={after}",
);
assert_eq!(
after,
before + 1,
"exactly one access_count increment expected after one top-1 recall; \
before={before}, after={after}",
);
drop(dir);
}
#[test]
fn cli_history_defaults_to_private_slot_after_keyed_remember() {
use mnemo_engine::Memory;
let dir = tempdir().unwrap();
let path = dir.path().join("cli-hist.mnemo");
let pp = "hist-pw";
{
let mut db = Mnemo::create(&path, pp, fast_cfg(4)).unwrap();
db.remember_keyed(
Memory::new("v1", MemoryType::Semantic, vec![1.0, 0.0, 0.0, 0.0]),
"release",
)
.unwrap();
db.remember_keyed(
Memory::new("v2", MemoryType::Semantic, vec![0.0, 1.0, 0.0, 0.0]),
"release",
)
.unwrap();
db.flush().unwrap();
}
let (status, stdout, _stderr) =
run_cli(&["history", path.to_str().unwrap(), "--key", "release"], pp);
assert!(status.success());
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
2,
"default --key history should return 2 entries (live + 1 superseded); got {stdout}"
);
assert!(
lines[0].contains("live"),
"entry 0 must be live, got {}",
lines[0]
);
assert!(
lines[0].contains("v2"),
"live entry should be v2, got {}",
lines[0]
);
assert!(lines[1].contains("superseded"));
assert!(lines[1].contains("v1"));
let (status2, stdout2, _stderr2) = run_cli(
&[
"history",
path.to_str().unwrap(),
"--key",
"release",
"--shared",
],
pp,
);
assert!(status2.success());
assert!(
stdout2.contains("no history"),
"no shared writes → 'no history' message; got {stdout2}"
);
}
#[test]
fn cli_history_rejects_agent_with_shared() {
let (_dir, path, pp, _) = seed_db();
let (status, _stdout, stderr) = run_cli(
&[
"history",
path.to_str().unwrap(),
"--key",
"k",
"--agent",
"alice",
"--shared",
],
pp,
);
assert!(
!status.success(),
"clap should reject --agent with --shared"
);
assert!(
stderr.contains("cannot be used with")
|| stderr.contains("conflicts")
|| (stderr.contains("--agent") && stderr.contains("--shared")),
"expected a clap 'conflicts' message, got: {stderr}"
);
}