blob: 566dd6a2d6ff13df0134a5a569d44fb41388b80f [file]
//! The seven M0 steps from PLAN.md, run against the built binary. This is the demo in
//! test form: if it passes, someone can clone, build, and do the same by hand.
// An integration test is test code, but clippy's allow-unwrap-in-tests only sees
// #[cfg(test)] modules; here a panic is the failure being reported.
#![allow(clippy::unwrap_used)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
fn memory(root: &Path, args: &[&str], stdin: Option<&str>) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_amem"));
cmd.arg("--root").arg(root).args(args);
cmd.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn memory");
if let Some(text) = stdin {
use std::io::Write;
child
.stdin
.take()
.expect("stdin")
.write_all(text.as_bytes())
.expect("write stdin");
}
child.wait_with_output().expect("wait")
}
fn ok(root: &Path, args: &[&str], stdin: Option<&str>) -> String {
let out = memory(root, args, stdin);
assert!(
out.status.success(),
"memory {args:?} failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("utf-8 stdout")
}
fn fails(root: &Path, args: &[&str]) -> String {
let out = memory(root, args, None);
assert!(!out.status.success(), "memory {args:?} should have failed");
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn json(text: &str) -> serde_json::Value {
serde_json::from_str(text).unwrap_or_else(|e| panic!("not json ({e}): {text}"))
}
fn id_of(text: &str) -> String {
json(text)["id"].as_str().expect("id").to_string()
}
/// What the steps hand to one another.
struct Demo {
dir: tempfile::TempDir,
root: PathBuf,
decision: String,
handoff: String,
}
/// 1. init creates a domain with a grants file.
fn step_init() -> Demo {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("memory");
let out = ok(
&root,
&["init", "--owner", "human:test:me", "--domain", "dom-me"],
None,
);
assert!(out.contains("dom-me"), "{out}");
assert!(root.join("grants.toml").is_file());
assert!(fails(&root, &["init"]).contains("already exists"));
Demo {
dir,
root,
decision: String::new(),
handoff: String::new(),
}
}
/// 2. a session writes a decision and a handoff.
fn step_write(d: &mut Demo) {
d.decision = id_of(&ok(
&d.root,
&[
"write",
"--kind",
"decision",
"--title",
"Use SQLite for the pilot",
],
Some("Nothing to operate, and FTS5 is built in.\n"),
));
d.handoff = id_of(&ok(
&d.root,
&[
"write",
"--kind",
"handoff",
"--title",
"Stopped at step 3 of the import",
],
Some("Steps 1 and 2 done; the manifest parses. Next: digests.\n"),
));
}
/// 3. a fresh session loads T0 and finds the handoff without any transcript.
fn step_t0(d: &Demo) {
let t0 = ok(&d.root, &["t0"], None);
assert!(t0.contains(&d.handoff), "{t0}");
assert!(t0.contains("Next: digests."), "{t0}");
assert!(t0.contains(&d.decision), "{t0}");
assert!(t0.contains("write a `handoff` entry"), "{t0}");
}
/// 4. search ranks and returns summaries; get returns one body.
fn step_search_and_get(d: &Demo) {
let hits = json(&ok(&d.root, &["search", "digests"], None));
assert_eq!(hits[0]["id"], d.handoff);
assert_eq!(hits[0]["type"], "handoff");
assert!(hits[0]["summary"]
.as_str()
.unwrap()
.contains("manifest parses"));
let body = ok(&d.root, &["get", &d.handoff], None);
assert!(body.starts_with("---\nid: "), "{body}");
assert!(body.contains("Next: digests."));
assert!(fails(&d.root, &["get", "mem-me-nope"]).contains("not found"));
}
/// 4b. recall with no model behind it, and a golden suite scored against the record.
fn step_recall_and_golden(d: &Demo) {
// recall with no model behind it: the pack comes back with synthesis owed
let r = json(&ok(
&d.root,
&["recall", "what is next after the manifest"],
None,
));
assert_eq!(r["synthesis"]["by"], "owed", "{r}");
assert_eq!(r["pack"]["depth"], "sections");
let pieces = r["pack"]["pieces"].as_array().unwrap();
assert!(pieces.iter().any(|p| p["id"] == d.handoff), "{r}");
assert!(pieces
.iter()
.any(|p| p["body"].as_str().is_some_and(|b| b.contains("digests"))));
assert!(r["citations"]
.as_array()
.unwrap()
.iter()
.any(|c| c == &d.handoff));
assert!(fails(&d.root, &["recall", "q", "--model", "m"]).contains("provider"));
assert!(fails(
&d.root,
&[
"recall",
"q",
"--provider",
"anthropic",
"--auth",
"subscription"
]
)
.contains("subscription"));
// taps: a name is checked before it becomes a path, and a run needs a grant
assert!(fails(&d.root, &["tap", "install", "Bad Name", "x.wasm"]).contains("name"));
assert!(fails(&d.root, &["tap", "run", "github-issues"]).contains("grants.toml has no"));
// the savings report runs from the CLI and counts that recall
let savings = ok(&d.root, &["savings", "--query", "digests"], None);
assert!(savings.contains("owed packs 1"), "{savings}");
// a golden suite scores the question against the record with no model behind it
let suite = d.dir.path().join("golden.toml");
std::fs::write(
&suite,
"[[question]]\nask = \"what comes after the manifest\"\nanswers = [\"step 3 of the import\"]\nkeywords = \"digests\"\n",
)
.unwrap();
let g = json(&ok(
&d.root,
&["golden", suite.to_str().unwrap(), "--json"],
None,
));
assert_eq!(g["questions"], 1, "{g}");
let kw = g["scores"]
.as_array()
.unwrap()
.iter()
.find(|s| s["strategy"] == "keywords")
.unwrap();
assert_eq!(kw["hits"], 1, "{g}");
}
/// 5. a task is created, claimed with a lease, heartbeated, completed; a second claim
/// while the lease holds finds nothing.
fn step_tasks(d: &Demo) {
let task = json(&ok(
&d.root,
&["task", "create", "Finish the import", "--priority", "5"],
None,
));
let id = task["id"].as_str().unwrap().to_string();
assert_eq!(task["status"], "proposed");
assert_eq!(ok(&d.root, &["task", "claim-next"], None).trim(), "null");
ok(&d.root, &["task", "admit", &id], None);
let claimed = json(&ok(&d.root, &["task", "claim-next"], None));
assert_eq!(claimed["id"], id);
assert_eq!(claimed["status"], "claimed");
assert_eq!(
ok(&d.root, &["task", "claim-next"], None).trim(),
"null",
"one holder"
);
ok(&d.root, &["task", "heartbeat", &id], None);
let done = json(&ok(&d.root, &["task", "complete", &id, &d.handoff], None));
assert_eq!(done["status"], "completed");
assert_eq!(done["handoff"], d.handoff);
let completed = json(&ok(
&d.root,
&["task", "list", "--status", "completed"],
None,
));
assert_eq!(completed.as_array().unwrap().len(), 1);
}
/// 6. export writes a bundle; import lands it raw elsewhere and refuses to import twice.
fn step_export_import(d: &Demo) {
let bundle = d.dir.path().join("bundle");
let bundle_arg = bundle.to_str().unwrap();
let out = ok(
&d.root,
&["export", bundle_arg, "--max-label", "internal"],
None,
);
assert!(out.contains("exported 2 entries"), "{out}");
assert!(bundle.join("manifest.yaml").is_file());
let other = d.dir.path().join("other");
ok(
&other,
&[
"init",
"--owner",
"human:test:other",
"--domain",
"dom-other",
],
None,
);
let out = ok(&other, &["import", bundle_arg], None);
assert!(
out.contains("imported 2 entries with 0 transcript excerpts, skipped 0"),
"{out}"
);
let out = ok(&other, &["import", bundle_arg], None);
assert!(
out.contains("imported 0 entries with 0 transcript excerpts, skipped 2"),
"{out}"
);
let theirs = ok(&other, &["get", &d.handoff], None);
assert!(theirs.contains("trust_tier: raw"), "{theirs}");
assert!(theirs.contains("domain: dom-other"), "{theirs}");
assert!(
theirs.contains("author: human:test:me"),
"provenance kept: {theirs}"
);
}
/// 7. delete the index and rebuild it from the record alone.
fn step_reindex(d: &Demo) {
std::fs::remove_file(d.root.join("state/index.db")).unwrap();
let gone = json(&ok(&d.root, &["search", "digests"], None));
assert_eq!(gone.as_array().unwrap().len(), 0);
let out = ok(&d.root, &["reindex"], None);
assert!(out.contains("indexed 2 entries"), "{out}");
let hits = json(&ok(&d.root, &["search", "digests"], None));
assert_eq!(hits[0]["id"], d.handoff);
let list = json(&ok(&d.root, &["list", "--kind", "handoff"], None));
assert_eq!(list.as_array().unwrap().len(), 1);
}
#[test]
fn the_seven_m0_steps() {
let mut d = step_init();
step_write(&mut d);
step_t0(&d);
step_search_and_get(&d);
step_recall_and_golden(&d);
step_tasks(&d);
step_export_import(&d);
step_reindex(&d);
}
#[test]
fn erasure_leaves_a_stub_a_receipt_and_a_flag_on_the_citing_entry() {
let mut d = step_init();
step_write(&mut d);
let marker = "passphrase-Qz7xx";
let secret = id_of(&ok(
&d.root,
&["write", "--title", "Vault"],
Some(&format!("The vault opens with {marker}.\n")),
));
let citing = id_of(&ok(
&d.root,
&["write", "--title", "Where the vault is explained"],
Some(&format!("See {secret}.\n")),
));
// the query is a word, not the secret: the read log keeps queries
let ids = |text: &str| -> Vec<String> {
json(text)
.as_array()
.unwrap()
.iter()
.map(|h| h["id"].as_str().unwrap().to_string())
.collect()
};
assert!(ids(&ok(&d.root, &["search", "vault opens"], None)).contains(&secret));
// a why that quotes the secret is the entry's too, and leaves with it
let why = format!("tried {marker} on the vault");
ok(
&d.root,
&["promote", &secret, "--to", "curated", "--why", &why],
None,
);
let err = fails(&d.root, &["erase", &secret, "--reason", "because"]);
assert!(err.contains("invalid value"), "{err}");
let receipt = json(&ok(
&d.root,
&["erase", &secret, "--reason", "sensitive"],
None,
));
assert_eq!(receipt["id"], secret);
assert_eq!(receipt["reason"], "sensitive");
assert_eq!(receipt["removed"][0]["version"], "current");
assert_eq!(receipt["flagged"], serde_json::json!([citing]));
assert_eq!(receipt["promotion_whys_removed"], 1);
assert!(!ids(&ok(&d.root, &["search", "vault opens"], None)).contains(&secret));
let err = fails(&d.root, &["get", &secret]);
assert!(err.contains("gone") && err.contains("erased"), "{err}");
let err = fails(&d.root, &["erase", &secret, "--reason", "legal"]);
assert!(err.contains("already erased"), "{err}");
let stub = std::fs::read_to_string(d.root.join(format!("record/dom-me/{secret}.md"))).unwrap();
assert!(
stub.contains("status: erased") && !stub.contains(marker),
"{stub}"
);
let receipt_file = d
.root
.join(format!("record/dom-me/.erasures/{secret}.yaml"));
assert!(receipt_file.is_file());
if let Some(file) = holds(&d.root, marker.as_bytes()) {
panic!("{} still holds the erased body", file.display());
}
}
#[test]
fn promotions_says_why_an_entry_was_promoted() {
let mut d = step_init();
step_write(&mut d);
let why = "read it against the pilot's notes";
let promoted = json(&ok(
&d.root,
&["promote", &d.decision, "--to", "curated", "--why", why],
None,
));
assert_eq!(promoted["tier"], "curated");
let records = json(&ok(&d.root, &["promotions", &d.decision], None));
let records = records.as_array().unwrap();
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(records[0]["why"], why);
assert_eq!(records[0]["tier"], "curated");
assert_eq!(records[0]["by"], "human:test:me");
assert_eq!(records[0]["landed"], true);
let none = json(&ok(&d.root, &["promotions", &d.handoff], None));
assert_eq!(none, serde_json::json!([]));
assert!(fails(&d.root, &["promotions", "mem-none"]).contains("not found"));
}
#[test]
fn an_erasure_takes_the_proposal_to_verify_the_entry_off_the_board() {
let d = step_init();
let id = id_of(&ok(
&d.root,
&["write", "--title", "A fact"],
Some("A fact worth verifying, then erased.\n"),
));
let proposal = json(&ok(
&d.root,
&["promote", &id, "--to", "verified", "--why", "checked"],
None,
));
assert_eq!(proposal["outcome"], "proposed", "{proposal}");
let task = proposal["task"]["id"].as_str().unwrap().to_string();
ok(&d.root, &["erase", &id, "--reason", "mistaken"], None);
let listed = |status: &str| -> Vec<String> {
json(&ok(&d.root, &["task", "list", "--status", status], None))
.as_array()
.unwrap()
.iter()
.map(|t| t["id"].as_str().unwrap().to_string())
.collect()
};
for open in ["proposed", "admitted", "claimed"] {
assert!(
!listed(open).contains(&task),
"{task} is still {open} after the erasure"
);
}
assert!(listed("invalidated").contains(&task));
let err = fails(&d.root, &["approve", &task]);
assert!(err.contains("erased"), "{err}");
let err = fails(
&d.root,
&["promote", &id, "--to", "verified", "--why", "again"],
);
assert!(err.contains("erased"), "{err}");
}
#[test]
fn a_declined_proposal_is_not_printed_as_proposed_again() {
let d = step_init();
let id = id_of(&ok(
&d.root,
&["write", "--title", "A fact"],
Some("A fact a person declines.\n"),
));
let proposal = json(&ok(
&d.root,
&["promote", &id, "--to", "verified", "--why", "checked"],
None,
));
let task = proposal["task"]["id"].as_str().unwrap().to_string();
ok(
&d.root,
&["task", "invalidate", &task, "--reason", "does not hold"],
None,
);
let out = memory(
&d.root,
&["promote", &id, "--to", "verified", "--why", "again"],
None,
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("proposed"),
"nothing was filed, and the command says: {stdout}"
);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains(&task), "{stderr}");
}
/// The first file under `root` whose bytes contain `needle`.
fn holds(root: &Path, needle: &[u8]) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for f in std::fs::read_dir(&dir).unwrap().flatten() {
if f.path().is_dir() {
stack.push(f.path());
} else if std::fs::read(f.path())
.unwrap()
.windows(needle.len())
.any(|w| w == needle)
{
return Some(f.path());
}
}
}
None
}
#[test]
fn no_root_is_a_clear_error() {
// with no --root and no MEMORY_ROOT the root is ~/memory, and a missing one says so
let home = tempfile::tempdir().unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_amem"))
.env_remove("MEMORY_ROOT")
.env("HOME", home.path())
.arg("t0")
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(&home.path().join("memory").display().to_string()),
"{err}"
);
assert!(err.contains("amem init") && err.contains("--root"), "{err}");
// and with no home either, there is nowhere to default to
let out = Command::new(env!("CARGO_BIN_EXE_amem"))
.env_remove("MEMORY_ROOT")
.env_remove("HOME")
.env_remove("USERPROFILE")
.arg("t0")
.output()
.unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("give --root"));
}