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) {
mnem(dir, &["add", "node", "--summary", summary, "--no-embed"])
.assert()
.success();
}
fn head_cid(dir: &Path) -> String {
let out = mnem(dir, &["status"]).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("commit ") {
let cid = rest.trim().to_string();
if !cid.is_empty() && !cid.starts_with('<') {
return cid;
}
}
}
panic!("could not extract HEAD CID from `mnem status` output:\n{stdout}");
}
fn active_branch_from_list(dir: &Path) -> String {
let out = mnem(dir, &["branch", "list"]).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("* ") {
let short = rest.split_whitespace().next().unwrap_or("").to_string();
if !short.is_empty() {
return short;
}
}
}
panic!("could not find starred branch in `mnem branch list` output:\n{stdout}");
}
#[test]
fn switch_to_existing_branch_moves_head() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "base commit");
let main_head = head_cid(p);
mnem(p, &["branch", "create", "feature"]).assert().success();
add_node(p, "main-only commit");
let main_advanced = head_cid(p);
assert_ne!(main_head, main_advanced, "main must have advanced");
let out = mnem(p, &["switch", "feature"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("switched to branch 'feature'"),
"expected switch confirmation, got: {stdout}"
);
let after = head_cid(p);
assert_eq!(
after, main_head,
"HEAD after switching to feature should equal the feature branch tip (= main at creation time)"
);
}
#[test]
fn switch_to_nonexistent_branch_errors() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "a commit");
let head_before = head_cid(p);
let out = mnem(p, &["switch", "does-not-exist"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("does-not-exist"),
"error message should mention the branch name, got: {stderr}"
);
assert!(
stderr.contains("not found") || stderr.contains("does not exist"),
"error message should say the branch was not found, got: {stderr}"
);
let head_after = head_cid(p);
assert_eq!(
head_before, head_after,
"HEAD must not change when switch fails"
);
}
#[test]
fn switch_already_on_branch_is_noop() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "a commit");
let head_before = head_cid(p);
let out = mnem(p, &["switch", "main"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("Already on 'main'"),
"expected 'Already on' message, got: {stdout}"
);
let head_after = head_cid(p);
assert_eq!(
head_before, head_after,
"HEAD must not change when already on the target branch"
);
}
#[test]
fn switch_updates_active_branch_field() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "base");
mnem(p, &["branch", "create", "other"]).assert().success();
add_node(p, "main-only commit");
mnem(p, &["switch", "other"]).assert().success();
let active = active_branch_from_list(p);
assert_eq!(
active, "other",
"active_branch field must point to 'other' after switching"
);
mnem(p, &["switch", "main"]).assert().success();
let active = active_branch_from_list(p);
assert_eq!(
active, "main",
"active_branch field must point to 'main' after switching back"
);
}
#[test]
fn switch_back_and_forth() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "shared base");
let shared_tip = head_cid(p);
mnem(p, &["branch", "create", "feature"]).assert().success();
add_node(p, "main extra");
let main_tip = head_cid(p);
assert_ne!(shared_tip, main_tip);
mnem(p, &["switch", "feature"]).assert().success();
assert_eq!(
head_cid(p),
shared_tip,
"after switching to feature HEAD should be at shared_tip"
);
assert_eq!(
active_branch_from_list(p),
"feature",
"active_branch should be 'feature'"
);
mnem(p, &["switch", "main"]).assert().success();
assert_eq!(
head_cid(p),
main_tip,
"after switching back to main HEAD should be at main_tip"
);
assert_eq!(
active_branch_from_list(p),
"main",
"active_branch should be 'main'"
);
mnem(p, &["switch", "feature"]).assert().success();
assert_eq!(
head_cid(p),
shared_tip,
"second switch to feature should land at shared_tip again"
);
assert_eq!(
active_branch_from_list(p),
"feature",
"active_branch should be 'feature' again"
);
}
#[test]
fn switch_refused_during_merge_in_progress() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "base");
mnem(p, &["branch", "create", "other"]).assert().success();
add_node(p, "main-only");
mnem(p, &["switch", "other"]).assert().success();
mnem(p, &["switch", "main"]).assert().success();
let head_before = head_cid(p);
let active_before = active_branch_from_list(p);
assert_eq!(active_before, "main", "sanity: we should be on main");
let mnem_dir = p.join(".mnem");
std::fs::write(mnem_dir.join("MERGE_HEAD"), "fake-cid-for-test")
.expect("could not write MERGE_HEAD");
let out = mnem(p, &["switch", "other"]).assert().failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
stderr.contains("middle of a merge") || stderr.contains("merge in progress"),
"switch during merge must produce a descriptive error, got: {stderr}"
);
assert_eq!(
head_cid(p),
head_before,
"HEAD must not change when switch is refused (merge in progress)"
);
assert_eq!(
active_branch_from_list(p),
active_before,
"active_branch must not change when switch is refused (merge in progress)"
);
}
#[test]
fn switch_diverged_branches_head_correct() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "common ancestor");
let base_tip = head_cid(p);
mnem(p, &["branch", "create", "branch-a"])
.assert()
.success();
add_node(p, "main-only commit");
let main_tip = head_cid(p);
mnem(p, &["switch", "branch-a"]).assert().success();
assert_eq!(head_cid(p), base_tip, "branch-a should start at base_tip");
add_node(p, "branch-a-only commit");
let branch_a_tip = head_cid(p);
assert_ne!(branch_a_tip, base_tip, "branch-a must have advanced");
assert_ne!(branch_a_tip, main_tip, "branch-a and main must be diverged");
mnem(p, &["switch", "main"]).assert().success();
assert_eq!(
head_cid(p),
main_tip,
"after switching to main HEAD must be main_tip"
);
assert_eq!(active_branch_from_list(p), "main");
mnem(p, &["switch", "branch-a"]).assert().success();
assert_eq!(
head_cid(p),
branch_a_tip,
"after switching to branch-a HEAD must be branch_a_tip"
);
assert_eq!(active_branch_from_list(p), "branch-a");
}
#[test]
fn checkout_alias_works_like_switch() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "base commit");
mnem(p, &["branch", "create", "side"]).assert().success();
add_node(p, "main extra commit after side creation");
let out = mnem(p, &["checkout", "side"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("switched to branch 'side'"),
"checkout alias must produce same output as switch, got: {stdout}"
);
assert_eq!(active_branch_from_list(p), "side");
}
#[test]
fn switch_accepts_fully_qualified_ref() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "a commit");
let main_head = head_cid(p);
mnem(p, &["branch", "create", "qbranch"]).assert().success();
add_node(p, "another commit");
let out = mnem(p, &["switch", "refs/heads/qbranch"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("switched to branch"),
"switch with full refname should succeed, got: {stdout}"
);
assert_eq!(
head_cid(p),
main_head,
"HEAD should be at qbranch tip (= main at creation time)"
);
}
#[test]
fn switch_bug38_guard_same_cid_branches() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "shared base node");
let shared_cid = head_cid(p);
mnem(p, &["branch", "create", "b"]).assert().success();
let active_before = active_branch_from_list(p);
assert_eq!(active_before, "main", "sanity: should start on main");
let out = mnem(p, &["switch", "b"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
assert!(
stdout.contains("switched to branch 'b'"),
"switch to same-CID branch must report a real switch, got: {stdout}"
);
assert_eq!(
head_cid(p),
shared_cid,
"HEAD CID must stay the same after switching between same-CID branches"
);
let active_after = active_branch_from_list(p);
assert_eq!(
active_after, "b",
"active_branch must be 'b' after switching to it (only extra[\"active_branch\"] can disambiguate)"
);
}
#[test]
fn switch_subsequent_commit_advances_correct_branch() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
add_node(p, "initial commit");
let main_tip_before = head_cid(p);
mnem(p, &["branch", "create", "feature"]).assert().success();
mnem(p, &["switch", "feature"]).assert().success();
assert_eq!(
active_branch_from_list(p),
"feature",
"must be on feature after switch"
);
add_node(p, "feature-only node");
let feature_tip = head_cid(p);
assert_ne!(
feature_tip, main_tip_before,
"feature tip must have advanced past main's old tip"
);
let out = mnem(p, &["branch", "list"]).assert().success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout).to_string();
let feature_line = stdout
.lines()
.find(|l| l.contains("feature"))
.unwrap_or_else(|| panic!("`feature` not found in branch list:\n{stdout}"));
assert!(
feature_line.contains(&feature_tip),
"feature branch tip must show the new commit CID, got: {feature_line}"
);
let main_line = stdout
.lines()
.find(|l| {
let stripped = l.trim_start_matches("* ").trim_start_matches(" ");
stripped.starts_with("main")
})
.unwrap_or_else(|| panic!("`main` not found in branch list:\n{stdout}"));
assert!(
main_line.contains(&main_tip_before),
"main branch tip must NOT have advanced (commit went to feature), got: {main_line}"
);
}
#[test]
fn switch_empty_repo_errors_cleanly() {
let dir = TempDir::new().unwrap();
let p = dir.path();
init(p);
let out = mnem(p, &["switch", "nonexistent-branch"])
.assert()
.failure();
let stderr = String::from_utf8_lossy(&out.get_output().stderr).to_string();
assert!(
!stderr.is_empty(),
"switch to nonexistent branch on fresh repo must produce an error on stderr"
);
assert!(
stderr.contains("nonexistent-branch")
&& (stderr.contains("not found") || stderr.contains("does not exist")),
"error must mention the branch name and indicate it was not found, got: {stderr}"
);
mnem(p, &["status"]).assert().success();
}