use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
base: PathBuf,
dest: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
struct Run {
stdout: String,
stderr: String,
success: bool,
}
impl Sandbox {
fn new() -> Self {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dest = base.join("personal");
let sb = Sandbox {
base: base.clone(),
dest: dest.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
git_init(&dest);
sb
}
fn mind(&self, args: &[&str]) -> Run {
self.run(args, None, &[])
}
fn mind_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Run {
self.run(args, None, envs)
}
fn run(&self, args: &[&str], input: Option<&str>, envs: &[(&str, &str)]) -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.env_remove("MIND_ABSORB_TO")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped());
for (k, v) in envs {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn mind");
if let Some(text) = input {
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(text.as_bytes())
.unwrap();
}
let out = child.wait_with_output().expect("wait mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
fn place_unmanaged_skill(&self, name: &str) -> PathBuf {
let p = self.claude_home.join("skills").join(name);
write_file(&p.join("SKILL.md"), &format!("# {name} skill\n"));
p
}
fn place_unmanaged_agent(&self, name: &str) -> PathBuf {
let p = self.claude_home.join("agents").join(format!("{name}.md"));
write_file(&p, &format!("# {name} agent\n"));
p
}
fn place_unmanaged_rule(&self, name: &str) -> PathBuf {
let p = self.claude_home.join("rules").join(format!("{name}.md"));
write_file(&p, &format!("# {name} rule\n"));
p
}
fn dest_spec(&self) -> String {
self.dest.to_string_lossy().into_owned()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write_file(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {dir:?}");
}
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(dir, &["config", "user.email", "t@t"]);
git(dir, &["config", "user.name", "t"]);
let readme = dir.join("README.md");
std::fs::write(&readme, "# personal\n").unwrap();
git(dir, &["add", "README.md"]);
git(dir, &["commit", "-qm", "init"]);
}
fn last_commit_msg(dir: &Path) -> String {
let out = Command::new("git")
.args(["log", "-1", "--pretty=format:%s"])
.current_dir(dir)
.output()
.expect("git log");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
#[test]
fn abs1_absorb_skill_installs_managed_symlink() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_skill("review");
assert!(lobe_path.exists(), "sanity: unmanaged skill must exist");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(
r.success,
"absorb skill:review must succeed: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
is_symlink(&lobe_path),
"after absorb the lobe path must be a managed symlink, not the original dir"
);
let recall = sb.mind(&["recall"]);
assert!(
recall.stdout.contains("review"),
"absorbed item must appear in recall: {}",
recall.stdout
);
}
#[test]
fn abs1_absorb_agent_installs_managed_symlink() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_agent("dev");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "agent:dev", "--to", &dest, "--yes"]);
assert!(
r.success,
"absorb agent:dev must succeed: stderr={}",
r.stderr
);
assert!(
is_symlink(&lobe_path),
"lobe path must be a managed symlink after absorb"
);
let recall = sb.mind(&["recall"]);
assert!(
recall.stdout.contains("dev"),
"dev must appear in recall after absorb"
);
}
#[test]
fn abs1_absorb_rule_installs_managed_symlink() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_rule("style");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "rule:style", "--to", &dest, "--yes"]);
assert!(
r.success,
"absorb rule:style must succeed: stderr={}",
r.stderr
);
assert!(
is_symlink(&lobe_path),
"lobe path must be a managed symlink after absorb"
);
}
#[test]
fn abs1_glob_ref_is_invalid_item_ref() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:*", "--to", &dest]);
assert!(
!r.success,
"a glob ref must fail: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("not a valid item ref") || r.stderr.contains("InvalidItemRef"),
"error must mention invalid item ref: {}",
r.stderr
);
}
#[test]
fn abs1_source_qualified_ref_never_matches() {
let sb = Sandbox::new();
let lobe = sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "owner/repo#skill:review", "--to", &dest, "--yes"]);
assert!(
!r.success,
"a source-qualified ref must not match an unmanaged item: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("not installed") || r.stderr.contains("NotInstalled"),
"error must be NotInstalled: {}",
r.stderr
);
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe must be unchanged after a sourceless ref miss"
);
}
#[test]
fn abs1_kind_prefix_disambiguates_same_name() {
let sb = Sandbox::new();
let skill_lobe = sb.place_unmanaged_skill("shared");
let agent_lobe = sb.place_unmanaged_agent("shared");
let dest = sb.dest_spec();
let ambiguous = sb.mind(&["absorb", "shared", "--to", &dest, "--yes"]);
assert!(
!ambiguous.success,
"a bare name shared across kinds must be ambiguous: stdout={} stderr={}",
ambiguous.stdout, ambiguous.stderr
);
assert!(
ambiguous.stderr.contains("ambiguous")
|| ambiguous.stderr.contains("Ambiguous")
|| ambiguous.stderr.contains("matches"),
"error must indicate ambiguity: {}",
ambiguous.stderr
);
assert!(
skill_lobe.exists() && !is_symlink(&skill_lobe),
"skill lobe must be unchanged after an ambiguous ref"
);
assert!(
agent_lobe.exists() && !is_symlink(&agent_lobe),
"agent lobe must be unchanged after an ambiguous ref"
);
let r = sb.mind(&["absorb", "agent:shared", "--to", &dest, "--yes"]);
assert!(
r.success,
"agent:shared must resolve and absorb: stderr={}",
r.stderr
);
assert!(
is_symlink(&agent_lobe),
"the agent lobe must become a managed symlink"
);
assert!(
sb.dest.join("agents").join("shared.md").exists(),
"the agent must be at agents/shared.md in the destination"
);
assert!(
!sb.dest.join("skills").join("shared").exists(),
"the skill must NOT have been absorbed by an agent: ref"
);
assert!(
skill_lobe.exists() && !is_symlink(&skill_lobe),
"the same-named skill must remain unmanaged after absorbing only the agent"
);
}
#[test]
fn abs1_unresolved_ref_is_not_installed() {
let sb = Sandbox::new();
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:nonexistent", "--to", &dest]);
assert!(
!r.success,
"ref with no match must fail: stderr={}",
r.stderr
);
assert!(
r.stderr.contains("not installed") || r.stderr.contains("NotInstalled"),
"error must indicate not installed: {}",
r.stderr
);
}
#[test]
fn abs2_to_flag_beats_env() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let other_dest = sb.base.join(format!("other-dest-{n}"));
git_init(&other_dest);
let dest = sb.dest_spec();
let other_dest_str = other_dest.to_string_lossy().into_owned();
let r = sb.mind_env(
&["absorb", "skill:review", "--to", &dest, "--yes"],
&[("MIND_ABSORB_TO", &other_dest_str)],
);
assert!(
r.success,
"--to flag must take precedence over MIND_ABSORB_TO: stderr={}",
r.stderr
);
let skill_in_dest = sb.dest.join("skills").join("review");
assert!(
skill_in_dest.exists(),
"skill must be in --to destination, not the env destination: {skill_in_dest:?}"
);
let skill_in_other = other_dest.join("skills").join("review");
assert!(
!skill_in_other.exists(),
"skill must NOT be in MIND_ABSORB_TO destination"
);
}
#[test]
fn abs2_env_beats_config_absorb_to() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let config_dest = sb.base.join(format!("config-dest-{n}"));
git_init(&config_dest);
let config_path = sb.mind_home.join("config.toml");
std::fs::create_dir_all(&sb.mind_home).unwrap();
std::fs::write(
&config_path,
format!("absorb_to = \"{}\"\n", config_dest.to_string_lossy()),
)
.unwrap();
let dest = sb.dest_spec();
let r = sb.mind_env(
&["absorb", "skill:review", "--yes"],
&[("MIND_ABSORB_TO", &dest)],
);
assert!(
r.success,
"MIND_ABSORB_TO must beat config.absorb_to: stderr={}",
r.stderr
);
let skill_in_dest = sb.dest.join("skills").join("review");
assert!(
skill_in_dest.exists(),
"skill must be in MIND_ABSORB_TO destination"
);
let skill_in_config = config_dest.join("skills").join("review");
assert!(
!skill_in_config.exists(),
"skill must NOT be in config.absorb_to destination"
);
}
#[test]
fn abs2_config_absorb_to_is_used_as_fallback() {
let sb = Sandbox::new();
sb.place_unmanaged_rule("style");
let dest = sb.dest_spec();
std::fs::create_dir_all(&sb.mind_home).unwrap();
std::fs::write(
sb.mind_home.join("config.toml"),
format!("absorb_to = \"{dest}\"\n"),
)
.unwrap();
let r = sb.mind(&["absorb", "rule:style", "--yes"]);
assert!(
r.success,
"config.absorb_to must be used as fallback: stderr={}",
r.stderr
);
let rule_in_dest = sb.dest.join("rules").join("style.md");
assert!(
rule_in_dest.exists(),
"rule must land in config.absorb_to destination"
);
}
#[test]
fn abs3_non_tty_no_dest_is_confirmation_required() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let r = sb.mind(&["absorb", "skill:review"]);
assert!(
!r.success,
"non-TTY with no dest must fail: stderr={}",
r.stderr
);
assert!(
r.stderr.contains("needs confirmation") || r.stderr.contains("ConfirmationRequired"),
"error must indicate ConfirmationRequired: {}",
r.stderr
);
let lobe = sb.claude_home.join("skills").join("review");
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe entry must be unchanged after a refused absorb"
);
}
#[test]
fn abs4_to_flag_dest_does_not_save_absorb_to() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let config_path = sb.mind_home.join("config.toml");
assert!(
!config_path.exists(),
"sanity: config.toml must not exist before absorb"
);
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(r.success, "absorb must succeed: stderr={}", r.stderr);
if config_path.exists() {
let contents = std::fs::read_to_string(&config_path).unwrap();
assert!(
!contents.contains("absorb_to"),
"--to destination must not save absorb_to in config: {contents}"
);
}
}
#[test]
fn abs5_commit_message_is_absorb_kind_name() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(r.success, "absorb must succeed: stderr={}", r.stderr);
let msg = last_commit_msg(&sb.dest);
assert_eq!(
msg, "absorb skill:review",
"commit message must be 'absorb skill:review', got: {msg}"
);
}
#[test]
fn abs5_non_repo_dest_is_error() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let plain_dir = sb.base.join(format!("notarepo-{n}"));
std::fs::create_dir_all(&plain_dir).unwrap();
let plain_str = plain_dir.to_string_lossy().into_owned();
let r = sb.mind(&["absorb", "skill:review", "--to", &plain_str, "--yes"]);
assert!(
!r.success,
"a non-repo destination must fail: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("not a git repository") || r.stderr.contains("DestinationNotRepo"),
"error must mention not a git repository: {}",
r.stderr
);
let lobe = sb.claude_home.join("skills").join("review");
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe must be unchanged after failed absorb"
);
}
#[test]
fn abs6_collision_without_force_is_error() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let collision = sb.dest.join("skills").join("review");
write_file(&collision.join("SKILL.md"), "# existing skill\n");
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add existing skill"]);
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(
!r.success,
"collision without --force must fail: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("already has") || r.stderr.contains("AbsorbCollision"),
"error must mention collision: {}",
r.stderr
);
let lobe = sb.claude_home.join("skills").join("review");
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe must be unchanged after a collision error"
);
let dest_content = std::fs::read_to_string(collision.join("SKILL.md")).unwrap();
assert!(
dest_content.contains("existing skill"),
"destination must not be clobbered: {dest_content}"
);
}
#[test]
fn abs6_collision_with_force_overwrites() {
let sb = Sandbox::new();
let lobe_skill = sb.claude_home.join("skills").join("review");
write_file(&lobe_skill.join("SKILL.md"), "# LOBE VERSION\n");
let dest = sb.dest_spec();
let collision = sb.dest.join("skills").join("review");
write_file(&collision.join("SKILL.md"), "# DEST VERSION\n");
write_file(&collision.join("stale.txt"), "only in old dest\n");
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add existing"]);
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--force", "--yes"]);
assert!(
r.success,
"absorb --force must overwrite collision: stderr={}",
r.stderr
);
let lobe = sb.claude_home.join("skills").join("review");
assert!(
is_symlink(&lobe),
"lobe must be a managed symlink after --force absorb"
);
let dest_skill = std::fs::read_to_string(collision.join("SKILL.md")).unwrap();
assert!(
dest_skill.contains("LOBE VERSION"),
"destination SKILL.md must be the absorbed lobe version: {dest_skill}"
);
assert!(
!collision.join("stale.txt").exists(),
"old destination-only file must be removed by --force overwrite (replace, not merge)"
);
}
#[test]
fn abs7_multi_lobe_stray_copies_deleted_with_yes() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-ml-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dest = base.join("personal");
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let lobe2 = base.join("lobe2");
git_init(&dest);
let skill1 = lobe1.join("skills").join("myskill");
write_file(&skill1.join("SKILL.md"), "# myskill\n");
let skill2 = lobe2.join("skills").join("myskill");
write_file(&skill2.join("SKILL.md"), "# myskill\n");
std::fs::create_dir_all(&mind_home).unwrap();
let lobe1_str = lobe1.to_string_lossy();
let lobe2_str = lobe2.to_string_lossy();
std::fs::write(
mind_home.join("config.toml"),
format!("lobes = [\"{lobe1_str}\", \"{lobe2_str}\"]\n"),
)
.unwrap();
let dest_str = dest.to_string_lossy().into_owned();
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(["absorb", "skill:myskill", "--to", &dest_str, "--yes"])
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &lobe1)
.env_remove("MIND_ABSORB_TO")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.output()
.expect("run mind");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"multi-lobe absorb with --yes must succeed: stdout={stdout} stderr={stderr}"
);
assert!(
is_symlink(&skill1),
"primary lobe path must be managed symlink after absorb"
);
assert!(
is_symlink(&skill2),
"stray copy in lobe2 must be replaced by managed symlink after absorb"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn abs7_multi_lobe_non_tty_without_yes_is_confirmation_required() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-ml-nontty-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dest = base.join("personal");
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let lobe2 = base.join("lobe2");
git_init(&dest);
let skill1 = lobe1.join("skills").join("myskill");
write_file(&skill1.join("SKILL.md"), "# myskill\n");
let skill2 = lobe2.join("skills").join("myskill");
write_file(&skill2.join("SKILL.md"), "# myskill\n");
std::fs::create_dir_all(&mind_home).unwrap();
let lobe1_str = lobe1.to_string_lossy();
let lobe2_str = lobe2.to_string_lossy();
std::fs::write(
mind_home.join("config.toml"),
format!("lobes = [\"{lobe1_str}\", \"{lobe2_str}\"]\n"),
)
.unwrap();
let dest_str = dest.to_string_lossy().into_owned();
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(["absorb", "skill:myskill", "--to", &dest_str])
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &lobe1)
.env_remove("MIND_ABSORB_TO")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.output()
.expect("run mind");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"multi-lobe non-TTY without --yes must fail: stderr={stderr}"
);
assert!(
stderr.contains("needs confirmation") || stderr.contains("ConfirmationRequired"),
"must be ConfirmationRequired: {stderr}"
);
assert!(
skill1.exists() && !is_symlink(&skill1),
"lobe1 skill must be unchanged"
);
assert!(
skill2.exists() && !is_symlink(&skill2),
"lobe2 skill must be unchanged"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn abs8_manifest_keyed_effective_name() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(r.success, "absorb must succeed: stderr={}", r.stderr);
let recall = sb.mind(&["recall", "skill:review"]);
assert!(
recall.success,
"recall skill:review must succeed after absorb: stderr={}",
recall.stderr
);
assert!(
recall.stdout.contains("review"),
"recall must show the absorbed item: {}",
recall.stdout
);
}
#[test]
fn abs8_effective_name_follows_destination_prefix() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
write_file(&sb.dest.join("mind.toml"), "[source]\nprefix = \"mypfx\"\n");
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add mind.toml"]);
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(
r.success,
"absorb must succeed with prefixed dest: stderr={}",
r.stderr
);
let recall = sb.mind(&["recall", "skill:mypfx-review"]);
assert!(
recall.success,
"recall skill:mypfx-review must work after absorb with prefix: stderr={}",
recall.stderr
);
let link = sb.claude_home.join("skills").join("mypfx-review");
assert!(
is_symlink(&link),
"managed link must be at skills/mypfx-review when destination has prefix mypfx: {link:?}"
);
}
#[test]
fn abs9_help_text_states_destination_ways() {
let sb = Sandbox::new();
let r = sb.mind(&["absorb", "--help"]);
let text = format!("{}\n{}", r.stdout, r.stderr);
assert!(
text.contains("--to") || text.contains("MIND_ABSORB_TO") || text.contains("absorb_to"),
"help must mention at least one of the three destination ways: {text}"
);
assert!(
text.contains("MIND_ABSORB_TO"),
"help must mention MIND_ABSORB_TO env var: {text}"
);
assert!(
text.contains("absorb_to") || text.contains("config.toml"),
"help must mention config.toml absorb_to: {text}"
);
assert!(
text.contains("precedence") || text.contains("takes precedence"),
"help must explicitly state precedence: {text}"
);
let to_pos = text.find("--to").expect("help mentions --to");
let env_pos = text
.find("MIND_ABSORB_TO")
.expect("help mentions MIND_ABSORB_TO");
let cfg_pos = text.find("absorb_to").expect("help mentions absorb_to");
assert!(
to_pos < env_pos && env_pos < cfg_pos,
"help must list the destination ways in precedence order \
(--to < MIND_ABSORB_TO < absorb_to); got positions to={to_pos} env={env_pos} cfg={cfg_pos} in:\n{text}"
);
}
#[test]
fn abs10_bad_dest_leaves_lobe_intact_and_manifest_unchanged() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let plain_dir = sb.base.join(format!("notarepo-{n}"));
std::fs::create_dir_all(&plain_dir).unwrap();
let plain_str = plain_dir.to_string_lossy().into_owned();
let r = sb.mind(&["absorb", "skill:review", "--to", &plain_str, "--yes"]);
assert!(!r.success, "must fail with bad destination");
let lobe = sb.claude_home.join("skills").join("review");
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe must be unchanged after a failed absorb"
);
let recall = sb.mind(&["recall"]);
assert!(
!recall.stdout.contains("[managed]") || !recall.stdout.contains("review"),
"skill:review must not appear as managed after a failed absorb: {}",
recall.stdout
);
}
#[test]
fn abs10_collision_leaves_lobe_and_manifest_unchanged() {
let sb = Sandbox::new();
sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let collision = sb.dest.join("skills").join("review");
write_file(&collision.join("SKILL.md"), "# existing\n");
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add collision"]);
let r = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(
!r.success,
"collision must cause failure: stderr={}",
r.stderr
);
let lobe = sb.claude_home.join("skills").join("review");
assert!(
lobe.exists() && !is_symlink(&lobe),
"lobe must be unchanged after collision error"
);
let existing_content = std::fs::read_to_string(collision.join("SKILL.md")).unwrap();
assert!(
existing_content.contains("existing"),
"destination must not be clobbered"
);
let recall = sb.mind(&["recall", "skill:review"]);
assert!(
!recall.success,
"skill:review must not be in manifest after failed absorb"
);
}
#[test]
fn abs8_forget_is_inverse_of_absorb() {
let sb = Sandbox::new();
let lobe = sb.place_unmanaged_skill("review");
let dest = sb.dest_spec();
let absorb = sb.mind(&["absorb", "skill:review", "--to", &dest, "--yes"]);
assert!(
absorb.success,
"absorb must succeed: stderr={}",
absorb.stderr
);
assert!(
is_symlink(&lobe),
"lobe must be a managed symlink after absorb"
);
let forget = sb.mind(&["forget", "skill:review", "--yes"]);
assert!(
forget.success,
"forget of an absorbed item must succeed: stdout={} stderr={}",
forget.stdout, forget.stderr
);
assert!(
!is_symlink(&lobe),
"forget must remove the managed symlink installed by absorb"
);
let recall = sb.mind(&["recall", "skill:review"]);
assert!(
!recall.success,
"skill:review must not resolve as managed after forget: stdout={} stderr={}",
recall.stdout, recall.stderr
);
}
#[test]
fn absorb_command_parses() {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(["absorb", "--help"])
.output()
.expect("run mind absorb --help");
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
assert!(
text.contains("absorb") || text.contains("Absorb"),
"absorb --help must print help text: {text}"
);
}
#[test]
fn git_helpers_init_and_is_repo() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("mind-abs-githelp-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(
!Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false),
"sanity: empty dir must not be a git repo"
);
git_init(&dir);
assert!(
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false),
"after git_init, git rev-parse --git-dir must succeed"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn is_repo_distinguishes_repo_from_plain_dir() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let plain =
std::env::temp_dir().join(format!("mind-abs-isrepo-plain-{}-{n}", std::process::id()));
let repo =
std::env::temp_dir().join(format!("mind-abs-isrepo-repo-{}-{n}", std::process::id()));
std::fs::create_dir_all(&plain).unwrap();
git_init(&repo);
let plain_result = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&plain)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let repo_result = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(&repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(!plain_result, "plain dir must not be a git repo");
assert!(repo_result, "initialized repo must be a git repo");
let _ = std::fs::remove_dir_all(&plain);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn abs10_commit_failure_after_copy_restores_lobe_entry() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_agent("myagent");
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let bare_repo = sb
.base
.join(format!("bare-dest-{}-{n}", std::process::id()));
std::fs::create_dir_all(&bare_repo).unwrap();
let status = std::process::Command::new("git")
.args(["init", "--bare", "-q"])
.current_dir(&bare_repo)
.status()
.expect("git init --bare");
assert!(status.success(), "git init --bare must succeed");
let bare_str = bare_repo.to_string_lossy().into_owned();
let r = sb.mind(&["absorb", "agent:myagent", "--to", &bare_str, "--yes"]);
assert!(
!r.success,
"absorb into a bare repo must fail (git add fails): stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
lobe_path.exists(),
"lobe entry must still exist after commit failure: {lobe_path:?}"
);
assert!(
!is_symlink(&lobe_path),
"lobe entry must be the original file, not a managed symlink: {lobe_path:?}"
);
let recall = sb.mind(&["recall", "agent:myagent"]);
assert!(
!recall.success,
"agent:myagent must not be in manifest after failed absorb: stdout={} stderr={}",
recall.stdout, recall.stderr
);
}
#[test]
fn abs10_meld_failure_after_copy_leaves_lobe_intact() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_rule("meldfail");
let dest = sb.dest_spec();
write_file(
&sb.dest.join("mind.toml"),
"[source]\nthis is = = not valid toml ===\n",
);
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add bad mind.toml"]);
let r = sb.mind(&["absorb", "rule:meldfail", "--to", &dest, "--yes"]);
assert!(
!r.success,
"absorb must fail when meld rejects the dest mind.toml: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
lobe_path.exists() && !is_symlink(&lobe_path),
"lobe entry must be intact after a meld failure: {lobe_path:?}"
);
let recall = sb.mind(&["recall", "rule:meldfail"]);
assert!(
!recall.success,
"rule:meldfail must not be in manifest after a failed absorb"
);
}
#[test]
fn abs10_failure_does_not_delete_stray_copies_in_other_lobes() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-strays-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dest = base.join("personal");
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let lobe2 = base.join("lobe2");
git_init(&dest);
let skill1 = lobe1.join("skills").join("mystray");
write_file(&skill1.join("SKILL.md"), "# mystray primary\n");
let skill2 = lobe2.join("skills").join("mystray");
write_file(&skill2.join("SKILL.md"), "# mystray stray\n");
std::fs::create_dir_all(&mind_home).unwrap();
let lobe1_str = lobe1.to_string_lossy();
let lobe2_str = lobe2.to_string_lossy();
std::fs::write(
mind_home.join("config.toml"),
format!("lobes = [\"{lobe1_str}\", \"{lobe2_str}\"]\n"),
)
.unwrap();
write_file(
&dest.join("mind.toml"),
"[source]\n= = invalid = toml = =\n",
);
git(&dest, &["add", "-A"]);
git(&dest, &["commit", "-qm", "bad toml"]);
let dest_str = dest.to_string_lossy().into_owned();
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(["absorb", "skill:mystray", "--to", &dest_str, "--yes"])
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &lobe1)
.env_remove("MIND_ABSORB_TO")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.output()
.expect("run mind");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"absorb must fail on the malformed dest mind.toml: stdout={stdout} stderr={stderr}"
);
assert!(
skill1.exists() && !is_symlink(&skill1),
"primary lobe copy must survive a failed absorb"
);
assert!(
skill2.exists() && !is_symlink(&skill2),
"stray lobe copy must NOT be deleted by a failed absorb"
);
let stray_content = std::fs::read_to_string(skill2.join("SKILL.md")).unwrap();
assert!(
stray_content.contains("mystray stray"),
"stray copy content must be untouched: {stray_content}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn abs10_learn_failure_after_removal_restores_backup() {
let sb = Sandbox::new();
let lobe_path = sb.claude_home.join("skills").join("badref");
let original = "# badref skill\n\nhand off to {{ns:nonexistent}}\n";
write_file(&lobe_path.join("SKILL.md"), original);
let dest = sb.dest_spec();
let r = sb.mind(&["absorb", "skill:badref", "--to", &dest, "--yes"]);
assert!(
!r.success,
"absorb must fail when learn cannot resolve a dangling reference: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
lobe_path.exists(),
"lobe entry must be restored after a post-removal learn failure: {lobe_path:?}"
);
assert!(
!is_symlink(&lobe_path),
"restored lobe entry must be the original file, not a managed symlink"
);
let restored = std::fs::read_to_string(lobe_path.join("SKILL.md")).unwrap();
assert_eq!(
restored, original,
"restored lobe content must be byte-for-byte the original"
);
let recall = sb.mind(&["recall", "skill:badref"]);
assert!(
!recall.success,
"skill:badref must not be in manifest after a failed absorb"
);
let backup = sb
.mind_home
.join(".tmp")
.join("absorb-backup")
.join("skill")
.join("badref");
assert!(
!backup.exists(),
"the absorb backup must be cleaned up after a failed absorb: {backup:?}"
);
}
#[test]
fn abs7_json_mode_with_yes_proceeds() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_skill("jsonyes");
let dest = sb.dest_spec();
let r = sb.mind(&["--json", "--yes", "absorb", "skill:jsonyes", "--to", &dest]);
assert!(
r.success,
"absorb --json --yes must proceed: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
is_symlink(&lobe_path),
"lobe must be a managed symlink after absorb --json --yes"
);
let recall = sb.mind(&["recall", "skill:jsonyes"]);
assert!(
recall.success,
"skill:jsonyes must be installed after absorb --json --yes: {}",
recall.stdout
);
}
#[test]
fn c5_legitimate_nested_root_is_accepted() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_rule("nestedok");
let dest = sb.dest_spec();
std::fs::create_dir_all(sb.dest.join("sub")).unwrap();
write_file(&sb.dest.join("mind.toml"), "[source]\nroots = [\"sub\"]\n");
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add nested root"]);
let r = sb.mind(&["absorb", "rule:nestedok", "--to", &dest, "--yes"]);
assert!(
r.success,
"absorb into a legitimate nested root must succeed: stdout={} stderr={}",
r.stdout, r.stderr
);
let landed = sb.dest.join("sub").join("rules").join("nestedok.md");
assert!(
landed.exists(),
"item must land under the nested scan root: {landed:?}"
);
assert!(
is_symlink(&lobe_path),
"lobe must be a managed symlink after absorb into a nested root"
);
}
#[test]
fn c5_escaping_root_that_exists_on_disk_is_rejected() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_rule("escapereal");
let dest = sb.dest_spec();
let outside = sb.base.join("outside-real");
std::fs::create_dir_all(&outside).unwrap();
write_file(
&sb.dest.join("mind.toml"),
"[source]\nroots = [\"../outside-real\"]\n",
);
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add existing escaping root"]);
let r = sb.mind(&["absorb", "rule:escapereal", "--to", &dest, "--yes"]);
assert!(
!r.success,
"an existing escaping root (canonicalize branch) must be rejected: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
lobe_path.exists() && !is_symlink(&lobe_path),
"lobe entry must be unchanged after escaping-root rejection: {lobe_path:?}"
);
let leaked = outside.join("rules").join("escapereal.md");
assert!(
!leaked.exists(),
"nothing must be written outside the repo: {leaked:?}"
);
let recall = sb.mind(&["recall", "rule:escapereal"]);
assert!(
!recall.success,
"rule:escapereal must not be in manifest after rejection"
);
}
#[test]
fn abs7_json_mode_without_yes_when_stray_copies_is_confirmation_required() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-abs-json-c3-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dest = base.join("personal");
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let lobe2 = base.join("lobe2");
git_init(&dest);
let skill1 = lobe1.join("skills").join("myjson");
write_file(&skill1.join("SKILL.md"), "# myjson\n");
let skill2 = lobe2.join("skills").join("myjson");
write_file(&skill2.join("SKILL.md"), "# myjson\n");
std::fs::create_dir_all(&mind_home).unwrap();
let lobe1_str = lobe1.to_string_lossy();
let lobe2_str = lobe2.to_string_lossy();
std::fs::write(
mind_home.join("config.toml"),
format!("lobes = [\"{lobe1_str}\", \"{lobe2_str}\"]\n"),
)
.unwrap();
let dest_str = dest.to_string_lossy().into_owned();
let out = std::process::Command::new(env!("CARGO_BIN_EXE_mind"))
.args(["--json", "absorb", "skill:myjson", "--to", &dest_str])
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &lobe1)
.env_remove("MIND_ABSORB_TO")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped())
.output()
.expect("run mind");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
assert!(
!out.status.success(),
"absorb --json without --yes must fail when stray copies exist: stdout={stdout} stderr={stderr}"
);
assert!(
stderr.contains("needs confirmation") || stderr.contains("ConfirmationRequired"),
"must return ConfirmationRequired: stderr={stderr}"
);
assert!(
skill1.exists() && !is_symlink(&skill1),
"lobe1 skill must be unchanged"
);
assert!(
skill2.exists() && !is_symlink(&skill2),
"lobe2 skill must be unchanged"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn abs7_json_mode_single_lobe_without_yes_is_confirmation_required() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_skill("solo");
let dest = sb.dest_spec();
let r = sb.mind(&["--json", "absorb", "skill:solo", "--to", &dest]);
assert!(
!r.success,
"--json without --yes must fail even with single lobe: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("needs confirmation") || r.stderr.contains("ConfirmationRequired"),
"must return ConfirmationRequired: stderr={}",
r.stderr
);
assert!(
lobe_path.exists() && !is_symlink(&lobe_path),
"lobe must be unchanged after json refusal"
);
}
#[test]
fn c5_dest_roots_escaping_repo_is_error_nothing_moved() {
let sb = Sandbox::new();
let lobe_path = sb.place_unmanaged_rule("escaperule");
let dest = sb.dest_spec();
write_file(
&sb.dest.join("mind.toml"),
"[source]\nroots = [\"../../outside\"]\n",
);
git(&sb.dest, &["add", "-A"]);
git(&sb.dest, &["commit", "-qm", "add escaping roots"]);
let r = sb.mind(&["absorb", "rule:escaperule", "--to", &dest, "--yes"]);
assert!(
!r.success,
"a dest with escaping roots must fail: stdout={} stderr={}",
r.stdout, r.stderr
);
assert!(
lobe_path.exists() && !is_symlink(&lobe_path),
"lobe entry must be unchanged after escaping-roots error: {lobe_path:?}"
);
let recall = sb.mind(&["recall", "rule:escaperule"]);
assert!(
!recall.success,
"rule:escaperule must not be in manifest after failed absorb"
);
}