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-uh-{}-{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 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()
}
fn source_id(&self) -> String {
let owner = self
.base
.file_name()
.expect("base has a name")
.to_string_lossy();
let repo = self
.source
.file_name()
.expect("source has a name")
.to_string_lossy();
format!("local/{owner}/{repo}")
}
fn tally(&self, name: &str) -> String {
format!("echo ran >> {}", self.base.join(name).display())
}
fn runs(&self, name: &str) -> usize {
std::fs::read_to_string(self.base.join(name))
.map(|s| s.lines().count())
.unwrap_or(0)
}
}
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:?}");
}
#[test]
fn source_update_hook_replaces_the_install_rerun_at_upgrade() {
let sb = Sandbox::new("update-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"mind.toml",
&format!(
"[[hooks]]\nrun = \"{install}\"\n\n[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"
),
);
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(sb.runs("install.log"), 1, "meld runs the install hook");
assert_eq!(
sb.runs("update.log"),
0,
"an update hook must not run at meld: {}",
r.stdout
);
sb.write_and_commit("README.md", "# fixture v2\n");
assert!(sb.mind(&["sync"]).success, "sync must succeed");
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
1,
"the update hook must run at upgrade: {}",
r.stdout
);
assert_eq!(
sb.runs("install.log"),
1,
"the install hook must NOT be re-run when an update hook exists: {}",
r.stdout
);
}
#[test]
fn accept_hooks_events_gate_install_and_update_separately() {
let sb = Sandbox::new("accept-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"mind.toml",
&format!(
"[[hooks]]\nrun = \"{install}\"\n\n[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"
),
);
let r = sb.mind(&["meld", &sb.source_spec(), "--accept-hooks=update"]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(
sb.runs("install.log"),
0,
"--accept-hooks=update must not accept an install hook: {}",
r.stdout
);
let r = sb.mind(&["meld", &sb.source_spec(), "--accept-hooks=install"]);
assert!(r.success, "re-meld: {}", r.stderr);
assert_eq!(
sb.runs("install.log"),
1,
"the install hook runs: {}",
r.stdout
);
sb.write_and_commit("README.md", "# fixture v2\n");
let r = sb.mind(&["upgrade", "--yes", "--accept-hooks=install"]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
0,
"--accept-hooks=install must not accept an update hook: {}",
r.stdout
);
let r = sb.mind(&["upgrade", "--yes", "--accept-hooks=update"]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
1,
"--accept-hooks=update runs the pending update hook: {}",
r.stdout
);
assert_eq!(sb.runs("install.log"), 1);
}
#[test]
fn legacy_install_hook_flag_still_runs_hooks() {
let sb = Sandbox::new("legacy-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"mind.toml",
&format!(
"[[hooks]]\nrun = \"{install}\"\n\n[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"
),
);
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--dangerously-skip-install-hook-check",
]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(sb.runs("install.log"), 1, "{}", r.stdout);
sb.write_and_commit("README.md", "# fixture v2\n");
let r = sb.mind(&["upgrade", "--yes", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(sb.runs("update.log"), 1, "{}", r.stdout);
}
#[test]
fn source_without_an_update_hook_reruns_its_install_hook() {
let sb = Sandbox::new("plain-src");
let install = sb.tally("install.log");
sb.write_and_commit("mind.toml", &format!("[[hooks]]\nrun = \"{install}\"\n"));
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(sb.runs("install.log"), 1);
sb.write_and_commit("README.md", "# fixture v2\n");
assert!(sb.mind(&["sync"]).success, "sync must succeed");
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("install.log"),
2,
"an advanced source re-runs its install hook: {}",
r.stdout
);
}
#[test]
fn source_update_hook_is_recorded_and_not_rerun_at_the_same_commit() {
let sb = Sandbox::new("recorded-src");
let update = sb.tally("update.log");
sb.write_and_commit(
"mind.toml",
&format!("[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"),
);
assert!(
sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
])
.success
);
sb.write_and_commit("README.md", "# fixture v2\n");
assert!(sb.mind(&["sync"]).success);
assert!(
sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert_eq!(sb.runs("update.log"), 1);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "second upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
1,
"an update hook recorded at the current commit must not re-run: {}",
r.stdout
);
}
#[test]
fn an_unmoved_source_runs_no_update_hook_on_the_first_upgrade() {
let sb = Sandbox::new("baseline-src");
let update = sb.tally("update.log");
sb.write_and_commit(
"mind.toml",
&format!("[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"),
);
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(
sb.runs("update.log"),
0,
"an update hook must not run at meld"
);
assert!(sb.mind(&["sync"]).success, "sync must succeed");
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
0,
"an unmoved source must not run its update hook: {}\n{}",
r.stdout,
r.stderr
);
let r = sb.mind(&["hooks", "list", &sb.source_id()]);
assert!(r.success, "hooks list: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("recorded at meld"),
"the baseline must not be reported as a run: {}",
r.stdout
);
sb.write_and_commit("README.md", "# fixture v2\n");
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
1,
"an advanced source runs its update hook: {}",
r.stdout
);
}
#[test]
fn an_install_run_does_not_settle_the_same_command_on_the_update_event() {
let sb = Sandbox::new("aliased-src");
let both = sb.tally("both.log");
sb.write_and_commit(
"mind.toml",
&format!(
"[[hooks]]\nrun = \"{both}\"\n\n[[hooks]]\nrun = \"{both}\"\nevent = \"update\"\n"
),
);
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}", r.stderr);
assert_eq!(sb.runs("both.log"), 1, "the install hook ran at meld");
sb.write_and_commit("README.md", "# fixture v2\n");
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"hooks",
"run",
&sb.source_id(),
"--event",
"install",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "hooks run install: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("both.log"),
2,
"the install hook ran at the new commit"
);
let r = sb.mind(&[
"hooks",
"run",
&sb.source_id(),
"--event",
"update",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "hooks run update: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("both.log"),
3,
"the update hook is not settled by the install hook's run: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn hooks_run_reports_nothing_pending_instead_of_exiting_silently() {
let sb = Sandbox::new("settled-src");
let install = sb.tally("install.log");
sb.write_and_commit("mind.toml", &format!("[[hooks]]\nrun = \"{install}\"\n"));
assert!(
sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
])
.success
);
assert_eq!(sb.runs("install.log"), 1);
let r = sb.mind(&[
"hooks",
"run",
&sb.source_id(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "hooks run: {}\n{}", r.stdout, r.stderr);
assert_eq!(sb.runs("install.log"), 1, "nothing pending, so nothing ran");
let out = format!("{}{}", r.stdout, r.stderr);
assert!(
out.contains("nothing pending") && out.contains("--force"),
"a run with nothing pending must say so and name --force: {out}"
);
let r = sb.mind(&[
"hooks",
"run",
&sb.source_id(),
"--force",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "forced hooks run: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("install.log"),
2,
"--force re-runs the settled hook"
);
}
#[test]
fn a_withdrawn_install_hook_is_not_replayed_at_upgrade() {
let sb = Sandbox::new("withdrawn-src");
let install = sb.tally("install.log");
sb.write_and_commit("mind.toml", &format!("[[hooks]]\nrun = \"{install}\"\n"));
assert!(
sb.mind(&[
"meld",
&sb.source_spec(),
"--accept-hooks=install,update,uninstall",
])
.success
);
assert_eq!(sb.runs("install.log"), 1);
sb.write_and_commit("mind.toml", "[source]\ndescription = \"no hooks\"\n");
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("install.log"),
1,
"a command the source no longer declares must not be replayed: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn a_curated_update_hook_runs_at_upgrade_and_not_at_meld() {
let nested = Sandbox::new("curated-nested");
let registry = Sandbox::new("curated-registry");
let update = registry.tally("curated-update.log");
registry.write_and_commit(
"mind.toml",
&format!(
"[[discover.sources]]\nsource = \"{}\"\n\n\
[[discover.sources.hooks]]\nrun = \"{update}\"\nevent = \"update\"\n",
nested.source_spec()
),
);
let r = registry.mind(&[
"meld",
®istry.source_spec(),
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);
assert_eq!(
registry.runs("curated-update.log"),
0,
"an update hook must not run at meld: {}",
r.stdout
);
nested.write_and_commit("README.md", "# fixture v2\n");
assert!(registry.mind(&["sync"]).success, "sync must succeed");
let r = registry.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
registry.runs("curated-update.log"),
1,
"the curated update hook must run at upgrade: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn a_meld_install_hook_override_covers_the_update_event() {
let sb = Sandbox::new("override-src");
let theirs = sb.tally("theirs.log");
let mine = sb.tally("mine.log");
sb.write_and_commit("mind.toml", &format!("[[hooks]]\nrun = \"{theirs}\"\n"));
let r = sb.mind(&[
"meld",
&sb.source_spec(),
"--install-hook",
&mine,
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);
assert_eq!(sb.runs("mine.log"), 1, "the consumer's command ran");
assert_eq!(
sb.runs("theirs.log"),
0,
"the author's command was replaced"
);
sb.write_and_commit(
"mind.toml",
&format!("[[hooks]]\nrun = \"{theirs}\"\nevent = \"update\"\n"),
);
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("theirs.log"),
0,
"the override must cover the update event: {}\n{}",
r.stdout,
r.stderr
);
assert_eq!(
sb.runs("mine.log"),
2,
"the consumer's command is what runs on the update path: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn item_update_hook_replaces_the_install_hook_on_upgrade() {
let sb = Sandbox::new("item-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"skills/scanner/SKILL.md",
&format!(
"---\ndescription: scanner\ninstall: {install}\nupdate: {update}\n---\n# scanner\n"
),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let r = sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert_eq!(sb.runs("install.log"), 1, "a first install runs `install`");
assert_eq!(sb.runs("update.log"), 0, "a first install skips `update`");
sb.write_and_commit(
"skills/scanner/SKILL.md",
&format!(
"---\ndescription: scanner\ninstall: {install}\nupdate: {update}\n---\n# scanner v2\n"
),
);
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("update.log"),
1,
"an upgraded item runs its update hook: {}",
r.stdout
);
assert_eq!(
sb.runs("install.log"),
1,
"an upgraded item must not re-run its install hook: {}",
r.stdout
);
}
#[test]
fn relearning_an_installed_item_is_a_no_op_and_runs_no_hook() {
let sb = Sandbox::new("relearn-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"skills/scanner/SKILL.md",
&format!(
"---\ndescription: scanner\ninstall: {install}\nupdate: {update}\n---\n# scanner\n"
),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert_eq!(sb.runs("install.log"), 1);
assert_eq!(sb.runs("update.log"), 0);
let r = sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "re-learn: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("already installed"),
"a repeat learn is a no-op: {}",
r.stdout
);
assert_eq!(
sb.runs("update.log"),
0,
"a no-op learn runs no update hook: {}",
r.stdout
);
assert_eq!(
sb.runs("install.log"),
1,
"a no-op learn re-runs no install hook: {}",
r.stdout
);
}
#[test]
fn skill_frontmatter_install_hook_runs_at_learn() {
let sb = Sandbox::new("fm-src");
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\ninstall: touch installed.sentinel\n---\n# scanner\n",
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let r = sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
let store = sb.mind_home.join("store").join("skill").join("scanner");
assert!(
store.join("installed.sentinel").exists(),
"the frontmatter install hook must run in the item's store dir: {}",
r.stdout
);
}
#[test]
fn item_directory_manifest_hook_runs_and_is_item_content() {
let sb = Sandbox::new("manifest-src");
let install = sb.tally("install.log");
let update = sb.tally("update.log");
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
sb.write_and_commit(
"skills/scanner/mind.toml",
&format!("[[hooks]]\nrun = \"{install}\"\nname = \"Set up\"\n"),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let r = sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert_eq!(
sb.runs("install.log"),
1,
"the item manifest's install hook must run: {}",
r.stdout
);
let store = sb.mind_home.join("store").join("skill").join("scanner");
assert!(
store.join("mind.toml").exists(),
"the item manifest is copied into the store with the rest of the item"
);
sb.write_and_commit(
"skills/scanner/mind.toml",
&format!("[[hooks]]\nrun = \"{update}\"\nevent = \"update\"\n"),
);
assert!(sb.mind(&["sync"]).success);
let r = sb.mind(&[
"upgrade",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("upgraded skill:scanner"),
"an edited item manifest is drift: {}",
r.stdout
);
assert_eq!(
sb.runs("update.log"),
1,
"the newly declared update hook runs on the upgrade: {}",
r.stdout
);
assert_eq!(
sb.runs("install.log"),
1,
"the replaced install hook must not run again: {}",
r.stdout
);
}