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,
source: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
struct Run {
stdout: String,
stderr: String,
success: bool,
}
impl Sandbox {
fn new(name: &str) -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-itlc-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join(name);
let sb = Sandbox {
base: base.clone(),
source: source.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
write(&source.join("README.md"), "# fixture\n");
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
sb
}
fn mind(&self, args: &[&str]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.output()
.expect("run 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 mind_cwd(&self, args: &[&str], cwd: &Path) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.current_dir(cwd)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.output()
.expect("run 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 write_and_commit(&self, rel: &str, contents: &str) {
write(&self.source.join(rel), contents);
git(&self.source, &["add", "-A"]);
git(&self.source, &["commit", "-qm", "fixture"]);
}
fn source_spec(&self) -> String {
self.source.to_string_lossy().into_owned()
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write(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 read_log(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.filter(|l| !l.is_empty())
.collect()
}
#[test]
fn item_hooks_array_runs_install_then_uninstall_in_declaration_order() {
let sb = Sandbox::new("arr");
let log = sb.base.join("order.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo i1 >> {lg}\"\n",
"name = \"first install\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo u1 >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo i2 >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo u2 >> {lg}\"\n",
"event = \"uninstall\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check",
]);
assert!(
learn.success,
"learn should run both install hooks: {} {}",
learn.stdout, learn.stderr
);
assert_eq!(read_log(&log), vec!["i1", "i2"], "install hooks in order");
let forget = sb.mind(&[
"forget",
"skill:greet",
"--dangerously-skip-install-hook-check",
]);
assert!(
forget.success,
"forget should run both uninstall hooks: {} {}",
forget.stdout, forget.stderr
);
assert_eq!(
read_log(&log),
vec!["i1", "i2", "u1", "u2"],
"uninstall hooks run in declaration order after install"
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"item removed after its uninstall hooks"
);
}
#[test]
fn scalar_install_uninstall_still_work_as_one_required_hook_each() {
let sb = Sandbox::new("scal");
let log = sb.base.join("scalar.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"install = \"echo SCALAR-INSTALL >> {lg}\"\n",
"uninstall = \"echo SCALAR-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check"
])
.success
);
assert_eq!(read_log(&log), vec!["SCALAR-INSTALL"], "scalar install ran");
assert!(
sb.mind(&[
"forget",
"skill:greet",
"--dangerously-skip-install-hook-check"
])
.success
);
assert_eq!(
read_log(&log),
vec!["SCALAR-INSTALL", "SCALAR-UNINSTALL"],
"scalar uninstall ran on forget"
);
}
#[test]
fn unmeld_runs_item_uninstall_hooks_before_source_uninstall_hooks() {
let sb = Sandbox::new("nest");
let log = sb.base.join("teardown.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"echo ITEM-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check"
])
.success,
"item must install before unmeld"
);
assert!(read_log(&log).is_empty(), "no teardown hooks at meld/learn");
let unmeld = sb.mind(&["unmeld", "nest", "--dangerously-skip-install-hook-check"]);
assert!(
unmeld.success,
"unmeld should succeed: {} {}",
unmeld.stdout, unmeld.stderr
);
assert_eq!(
read_log(&log),
vec!["ITEM-UNINSTALL", "SOURCE-UNINSTALL"],
"item uninstall hook must fire before the source uninstall hook"
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
!sources.contains("nest"),
"source must be removed after unmeld: {sources}"
);
}
#[test]
fn unmeld_non_tty_skips_hooks_but_still_removes_source_and_items() {
let sb = Sandbox::new("ntty");
let log = sb.base.join("skip.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"echo ITEM-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check"
])
.success
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item installed before unmeld"
);
let unmeld = sb.mind(&["unmeld", "ntty"]);
assert!(
unmeld.success,
"non-TTY unmeld still succeeds: {} {}",
unmeld.stdout, unmeld.stderr
);
assert!(
read_log(&log).is_empty(),
"non-TTY must skip both item and source uninstall hooks: {:?}",
read_log(&log)
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"item must still be removed even though its uninstall hook was skipped"
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
!sources.contains("ntty"),
"source must still be removed: {sources}"
);
}
#[test]
fn unmeld_yes_runs_item_uninstall_hooks_for_multiple_items_before_source() {
let sb = Sandbox::new("multi");
let log = sb.base.join("multi.log");
let lg = log.display();
write(
&sb.source.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
write(
&sb.source.join("skills/beta/SKILL.md"),
"---\ndescription: beta\n---\n# beta\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"uninstall = \"echo ITEM-ALPHA >> {lg}\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
"uninstall = \"echo ITEM-BETA >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:alpha",
"--dangerously-skip-install-hook-check"
])
.success
);
assert!(
sb.mind(&[
"learn",
"skill:beta",
"--dangerously-skip-install-hook-check"
])
.success
);
let unmeld = sb.mind(&[
"unmeld",
"multi",
"--yes",
"--dangerously-skip-install-hook-check",
]);
assert!(
unmeld.success,
"multi-item unmeld --yes should succeed: {} {}",
unmeld.stdout, unmeld.stderr
);
let lines = read_log(&log);
let src_pos = lines
.iter()
.position(|l| l == "SOURCE-UNINSTALL")
.expect("source uninstall hook must run");
let alpha_pos = lines
.iter()
.position(|l| l == "ITEM-ALPHA")
.expect("alpha uninstall hook must run");
let beta_pos = lines
.iter()
.position(|l| l == "ITEM-BETA")
.expect("beta uninstall hook must run");
assert!(
alpha_pos < src_pos && beta_pos < src_pos,
"both item uninstall hooks must precede the source uninstall hook: {lines:?}"
);
assert!(
!sb.mind_home.join("store/skill/alpha").exists()
&& !sb.mind_home.join("store/skill/beta").exists(),
"both items removed after unmeld"
);
}
#[test]
fn meld_runs_source_install_hook_before_item_install_hooks() {
let sb = Sandbox::new("instord");
let log = sb.base.join("install_order.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-INSTALL >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"install = \"echo ITEM-INSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
let meld = sb.mind(&[
"meld",
&spec,
"--yes",
"--dangerously-skip-install-hook-check",
]);
assert!(
meld.success,
"meld --yes --dangerously-skip should succeed: {} {}",
meld.stdout, meld.stderr
);
let lines = read_log(&log);
assert!(
lines.contains(&"SOURCE-INSTALL".to_string()),
"source install hook must run: {lines:?}"
);
assert!(
lines.contains(&"ITEM-INSTALL".to_string()),
"item install hook must run: {lines:?}"
);
let src_pos = lines
.iter()
.position(|l| l == "SOURCE-INSTALL")
.expect("SOURCE-INSTALL must appear in the log");
let item_pos = lines
.iter()
.position(|l| l == "ITEM-INSTALL")
.expect("ITEM-INSTALL must appear in the log");
assert!(
src_pos < item_pos,
"source install hook (pos {src_pos}) must precede item install hook (pos {item_pos}): {lines:?}"
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item must be in the store after a successful meld --yes"
);
}
#[test]
fn unmeld_item_uninstall_hook_failure_leaves_source_melded() {
let sb = Sandbox::new("failun");
let log = sb.base.join("fail.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"exit 7\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check"
])
.success
);
let unmeld = sb.mind(&["unmeld", "failun", "--dangerously-skip-install-hook-check"]);
assert!(
!unmeld.success,
"an item uninstall-hook failure must fail the unmeld: {} {}",
unmeld.stdout, unmeld.stderr
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item must remain installed after its uninstall hook failed"
);
assert!(
!read_log(&log).contains(&"SOURCE-UNINSTALL".to_string()),
"source uninstall hook must NOT run when an item uninstall hook failed: {:?}",
read_log(&log)
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
sources.contains("failun"),
"source must remain melded after a failed item uninstall hook: {sources}"
);
let combined = format!("{}{}", unmeld.stdout, unmeld.stderr);
assert!(
combined.contains("greet"),
"the failure output must reference the failing item 'greet': {combined}"
);
assert!(
combined.contains("exit 7")
|| combined.contains("HookFailed")
|| combined.contains("failed"),
"the failure output must surface the hook exit code or error: {combined}"
);
}
#[test]
fn item_install_hook_failure_rolls_back_the_item_install() {
let sb = Sandbox::new("failin");
let log = sb.base.join("failin.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo FIRST-RAN >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"exit 3\"\n",
"event = \"install\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check",
]);
assert!(
!learn.success,
"a failing item install hook must fail the learn: {} {}",
learn.stdout, learn.stderr
);
assert_eq!(
read_log(&log),
vec!["FIRST-RAN"],
"install hooks run in order; the first ran before the second failed"
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"the failed item install must be rolled back (store copy removed)"
);
let manifest = std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap_or_default();
assert!(
!manifest.contains("skill:greet"),
"the rolled-back item must not be recorded in the manifest: {manifest}"
);
}
#[test]
fn optional_item_hook_parses_and_runs_two_way_without_abort() {
let sb = Sandbox::new("opt");
let log = sb.base.join("opt.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo OPT-RAN >> {lg}\"\n",
"optional = true\n",
"event = \"install\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn_skip = sb.mind(&["learn", "skill:greet"]);
assert!(
learn_skip.success,
"non-TTY install with an optional hook still installs the item: {} {}",
learn_skip.stdout, learn_skip.stderr
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item installs even though its optional hook was skipped"
);
assert!(
read_log(&log).is_empty(),
"non-TTY skip: the optional hook must not have run: {:?}",
read_log(&log)
);
assert!(sb.mind(&["forget", "skill:greet"]).success);
let learn_run = sb.mind(&[
"learn",
"skill:greet",
"--dangerously-skip-install-hook-check",
]);
assert!(
learn_run.success,
"optional hook runs unattended under the dangerous flag: {} {}",
learn_run.stdout, learn_run.stderr
);
assert_eq!(
read_log(&log),
vec!["OPT-RAN"],
"the optional install hook ran unattended"
);
}
fn init_fixture(name: &str, prefix: Option<&str>) -> Sandbox {
let sb = Sandbox::new(name);
write(
&sb.source.join("agents/dev.md"),
"---\ndescription: dev agent\n---\n# dev\nHand off to review when done.\n",
);
write(
&sb.source.join("agents/review.md"),
"---\ndescription: review agent\n---\n# review\n",
);
if let Some(p) = prefix {
write(
&sb.source.join("mind.toml"),
&format!("[source]\nprefix = \"{p}\"\n"),
);
}
sb
}
#[test]
fn init_source_without_prefix_emits_no_unguarded_reference_advisory() {
let sb = init_fixture("noprefix", None);
let r = sb.mind_cwd(&["init-source", "."], &sb.source);
assert!(
r.success,
"init-source should succeed: {} {}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
!combined.contains("unguarded-reference"),
"no prefix => no unguarded-reference advisory: {combined}"
);
}
#[test]
fn init_source_with_prefix_emits_the_unguarded_reference_advisory() {
let sb = init_fixture("prefixed", Some("jk"));
let r = sb.mind_cwd(&["init-source", "."], &sb.source);
assert!(
r.success,
"init-source should succeed: {} {}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("unguarded-reference"),
"a prefix in force must flag the bare reference: {combined}"
);
let advisory_line = combined.lines().find(|l| l.contains("unguarded-reference"));
assert!(
advisory_line.is_some(),
"must have a line containing 'unguarded-reference': {combined}"
);
let advisory_line = advisory_line.unwrap();
assert!(
advisory_line.contains("review"),
"the unguarded-reference advisory line must name the sibling 'review': {advisory_line}"
);
assert!(
advisory_line.contains("dev"),
"the unguarded-reference advisory line must name the referencing item 'dev': {advisory_line}"
);
}