use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use assert_cmd::prelude::*;
use tempfile::TempDir;
use mnem_backend_redb::open_or_init;
use mnem_core::codec::hash_to_cid;
use mnem_core::store::Blockstore;
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 gc_dry_run(dir: &Path) -> String {
let out = mnem(dir, &["gc"]).assert().success();
String::from_utf8_lossy(&out.get_output().stdout).to_string()
}
fn gc_force(dir: &Path) -> String {
let out = mnem(dir, &["gc", "--force"]).assert().success();
String::from_utf8_lossy(&out.get_output().stdout).to_string()
}
fn open_blockstore(dir: &Path) -> Arc<dyn Blockstore> {
let db_path = dir.join(".mnem").join("repo.redb");
let (bs, _ohs, _cfg) = open_or_init(&db_path).expect("open redb");
bs
}
fn inject_orphaned_block(dir: &Path, payload: &[u8]) -> mnem_core::id::Cid {
use serde::Serialize;
#[derive(Serialize)]
struct GarbagePayload<'a> {
_kind: &'a str,
data: &'a [u8],
}
let val = GarbagePayload {
_kind: "gc_test_garbage",
data: payload,
};
let bs = open_blockstore(dir);
let (raw_bytes, cid) = hash_to_cid(&val).expect("hash_to_cid");
bs.put_trusted(cid.clone(), raw_bytes)
.expect("put_trusted orphan block");
cid
}
#[test]
fn gc_dry_run_reports_unreachable_blocks_without_deleting() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let orphan_cid = inject_orphaned_block(p, b"orphan-payload-1");
let stdout = gc_dry_run(p);
assert!(
stdout.contains("gc: 1 unreachable block(s) found"),
"dry-run must report exactly 'gc: 1 unreachable block(s) found', got: {stdout}"
);
let stdout2 = gc_dry_run(p);
assert!(
stdout2.contains("gc: 1 unreachable block(s) found"),
"second dry-run must still report 'gc: 1 unreachable block(s) found' (idempotent, nothing deleted), got: {stdout2}"
);
{
let bs = open_blockstore(p);
assert!(
bs.has(&orphan_cid).expect("has() after dry-runs"),
"dry-run must NOT delete blocks from the store; orphan CID still expected present"
);
}
}
#[test]
fn gc_force_deletes_unreachable_blocks() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let orphan_cid = inject_orphaned_block(p, b"orphan-payload-force-test");
let before = gc_dry_run(p);
assert!(
before.contains("unreachable block(s) found"),
"precondition: dry-run must see the orphaned block, got: {before}"
);
let stdout = gc_force(p);
assert!(
stdout.contains("gc: removed 1 block(s)"),
"gc --force must report 'gc: removed 1 block(s)', got: {stdout}"
);
let after = gc_dry_run(p);
assert!(
after.contains("no unreachable blocks") || after.contains("store is clean"),
"after gc --force the store must be clean, got: {after}"
);
{
let bs = open_blockstore(p);
assert!(
!bs.has(&orphan_cid).expect("has() after gc --force"),
"gc --force must actually delete the orphan block from the store"
);
}
}
#[test]
fn gc_force_on_clean_repo_exits_gracefully() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "live-node");
let block_count_before = {
let bs = open_blockstore(p);
bs.all_cids()
.expect("all_cids")
.expect("redb supports enumeration")
.len()
};
let stdout = gc_force(p);
assert!(
stdout.contains("nothing to collect")
|| stdout.contains("no unreachable blocks")
|| stdout.contains("store is clean"),
"gc --force on clean repo must say nothing to collect, got: {stdout}"
);
let block_count_after = {
let bs = open_blockstore(p);
bs.all_cids()
.expect("all_cids")
.expect("redb supports enumeration")
.len()
};
assert_eq!(
block_count_before, block_count_after,
"gc --force on clean repo must not delete any blocks (before={block_count_before}, after={block_count_after})"
);
}
#[test]
fn gc_force_outside_repo_fails() {
let dir = TempDir::new().unwrap();
let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
cmd.current_dir(dir.path());
cmd.arg("-R").arg(dir.path());
cmd.args(["gc", "--force"]);
cmd.assert().failure();
}
#[test]
fn gc_force_repo_remains_functional() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let survivor_uuid = add_node(p, "survivor-node");
inject_orphaned_block(p, b"orphan-payload-functional-test");
gc_force(p);
let out = mnem(p, &["log"]).assert().success();
let log_stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
log_stdout.contains("op "),
"mnem log must still show ops after gc, got: {log_stdout}"
);
let out = mnem(p, &["get", &survivor_uuid]).assert().success();
let get_stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
get_stdout.contains("survivor-node"),
"survivor node must still be retrievable via get after gc, got: {get_stdout}"
);
add_node(p, "post-gc-node");
let out = mnem(p, &["log", "-n", "1"]).assert().success();
let last_log = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
last_log.contains("op "),
"new commit after gc must appear in log, got: {last_log}"
);
}
#[test]
fn gc_force_removes_multiple_orphaned_blocks() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let orphan_a = inject_orphaned_block(p, b"orphan-block-a");
let orphan_b = inject_orphaned_block(p, b"orphan-block-b");
let orphan_c = inject_orphaned_block(p, b"orphan-block-c");
{
let bs = open_blockstore(p);
assert!(
bs.has(&orphan_a).expect("has(orphan_a) before gc"),
"orphan_a must exist before gc"
);
assert!(
bs.has(&orphan_b).expect("has(orphan_b) before gc"),
"orphan_b must exist before gc"
);
assert!(
bs.has(&orphan_c).expect("has(orphan_c) before gc"),
"orphan_c must exist before gc"
);
}
let dry = gc_dry_run(p);
assert!(
dry.contains("unreachable block(s) found"),
"dry-run must report unreachable blocks, got: {dry}"
);
let stdout = gc_force(p);
assert!(
stdout.contains("gc: removed 3 block(s)"),
"gc --force must report 'gc: removed 3 block(s)', got: {stdout}"
);
let after = gc_dry_run(p);
assert!(
after.contains("no unreachable blocks") || after.contains("store is clean"),
"store must be clean after collecting orphaned blocks, got: {after}"
);
{
let bs = open_blockstore(p);
assert!(
!bs.has(&orphan_a).expect("has(orphan_a) after gc"),
"orphan_a must be deleted by gc --force"
);
assert!(
!bs.has(&orphan_b).expect("has(orphan_b) after gc"),
"orphan_b must be deleted by gc --force"
);
assert!(
!bs.has(&orphan_c).expect("has(orphan_c) after gc"),
"orphan_c must be deleted by gc --force"
);
}
}
#[test]
fn gc_dry_run_clean_repo_exits_zero() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "only-node");
let stdout = gc_dry_run(p);
assert!(
stdout.contains("no unreachable blocks") || stdout.contains("store is clean"),
"dry-run on clean repo must report clean store, got: {stdout}"
);
}
#[test]
fn gc_force_preserves_blocks_reachable_from_other_branches() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "main-node");
mnem(p, &["branch", "create", "side"]).assert().success();
mnem(p, &["switch", "side"]).assert().success();
let side_uuid = add_node(p, "side-only-node");
mnem(p, &["switch", "main"]).assert().success();
let orphan_cid = inject_orphaned_block(p, b"orphan-not-on-any-branch");
let cids_before_gc: std::collections::HashSet<mnem_core::id::Cid> = {
let bs = open_blockstore(p);
bs.all_cids()
.expect("all_cids")
.expect("redb supports enumeration")
.into_iter()
.collect()
};
let stdout = gc_force(p);
assert!(
stdout.contains("gc: removed 1 block(s)"),
"gc --force must report 'gc: removed 1 block(s)' (exactly the injected orphan), got: {stdout}"
);
{
let bs = open_blockstore(p);
assert!(
!bs.has(&orphan_cid).expect("has(orphan) after gc"),
"orphan block must be deleted from store by gc --force"
);
for cid in &cids_before_gc {
if cid == &orphan_cid {
continue; }
assert!(
bs.has(cid).expect("has(live cid) after gc"),
"live block {cid} must still exist in store after gc --force (side-branch block deleted?)"
);
}
}
mnem(p, &["switch", "side"]).assert().success();
let out = mnem(p, &["get", &side_uuid]).assert().success();
let get_stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
get_stdout.contains("side-only-node"),
"side branch node must survive gc --force, got: {get_stdout}"
);
let out2 = mnem(p, &["log"]).assert().success();
let log_stdout = String::from_utf8_lossy(&out2.get_output().stdout).to_string();
assert!(
log_stdout.contains("op "),
"side branch log must still work after gc --force on main, got: {log_stdout}"
);
}
#[test]
fn gc_force_does_not_delete_blocks_from_deleted_node() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let node_uuid = add_node(p, "node-to-be-deleted");
mnem(p, &["delete", &node_uuid]).assert().success();
let cids_after_delete: std::collections::HashSet<mnem_core::id::Cid> = {
let bs = open_blockstore(p);
bs.all_cids()
.expect("all_cids")
.expect("redb supports enumeration")
.into_iter()
.collect()
};
let orphan_cid = inject_orphaned_block(p, b"orphan-after-delete");
let stdout = gc_force(p);
assert!(
stdout.contains("gc: removed 1 block(s)"),
"gc --force must report 'gc: removed 1 block(s)' (only the injected orphan), got: {stdout}"
);
{
let bs = open_blockstore(p);
assert!(
!bs.has(&orphan_cid).expect("has(orphan) after gc"),
"injected orphan block must be deleted from the store by gc --force"
);
for cid in &cids_after_delete {
assert!(
bs.has(cid).expect("has(historical cid) after gc"),
"block {cid} (reachable from HEAD via commit chain) must still be present after gc --force"
);
}
}
}
#[test]
fn gc_force_does_not_delete_live_blocks() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "live-committed-node");
inject_orphaned_block(p, b"orphan-alongside-live");
let total_before = {
let bs = open_blockstore(p);
let cids = bs
.all_cids()
.expect("all_cids")
.expect("redb supports enumeration");
cids.len()
};
assert!(
total_before >= 5,
"expected >= 5 blocks after one add_node + one orphan inject (got {total_before}); \
if the block layout changed, update this test"
);
gc_force(p);
let total_after = {
let bs2 = open_blockstore(p);
let cids = bs2
.all_cids()
.expect("all_cids")
.expect("redb supports enumeration");
cids.len()
};
assert_eq!(
total_before - 1,
total_after,
"gc --force must remove exactly 1 block (the injected orphan), removed {} instead",
total_before.saturating_sub(total_after)
);
}