use std::process::Command;
use mnemo::{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}"
);
}