use std::path::Path;
use std::process::Command;
use assert_cmd::prelude::*;
use tempfile::TempDir;
fn mnem(repo: &Path, args: &[&str]) -> Command {
let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
cmd.current_dir(repo);
cmd.arg("-R").arg(repo);
for a in args {
cmd.arg(a);
}
cmd
}
fn init(dir: &Path) {
mnem(dir, &["init", dir.to_str().unwrap()])
.assert()
.success();
}
fn add_node(dir: &Path, summary: &str) -> String {
let out = mnem(dir, &["add", "node", "--summary", summary, "--no-embed"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
for line in stdout.lines() {
if let Some(rest) = line.strip_prefix("added node ") {
return rest.trim().to_string();
}
}
panic!("add node stdout had no 'added node <uuid>' line: {stdout}");
}
fn latest_op_cid(dir: &Path) -> String {
let out = mnem(dir, &["log", "-n", "1"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
stdout
.lines()
.find_map(|l| l.strip_prefix("op ").map(str::trim).map(str::to_string))
.expect("mnem log -n 1 must emit an 'op <cid>' line")
}
#[test]
fn revert_root_op_exercises_empty_before_state() {
let dir = TempDir::new().unwrap();
init(dir.path());
let anchor_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &anchor_op_cid])
.assert()
.success();
mnem(
dir.path(),
&["get", "00000000-0000-7000-8000-6d6e656d0001"],
)
.assert()
.failure();
}
#[test]
fn revert_undoes_add_node() {
let dir = TempDir::new().unwrap();
init(dir.path());
let uuid = add_node(dir.path(), "to-be-reverted");
let op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &op_cid])
.assert()
.success();
mnem(dir.path(), &["get", &uuid])
.assert()
.failure();
}
#[test]
fn revert_output_format() {
let dir = TempDir::new().unwrap();
init(dir.path());
add_node(dir.path(), "format-check-node");
let op_cid = latest_op_cid(dir.path());
let out = mnem(dir.path(), &["revert", &op_cid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
let expected_header = format!("reverting op: {op_cid}");
assert!(
stdout.contains(&expected_header),
"stdout must contain 'reverting op: <cid>', got:\n{stdout}"
);
assert!(
stdout.contains(" nodes: 1 added, 0 removed, 0 changed by the original op"),
"stdout must contain node-change summary, got:\n{stdout}"
);
assert!(
stdout.contains(" edges: 0 added, 0 removed, 0 changed by the original op"),
"stdout must contain edge-change summary, got:\n{stdout}"
);
assert!(
stdout.contains(" tombstones: 0 added by the original op (will be removed)"),
"stdout must contain tombstone-change summary, got:\n{stdout}"
);
assert!(
stdout.contains("applying inverse changes..."),
"stdout must contain 'applying inverse changes...', got:\n{stdout}"
);
assert!(
stdout.contains("done."),
"stdout must contain 'done.', got:\n{stdout}"
);
assert!(
stdout.contains(" new op: "),
"stdout must contain ' new op: ' (two leading spaces, four after colon), got:\n{stdout}"
);
assert!(
stdout.contains(" new commit: "),
"stdout must contain ' new commit: ' line, got:\n{stdout}"
);
}
#[test]
fn revert_undoes_node_deletion() {
let dir = TempDir::new().unwrap();
init(dir.path());
let uuid = add_node(dir.path(), "will-be-deleted");
mnem(dir.path(), &["delete", &uuid])
.assert()
.success();
let delete_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["get", &uuid])
.assert()
.failure();
mnem(dir.path(), &["revert", &delete_op_cid])
.assert()
.success();
let out = mnem(dir.path(), &["get", &uuid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("will-be-deleted"),
"restored node must contain its original summary, got:\n{stdout}"
);
}
#[test]
fn revert_undoes_tombstone() {
let dir = TempDir::new().unwrap();
init(dir.path());
let uuid = add_node(dir.path(), "soft-delete-candidate");
mnem(dir.path(), &["tombstone", &uuid])
.assert()
.success();
let tombstone_op_cid = latest_op_cid(dir.path());
let out = mnem(dir.path(), &["get", &uuid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains(" tombstoned: true"),
"node must be tombstoned before revert, got:\n{stdout}"
);
let revert_out = mnem(dir.path(), &["revert", &tombstone_op_cid])
.assert()
.success();
let revert_stdout = String::from_utf8_lossy(&revert_out.get_output().stdout).to_string();
assert!(
revert_stdout.contains(" tombstones: 1 added by the original op (will be removed)"),
"revert of tombstone op must report tombstone count, got:\n{revert_stdout}"
);
let out = mnem(dir.path(), &["get", &uuid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
!stdout.contains(" tombstoned: true"),
"tombstone marker must be removed after revert, got:\n{stdout}"
);
}
#[test]
fn revert_already_reverted_prints_nothing_to_commit() {
let dir = TempDir::new().unwrap();
init(dir.path());
add_node(dir.path(), "double-revert-node");
let op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &op_cid])
.assert()
.success();
let before_out = mnem(dir.path(), &["log"]).assert().success();
let before_count = String::from_utf8_lossy(&before_out.get_output().stdout)
.lines()
.filter(|l| l.starts_with("op "))
.count();
let out = mnem(dir.path(), &["revert", &op_cid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("note: "),
"second revert must print 'note: ...' before 'nothing to commit.', got:\n{stdout}"
);
assert!(
stdout.contains(" nothing to commit."),
"second revert must print ' nothing to commit.' (six spaces, period), got:\n{stdout}"
);
let after_out = mnem(dir.path(), &["log"]).assert().success();
let after_count = String::from_utf8_lossy(&after_out.get_output().stdout)
.lines()
.filter(|l| l.starts_with("op "))
.count();
assert_eq!(
after_count,
before_count,
"no-op revert must not create a new op (count unchanged at {before_count})"
);
}
#[test]
fn revert_custom_message_in_log() {
let dir = TempDir::new().unwrap();
init(dir.path());
add_node(dir.path(), "msg-test-node");
let op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &op_cid, "-m", "my-custom-revert-message"])
.assert()
.success();
let out = mnem(dir.path(), &["log"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("my-custom-revert-message"),
"log must contain the custom revert message, got:\n{stdout}"
);
}
#[test]
fn revert_creates_new_op() {
let dir = TempDir::new().unwrap();
init(dir.path());
add_node(dir.path(), "op-count-node");
let add_op_cid = latest_op_cid(dir.path());
let before_out = mnem(dir.path(), &["log"]).assert().success();
let before_stdout = String::from_utf8_lossy(&before_out.get_output().stdout).to_string();
let before_count = before_stdout
.lines()
.filter(|l| l.starts_with("op "))
.count();
mnem(dir.path(), &["revert", &add_op_cid])
.assert()
.success();
let after_out = mnem(dir.path(), &["log"]).assert().success();
let after_stdout = String::from_utf8_lossy(&after_out.get_output().stdout).to_string();
let after_count = after_stdout
.lines()
.filter(|l| l.starts_with("op "))
.count();
assert_eq!(
after_count,
before_count + 1,
"revert must create exactly 1 new op (was {before_count}, now {after_count})"
);
}
#[test]
fn revert_chaining_restores_original_state() {
let dir = TempDir::new().unwrap();
init(dir.path());
let uuid = add_node(dir.path(), "original-node");
let add_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_op_cid])
.assert()
.success();
mnem(dir.path(), &["get", &uuid])
.assert()
.failure();
let revert_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &revert_op_cid])
.assert()
.success();
let out = mnem(dir.path(), &["get", &uuid])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("original-node"),
"chained revert must restore the original node, got:\n{stdout}"
);
}
#[test]
fn revert_undoes_edge_add() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "edge-src-node");
let dst_uuid = add_node(dir.path(), "edge-dst-node");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "test_link",
])
.assert()
.success();
let edge_op_cid = latest_op_cid(dir.path());
let before = mnem(dir.path(), &["traverse", &src_uuid])
.assert()
.success();
let before_stdout = String::from_utf8_lossy(&before.get_output().stdout).to_string();
assert!(
before_stdout.contains("-[test_link]->"),
"edge must exist before revert, got:\n{before_stdout}"
);
mnem(dir.path(), &["revert", &edge_op_cid])
.assert()
.success();
let after = mnem(dir.path(), &["traverse", &src_uuid])
.assert()
.success();
let after_stdout = String::from_utf8_lossy(&after.get_output().stdout).to_string();
assert!(
after_stdout.contains("<no outgoing edges>"),
"edge must be removed after reverting the edge-add op, got:\n{after_stdout}"
);
}
#[test]
fn revert_bug4_preflight_rejects_when_edge_endpoint_deleted() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "bug4-src");
let dst_uuid = add_node(dir.path(), "bug4-dst");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "bug4_link",
])
.assert()
.success();
let add_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_edge_op_cid])
.assert()
.success();
let remove_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["delete", &dst_uuid])
.assert()
.success();
mnem(dir.path(), &["revert", &remove_edge_op_cid])
.assert()
.failure();
}
#[test]
fn revert_undoes_edge_removal() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "edge-restore-src");
let dst_uuid = add_node(dir.path(), "edge-restore-dst");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "restore_link",
])
.assert()
.success();
let add_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_edge_op_cid])
.assert()
.success();
let remove_edge_op_cid = latest_op_cid(dir.path());
let mid = mnem(dir.path(), &["traverse", &src_uuid])
.assert()
.success();
assert!(
String::from_utf8_lossy(&mid.get_output().stdout).contains("<no outgoing edges>"),
"edge must be absent before the second revert"
);
mnem(dir.path(), &["revert", &remove_edge_op_cid])
.assert()
.success();
let after = mnem(dir.path(), &["traverse", &src_uuid])
.assert()
.success();
let after_stdout = String::from_utf8_lossy(&after.get_output().stdout).to_string();
assert!(
after_stdout.contains("-[restore_link]->"),
"reverted edge must reappear in traverse output, got:\n{after_stdout}"
);
}
#[test]
fn revert_bug4_preflight_rejects_when_edge_endpoint_tombstoned() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "bug4-ts-src");
let dst_uuid = add_node(dir.path(), "bug4-ts-dst");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "bug4_ts_link",
])
.assert()
.success();
let add_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_edge_op_cid])
.assert()
.success();
let remove_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["tombstone", &dst_uuid])
.assert()
.success();
mnem(dir.path(), &["revert", &remove_edge_op_cid])
.assert()
.failure();
}
#[test]
fn revert_bug4_preflight_rejects_when_edge_src_deleted() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "bug4-src-del-src");
let dst_uuid = add_node(dir.path(), "bug4-src-del-dst");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "bug4_src_link",
])
.assert()
.success();
let add_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_edge_op_cid])
.assert()
.success();
let remove_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["delete", &src_uuid])
.assert()
.success();
mnem(dir.path(), &["revert", &remove_edge_op_cid])
.assert()
.failure();
}
#[test]
fn revert_bug4_preflight_rejects_when_edge_src_tombstoned() {
let dir = TempDir::new().unwrap();
init(dir.path());
let src_uuid = add_node(dir.path(), "bug4-src-ts-src");
let dst_uuid = add_node(dir.path(), "bug4-src-ts-dst");
mnem(dir.path(), &[
"add", "edge",
"--from", &src_uuid,
"--to", &dst_uuid,
"--label", "bug4_src_ts_link",
])
.assert()
.success();
let add_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["revert", &add_edge_op_cid])
.assert()
.success();
let remove_edge_op_cid = latest_op_cid(dir.path());
mnem(dir.path(), &["tombstone", &src_uuid])
.assert()
.success();
mnem(dir.path(), &["revert", &remove_edge_op_cid])
.assert()
.failure();
}