use std::io::Write as _;
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,
code: Option<i32>,
}
impl Sandbox {
fn new(name: &str) -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-hk-{}-{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(),
code: out.status.code(),
}
}
fn mind_env_stdin(&self, args: &[&str], envs: &[(&str, &str)], stdin: &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)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped());
for (key, value) in envs {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn mind");
child
.stdin
.take()
.expect("piped stdin")
.write_all(stdin.as_bytes())
.expect("write stdin");
let out = child.wait_with_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(),
code: out.status.code(),
}
}
fn mind_env_stdin_bounded(
&self,
args: &[&str],
envs: &[(&str, &str)],
stdin: &str,
secs: u64,
) -> (Run, bool) {
use std::io::Read as _;
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)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped());
for (key, value) in envs {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn mind");
let mut sin = child.stdin.take().expect("piped stdin");
let body = stdin.to_string();
let writer = std::thread::spawn(move || {
let _ = sin.write_all(body.as_bytes());
});
let mut sout = child.stdout.take().expect("piped stdout");
let mut serr = child.stderr.take().expect("piped stderr");
let out_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = sout.read_to_end(&mut s);
s
});
let err_t = std::thread::spawn(move || {
let mut s = Vec::new();
let _ = serr.read_to_end(&mut s);
s
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
let mut status = None;
while std::time::Instant::now() < deadline {
match child.try_wait().expect("try_wait") {
Some(s) => {
status = Some(s);
break;
}
None => std::thread::sleep(std::time::Duration::from_millis(20)),
}
}
let timed_out = status.is_none();
if timed_out {
let _ = child.kill();
}
let _ = child.wait();
let _ = writer.join();
let stdout = String::from_utf8_lossy(&out_t.join().unwrap_or_default()).into_owned();
let stderr = String::from_utf8_lossy(&err_t.join().unwrap_or_default()).into_owned();
let run = Run {
stdout,
stderr,
success: status.is_some_and(|s| s.success()),
code: status.and_then(|s| s.code()),
};
(run, timed_out)
}
fn sentinel(&self, name: &str) -> PathBuf {
self.base.join(name)
}
fn touch(&self, name: &str) -> String {
format!("touch {}", self.sentinel(name).display())
}
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 link(&self, tail: &str) -> String {
format!("file://{}/{tail}", self.source.to_string_lossy())
}
fn link_identity(&self, path: &str) -> String {
format!(
"local/{}/{}#{path}",
self.base.file_name().unwrap().to_string_lossy(),
self.source.file_name().unwrap().to_string_lossy(),
)
}
}
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 hooks_list_source_shows_declared_hooks() {
let sb = Sandbox::new("hooks-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"build helper\"\n",
"run = \"echo build\"\n",
"event = \"install\"\n",
"\n",
"[[hooks]]\n",
"name = \"cleanup\"\n",
"run = \"echo clean\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld failed: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "list", "hooks-src"]);
assert!(r.success, "hooks list failed: {}\n{}", r.stdout, r.stderr);
let out = r.stdout;
assert!(
out.contains("build helper") || out.contains("echo build"),
"install hook label should appear: {out}"
);
assert!(
out.contains("cleanup") || out.contains("echo clean"),
"uninstall hook label should appear: {out}"
);
assert!(
out.contains("[install]"),
"install event tag should appear: {out}"
);
assert!(
out.contains("[uninstall]"),
"uninstall event tag should appear: {out}"
);
}
#[test]
fn hooks_list_json_answers_with_the_cli_220_document() {
let sb = Sandbox::new("hooks-list-json");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let plain = sb.mind(&["hooks", "list", "hooks-list-json"]);
let json = sb.mind(&["--json", "hooks", "list", "hooks-list-json"]);
assert!(
plain.success && json.success,
"{} {}",
plain.stderr,
json.stderr
);
assert!(
plain.stdout.contains("[install]") && plain.stdout.contains("setup"),
"text mode must be unchanged: {:?}",
plain.stdout
);
let doc: serde_json::Value = serde_json::from_str(json.stdout.trim()).unwrap_or_else(|e| {
panic!(
"hooks list --json stdout must parse as one JSON document ({e}): {:?}",
json.stdout
)
});
assert_eq!(doc["schema"], 1, "{doc}");
assert_eq!(doc["action"], "hooks-list", "{doc}");
assert_eq!(doc["target"], "hooks-list-json", "{doc}");
let sources = doc["sources"]
.as_array()
.unwrap_or_else(|| panic!("sources must be an array: {doc}"));
assert_eq!(sources.len(), 1, "{doc}");
assert!(
sources[0]["source"]
.as_str()
.is_some_and(|s| s.ends_with("hooks-list-json")),
"{doc}"
);
let hooks = sources[0]["hooks"]
.as_array()
.unwrap_or_else(|| panic!("sources[0].hooks must be an array: {doc}"));
assert_eq!(hooks.len(), 1, "{doc}");
assert_eq!(hooks[0]["event"], "install", "{doc}");
assert_eq!(hooks[0]["required"], true, "{doc}");
assert_eq!(hooks[0]["command"], "echo setup", "{doc}");
assert_eq!(hooks[0]["status"], "pending (never ran)", "{doc}");
assert!(doc.get("items").is_none(), "{doc}");
}
#[test]
fn hooks_list_source_no_hooks_prints_note() {
let sb = Sandbox::new("no-hooks");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld failed: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "no-hooks"]);
assert!(
r.success,
"hooks list should succeed even with no hooks: {}",
r.stderr
);
assert!(
r.stdout.contains("no source-level hooks"),
"should note absence of hooks: {}",
r.stdout
);
}
#[test]
fn hooks_list_json_source_without_hooks_still_has_an_empty_hooks_array() {
let sb = Sandbox::new("no-hooks-json");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld failed: {}", r.stderr);
let r = sb.mind(&["--json", "hooks", "list", "no-hooks-json"]);
assert!(r.success, "hooks list --json: {} {}", r.stdout, r.stderr);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", r.stdout));
let sources = doc["sources"]
.as_array()
.unwrap_or_else(|| panic!("sources must be present even with no hooks: {doc}"));
assert_eq!(sources.len(), 1, "{doc}");
assert_eq!(
sources[0]["hooks"],
serde_json::json!([]),
"an empty `hooks` array, not an absent member: {doc}"
);
assert_eq!(
sources[0]["items"],
serde_json::json!([]),
"an empty `items` array, not an absent member: {doc}"
);
assert!(
r.stderr.contains("no source-level hooks"),
"the text note must survive the redirect: {:?}",
r.stderr
);
}
#[test]
fn hooks_list_json_glob_matching_two_sources_is_one_document() {
let sb = Sandbox::new("globa");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"a-setup\"\n",
"run = \"echo a\"\n",
"event = \"install\"\n",
),
);
let other = sb.base.join("globb");
init_source_repo(
&other,
concat!(
"[[hooks]]\n",
"name = \"b-setup\"\n",
"run = \"echo b\"\n",
"event = \"install\"\n",
),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["meld", &other.to_string_lossy()]).success,
"second meld must succeed"
);
let r = sb.mind(&["--json", "hooks", "list", "glob*"]);
assert!(
r.success,
"hooks list --json glob: {} {}",
r.stdout, r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"a multi-source glob must still be ONE document ({e}): {:?}",
r.stdout
)
});
let sources = doc["sources"]
.as_array()
.unwrap_or_else(|| panic!("sources must be an array: {doc}"));
assert_eq!(sources.len(), 2, "both matched sources must appear: {doc}");
let commands: Vec<&str> = sources
.iter()
.filter_map(|s| s["hooks"][0]["command"].as_str())
.collect();
assert!(
commands.contains(&"echo a") && commands.contains(&"echo b"),
"each source's own hooks must be under its own entry: {doc}"
);
assert_eq!(doc["target"], "glob*", "{doc}");
}
#[test]
fn hooks_list_unknown_source_fails() {
let sb = Sandbox::new("src");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld failed: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "does-not-exist"]);
assert!(!r.success, "should fail for unknown source");
}
#[test]
fn hooks_list_item_shows_hooks() {
let sb = Sandbox::new("item-hooks-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"myscan\"\n",
"path = \"skills/myscan\"\n",
"install = \"echo scan-installed\"\n",
"uninstall = \"echo scan-removed\"\n",
),
);
sb.write_and_commit(
"skills/myscan/SKILL.md",
"---\ndescription: scan skill\n---\n# scan\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld failed: {}", r.stderr);
let r = sb.mind(&["learn", "myscan", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn failed: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "item-hooks-src#myscan"]);
assert!(
r.success,
"hooks list item failed: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("[install]"),
"install hook should be listed: {out}"
);
assert!(
out.contains("echo scan-installed") || out.contains("scan-installed"),
"install hook command should appear: {out}"
);
}
#[test]
fn hooks_list_json_item_target_answers_with_items_and_no_sources() {
let sb = Sandbox::new("item-json-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"myscan\"\n",
"path = \"skills/myscan\"\n",
"install = \"echo scan-installed\"\n",
"uninstall = \"echo scan-removed\"\n",
),
);
sb.write_and_commit(
"skills/myscan/SKILL.md",
"---\ndescription: scan skill\n---\n# scan\n",
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "myscan", "--dangerously-skip-install-hook-check"])
.success
);
let r = sb.mind(&["--json", "hooks", "list", "item-json-src#myscan"]);
assert!(r.success, "hooks list --json: {} {}", r.stdout, r.stderr);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", r.stdout));
assert_eq!(doc["action"], "hooks-list", "{doc}");
assert_eq!(doc["target"], "item-json-src#myscan", "{doc}");
assert!(
doc.get("sources").is_none(),
"an item-ref target must omit `sources`, not send an empty array: {doc}"
);
let items = doc["items"]
.as_array()
.unwrap_or_else(|| panic!("items must be an array: {doc}"));
assert_eq!(items.len(), 1, "{doc}");
assert_eq!(items[0]["item"], "skill:myscan", "{doc}");
assert!(
items[0]["source"]
.as_str()
.is_some_and(|s| s.ends_with("item-json-src")),
"a top-level item entry names its own source: {doc}"
);
let hooks = items[0]["hooks"]
.as_array()
.unwrap_or_else(|| panic!("hooks must be an array: {doc}"));
assert_eq!(hooks.len(), 2, "install + uninstall: {doc}");
let install = hooks
.iter()
.find(|h| h["event"] == "install")
.unwrap_or_else(|| panic!("an install entry: {doc}"));
assert_eq!(install["command"], "echo scan-installed", "{doc}");
assert_eq!(install["required"], true, "{doc}");
assert!(
install.get("status").is_none(),
"only a recorded SOURCE install hook carries `status`: {doc}"
);
}
#[test]
fn hooks_list_json_source_target_nests_item_hooks_without_a_source_member() {
let sb = Sandbox::new("nest-json-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"myscan\"\n",
"path = \"skills/myscan\"\n",
"install = \"echo scan-installed\"\n",
),
);
sb.write_and_commit(
"skills/myscan/SKILL.md",
"---\ndescription: scan skill\n---\n# scan\n",
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(
sb.mind(&["learn", "myscan", "--dangerously-skip-install-hook-check"])
.success
);
let r = sb.mind(&["--json", "hooks", "list", "nest-json-src"]);
assert!(r.success, "hooks list --json: {} {}", r.stdout, r.stderr);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", r.stdout));
let nested = &doc["sources"][0]["items"][0];
assert_eq!(nested["item"], "skill:myscan", "{doc}");
assert!(
nested.get("source").is_none(),
"a nested item's source is the enclosing object, so it is omitted: {doc}"
);
assert_eq!(
nested["hooks"][0]["command"], "echo scan-installed",
"{doc}"
);
assert!(
doc.get("items").is_none(),
"the top-level `items` belongs to the item-ref branch only: {doc}"
);
}
#[test]
fn hooks_list_item_not_installed_fails() {
let sb = Sandbox::new("item-miss");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "item-miss#ghost"]);
assert!(!r.success, "should fail when item not installed");
}
#[test]
fn hooks_run_build_event_on_source_target_errors() {
let sb = Sandbox::new("bld-src");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "build", "bld-src"]);
assert!(
!r.success,
"should fail with build-event-requires-item-target"
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("build") && combined.contains("item"),
"error should mention build and item: {combined}"
);
}
#[test]
fn hooks_run_source_install_skips_in_non_tty() {
let sb = Sandbox::new("src-skip");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let identity = format!(
"local/{}/src-skip",
sb.base.file_name().unwrap().to_string_lossy()
);
let r = sb.mind(&["hooks", "run", "--event", "install", "src-skip"]);
assert!(
!r.success,
"a non-TTY skip with a hook that existed must be a non-zero exit: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("skipped") || out.contains("skip"),
"should mention skip in non-TTY: {out}"
);
assert!(
out.contains("not a terminal"),
"should name the cause: {out}"
);
assert!(
out.contains(&format!(
"mind hooks run '{identity}' --event install --dangerously-skip-install-hook-check"
)),
"should print the exact copy-pasteable remedy, re-selecting the event \
the run selected, with the identity shell-quoted (HOOK-106): {out}"
);
assert!(
r.stderr.contains("skipped for want of consent"),
"should surface the HooksNotRun error: {}",
r.stderr
);
}
#[test]
fn hooks_run_source_install_runs_with_dangerously_skip() {
let sb = Sandbox::new("src-run");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"write sentinel\"\n",
"run = \"touch ran.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"src-run",
]);
assert!(
r.success,
"hooks run with skip flag should succeed: {}\n{}",
r.stdout, r.stderr
);
let sentinel = sb.source.join("ran.sentinel");
assert!(
sentinel.exists(),
"hook should have created ran.sentinel in the clone dir ({})",
sb.source.display()
);
}
#[test]
fn hooks_run_running_hook_note_is_one_document() {
let sb = Sandbox::new("running-leak");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"first\"\n",
"run = \"true\"\n",
"event = \"install\"\n",
"[[hooks]]\n",
"name = \"second\"\n",
"run = \"false\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"running-leak",
]);
assert!(
!r.success,
"the second hook's failure must still fail the run: {}\n{}",
r.stdout, r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"stdout under --json must parse as a single JSON document ({e}): {:?}",
r.stdout
)
});
assert!(
doc["error"]["kind"].is_string(),
"the one document is the CLI-181 error envelope: {doc:#}"
);
assert!(
r.stderr.contains("running install hook 'first'"),
"the announcement must still be emitted, on stderr: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_build_rebuilding_note_is_one_document() {
let sb = Sandbox::new("rebuild-leak");
sb.write_and_commit("skills/widget/SKILL.md", "---\ndescription: d\n---\n# w\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"learn",
"widget",
"--dangerously-skip-install-hook-check",
"--dangerously-skip-build-hook-check",
]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
write(
&sb.source.join("skills/widget/SKILL.md"),
"---\ndescription: d\n---\n# w\nSee {{ns:does-not-exist}}.\n",
);
git(&sb.source, &["add", "-A"]);
git(&sb.source, &["commit", "-qm", "break reference"]);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"build",
"--dangerously-skip-build-hook-check",
"rebuild-leak#widget",
]);
assert!(
!r.success,
"the rebuild must fail once the item is gone from the source: {}\n{}",
r.stdout, r.stderr
);
serde_json::from_str::<serde_json::Value>(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"stdout under --json must parse as a single JSON document ({e}): {:?}",
r.stdout
)
});
assert!(
r.stderr.contains("rebuilding"),
"the announcement must still be emitted, on stderr: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_source_install_already_ran_not_rerun() {
let sb = Sandbox::new("src-repeat");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"counter\"\n",
"run = \"echo RAN >> counter.log\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"src-repeat",
]);
assert!(r.success, "first run: {}\n{}", r.stdout, r.stderr);
let log1 = read_log_file(&sb.source.join("counter.log"));
assert_eq!(log1.len(), 1, "hook should have run exactly once: {log1:?}");
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"src-repeat",
]);
assert!(r.success, "second run: {}\n{}", r.stdout, r.stderr);
let log2 = read_log_file(&sb.source.join("counter.log"));
assert_eq!(
log2.len(),
1,
"hook should not have run again (already-ran): {log2:?}"
);
}
#[test]
fn hooks_run_source_install_force_reruns_hook() {
let sb = Sandbox::new("src-force");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"counter\"\n",
"run = \"echo RAN >> counter.log\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"src-force",
]);
assert!(r.success, "first run: {}", r.stderr);
let log1 = read_log_file(&sb.source.join("counter.log"));
assert_eq!(log1.len(), 1, "first run count");
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--force",
"--dangerously-skip-install-hook-check",
"src-force",
]);
assert!(r.success, "force re-run: {}\n{}", r.stdout, r.stderr);
let log2 = read_log_file(&sb.source.join("counter.log"));
assert_eq!(log2.len(), 2, "--force should cause a second run: {log2:?}");
}
#[test]
fn hooks_run_source_uninstall_skips_in_non_tty() {
let sb = Sandbox::new("src-unskip");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"teardown\"\n",
"run = \"echo teardown-ran\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "uninstall", "src-unskip"]);
assert!(
!r.success,
"uninstall non-TTY skip with a hook that existed must be a non-zero exit: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("skipped for want of consent"),
"should surface the HooksNotRun error: {}",
r.stderr
);
}
#[test]
fn hooks_run_source_no_hooks_prints_note() {
let sb = Sandbox::new("no-hook-src");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "no-hook-src"]);
assert!(
r.success,
"should succeed with no hooks: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("no") && r.stdout.contains("hook"),
"should note absence of hooks: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_install_hook_runs() {
let sb = Sandbox::new("item-install-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"touch hook-ran.sentinel\"\n",
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-install-src#scanner",
]);
assert!(
r.success,
"hooks run item install: {}\n{}",
r.stdout, r.stderr
);
let store_sentinel = sb
.mind_home
.join("store")
.join("skill")
.join("scanner")
.join("hook-ran.sentinel");
assert!(
store_sentinel.exists(),
"item install hook should have created hook-ran.sentinel in the store at {}",
store_sentinel.display()
);
}
#[test]
fn hooks_run_item_uninstall_hook_skips_in_non_tty() {
let sb = Sandbox::new("item-uninstall-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"fetcher\"\n",
"path = \"skills/fetcher\"\n",
"uninstall = \"echo fetcher-removed\"\n",
),
);
sb.write_and_commit(
"skills/fetcher/SKILL.md",
"---\ndescription: fetcher\n---\n# fetcher\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "fetcher"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"uninstall",
"item-uninstall-src#fetcher",
]);
assert!(
r.stdout.contains("skipped uninstall hook"),
"the hook must be skipped, not run: {}",
r.stdout
);
assert!(
!r.success,
"an item uninstall hook skipped for want of a terminal must be \
reported, not a silent exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains(
"mind hooks run 'item-uninstall-src#fetcher' --event uninstall \
--dangerously-skip-install-hook-check"
),
"the remedy must re-select the uninstall event: {}",
r.stderr
);
}
#[test]
fn hooks_run_item_build_reinstalls() {
let sb = Sandbox::new("item-bld");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"builder\"\n",
"path = \"skills/builder\"\n",
),
);
sb.write_and_commit(
"skills/builder/SKILL.md",
"---\ndescription: builder\n---\n# builder\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "builder"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"build",
"--dangerously-skip-build-hook-check",
"item-bld#builder",
]);
assert!(
r.success,
"hooks run build should reinstall: {}\n{}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("rebuild")
|| combined.contains("reinstall")
|| combined.contains("builder"),
"output should mention rebuild: {combined}"
);
}
#[test]
fn hooks_run_item_build_with_force_is_a_benign_no_op() {
let sb = Sandbox::new("item-bld-force");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"builder\"\n",
"path = \"skills/builder\"\n",
),
);
sb.write_and_commit(
"skills/builder/SKILL.md",
"---\ndescription: builder\n---\n# builder\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "builder"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"build",
"--force",
"--dangerously-skip-build-hook-check",
"item-bld-force#builder",
]);
assert!(
r.success,
"--force on --event build must stay a benign no-op that still \
reinstalls: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains("want of consent") && !r.stdout.contains("want of consent"),
"--force on a build event must not fabricate a HooksNotRun: {}\n{}",
r.stdout,
r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("rebuild")
|| combined.contains("reinstall")
|| combined.contains("builder"),
"the build must still have happened under --force: {combined}"
);
}
#[test]
fn hooks_run_source_glob_runs_each_matched_source() {
let sb = Sandbox::new("multi-a");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"a-setup\"\n",
"run = \"touch a-ran.sentinel\"\n",
"event = \"install\"\n",
),
);
let src_b = sb.base.join("multi-b");
init_source_repo(
&src_b,
concat!(
"[[hooks]]\n",
"name = \"b-setup\"\n",
"run = \"touch b-ran.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld a: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", src_b.to_string_lossy().as_ref()]);
assert!(r.success, "meld b: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"*",
]);
assert!(r.success, "hooks run '*': {}\n{}", r.stdout, r.stderr);
assert!(
sb.source.join("a-ran.sentinel").exists(),
"source a's install hook must run under a fan-out selector"
);
assert!(
src_b.join("b-ran.sentinel").exists(),
"source b's install hook must run under a fan-out selector"
);
}
#[test]
fn hooks_run_item_glob_runs_each_matched_item() {
let sb = Sandbox::new("multi-item-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"install = \"touch alpha-ran.sentinel\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
"install = \"touch beta-ran.sentinel\"\n",
),
);
sb.write_and_commit("skills/alpha/SKILL.md", "---\ndescription: a\n---\n# a\n");
sb.write_and_commit("skills/beta/SKILL.md", "---\ndescription: b\n---\n# b\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "beta", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn beta: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"multi-item-src#*",
]);
assert!(r.success, "hooks run item glob: {}\n{}", r.stdout, r.stderr);
let alpha = sb.mind_home.join("store/skill/alpha/alpha-ran.sentinel");
let beta = sb.mind_home.join("store/skill/beta/beta-ran.sentinel");
assert!(
alpha.exists(),
"alpha's install hook must run under an item glob: {}",
alpha.display()
);
assert!(
beta.exists(),
"beta's install hook must run under an item glob: {}",
beta.display()
);
}
#[test]
fn hooks_run_source_install_skip_stays_pending() {
let sb = Sandbox::new("skip-pending");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch ran.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "skip-pending"]);
assert!(
!r.success,
"a non-TTY skip with a hook that existed must be a non-zero exit: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.source.join("ran.sentinel").exists(),
"a skipped hook must not have executed"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"skip-pending",
]);
assert!(r.success, "bypassed run: {}\n{}", r.stdout, r.stderr);
assert!(
sb.source.join("ran.sentinel").exists(),
"a skipped install hook stays pending and runs on a later bypassed run"
);
}
#[test]
fn hooks_run_source_install_already_ran_non_tty_stays_exit_zero() {
let sb = Sandbox::new("already-ran-quiet");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"already-ran-quiet",
]);
assert!(
r.success,
"first (bypassed) run: {}\n{}",
r.stdout, r.stderr
);
let r = sb.mind(&["hooks", "run", "--event", "install", "already-ran-quiet"]);
assert!(
r.success,
"a run with nothing to do must stay exit 0 even in non-TTY: {}\n{}",
r.stdout, r.stderr
);
}
#[test]
fn hooks_list_source_shows_pending_then_recorded_commit() {
let sb = Sandbox::new("list-status");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo hi\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "list-status"]);
assert!(r.success, "list before: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("pending"),
"an unrun install hook must list as pending: {}",
r.stdout
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"list-status",
]);
assert!(r.success, "run: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "list", "list-status"]);
assert!(r.success, "list after: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("ran at"),
"a recorded install hook must show its last-ran commit: {}",
r.stdout
);
assert!(
!r.stdout.contains("pending"),
"a hook recorded at the current commit must not show as pending: {}",
r.stdout
);
}
#[test]
fn hooks_run_build_failure_leaves_live_copy_untouched() {
let sb = Sandbox::new("bld-fail-src");
let trigger = sb.base.join("fail-trigger");
let trigger_str = trigger.to_string_lossy().into_owned();
let mind_toml = format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"bt\"\n",
"path = \"tools/bt\"\n",
"build = \"test ! -f {trigger} && touch built.sentinel\"\n",
),
trigger = trigger_str,
);
sb.write_and_commit("mind.toml", &mind_toml);
sb.write_and_commit("tools/bt/TOOL.md", "---\ndescription: bt\n---\n# bt\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "tool:bt", "--dangerously-skip-build-hook-check"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
let store = sb.mind_home.join("store/tool/bt");
let sentinel = store.join("built.sentinel");
let tool_md = store.join("TOOL.md");
assert!(
sentinel.exists(),
"the first build must create the sentinel: {}",
sentinel.display()
);
assert!(tool_md.exists(), "the store copy must exist after install");
write(&trigger, "x");
let r = sb.mind(&[
"hooks",
"run",
"--event",
"build",
"--dangerously-skip-build-hook-check",
"bld-fail-src#bt",
]);
assert!(
!r.success,
"a rebuild must fail when the build hook exits non-zero: {}\n{}",
r.stdout, r.stderr
);
assert!(
tool_md.exists(),
"the store TOOL.md must survive a failed rebuild: {}",
tool_md.display()
);
assert!(
sentinel.exists(),
"the prior build output must survive a failed rebuild: {}",
sentinel.display()
);
}
#[test]
fn hooks_list_link_instance_identity_reaches_source_hooks() {
let sb = Sandbox::new("link-repo");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"link setup\"\n",
"run = \"echo link-setup-ran\"\n",
"event = \"install\"\n",
),
);
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/review")]);
assert!(r.success, "learn <url> failed: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/review");
let r = sb.mind(&["hooks", "list", &identity]);
assert!(
r.success,
"hooks list <link-identity> should reach the source target: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("source:"),
"should be read as a source target, not an item ref: {out}"
);
assert!(
out.contains("link setup") || out.contains("echo link-setup-ran"),
"the instance's declared source hook must be listed: {out}"
);
}
#[test]
fn hooks_list_plain_source_hash_item_still_resolves_as_item() {
let sb = Sandbox::new("plain-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"widget\"\n",
"path = \"skills/widget\"\n",
"install = \"echo widget-installed\"\n",
),
);
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: widget\n---\n# widget\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "widget", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "plain-src#widget"]);
assert!(
r.success,
"hooks list <source>#<item> must still resolve as an item ref: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("item:"),
"should be read as an item target, not a source: {out}"
);
assert!(
out.contains("echo widget-installed") || out.contains("widget-installed"),
"the item's install hook should be listed: {out}"
);
}
#[test]
fn hooks_list_item_inside_link_instance_still_resolves_as_item() {
let sb = Sandbox::new("link-item-repo");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/review")]);
assert!(r.success, "learn <url> failed: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/review");
let target = format!("{identity}#review");
let r = sb.mind(&["hooks", "list", &target]);
assert!(
r.success,
"hooks list <link-identity>#<item> should resolve as an item ref: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("item:"),
"should be read as an item target, not a source: {out}"
);
}
#[test]
fn hooks_run_link_instance_identity_reaches_and_runs_source_hooks() {
let sb = Sandbox::new("link-run-repo");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"link setup\"\n",
"run = \"touch link-setup.sentinel\"\n",
"event = \"install\"\n",
),
);
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/review")]);
assert!(r.success, "learn <url> failed: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/review");
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
&identity,
]);
assert!(
r.success,
"hooks run <link-identity> should run the source's install hook: {}\n{}",
r.stdout, r.stderr
);
let sentinel = find_sentinel(&sb.mind_home, "link-setup.sentinel");
assert!(
sentinel.is_some(),
"the instance's install hook should have run in its clone dir: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn hooks_run_item_inside_link_instance_resolves_as_item_not_source() {
let sb = Sandbox::new("link-item-run-repo");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/review")]);
assert!(r.success, "learn <url> failed: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/review");
let target = format!("{identity}#review");
let r = sb.mind(&["hooks", "run", "--event", "install", &target]);
assert!(
r.success,
"hooks run <link-identity>#<item> should resolve and run as an item ref: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("no install hooks declared for skill:review"),
"should resolve to the installed item skill:review, not the source: {out}"
);
assert!(
!out.contains("no install hooks declared for source"),
"must not be misread as a source target: {out}"
);
}
#[test]
fn hooks_list_alias_suffixed_source_identity_resolves_as_source() {
let sb = Sandbox::new("alias-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"alias setup\"\n",
"run = \"echo alias-setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", "--namespace", "jk", &sb.source_spec()]);
assert!(r.success, "meld --namespace: {}\n{}", r.stdout, r.stderr);
let identity = format!(
"local/{}/{}@jk",
sb.base.file_name().unwrap().to_string_lossy(),
sb.source.file_name().unwrap().to_string_lossy(),
);
let r = sb.mind(&["hooks", "list", &identity]);
assert!(
r.success,
"hooks list <alias-identity> should resolve as a source: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("source:"),
"should be read as a source target: {out}"
);
assert!(
out.contains("alias setup") || out.contains("echo alias-setup-ran"),
"the aliased source's declared hook must be listed: {out}"
);
}
#[test]
fn hooks_list_unknown_hash_target_errors_cleanly_as_item_not_found() {
let sb = Sandbox::new("unknown-hash-src");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "totally/unknown-repo#ghost-item"]);
assert!(
!r.success,
"an unresolvable #-target must fail, not succeed: {}\n{}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("not installed") || combined.contains("no item matches"),
"should be the ordinary item-ref error, not a source-not-found misreport: {combined}"
);
assert!(!combined.contains("panic"), "must not panic: {combined}");
}
#[test]
fn hooks_list_link_instance_identity_with_surrounding_whitespace_still_matches() {
let sb = Sandbox::new("ws-link-repo");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"link setup\"\n",
"run = \"echo link-setup-ran\"\n",
"event = \"install\"\n",
),
);
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review skill\n---\n# review\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/review")]);
assert!(r.success, "learn <url> failed: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/review");
let padded = format!(" {identity}\t\n");
let r = sb.mind(&["hooks", "list", &padded]);
assert!(
r.success,
"hooks list <padded link-identity> should still resolve as a source: {}\n{}",
r.stdout, r.stderr
);
let out = r.stdout;
assert!(
out.contains("source:"),
"whitespace-padded exact identity should still be read as a source target: {out}"
);
assert!(
out.contains("link setup") || out.contains("echo link-setup-ran"),
"the instance's declared source hook must be listed: {out}"
);
}
#[test]
fn hooks_target_exactly_matching_source_and_item_errors_ambiguous() {
let sb = Sandbox::new("amb-src");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"dev\"\n",
"path = \"dev\"\n",
),
);
sb.write_and_commit("dev/SKILL.md", "---\ndescription: dev skill\n---\n# dev\n");
let r = sb.mind(&["meld", &sb.link("tree/main/dev"), "--register-only"]);
assert!(r.success, "link meld: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"learn",
"amb-src#dev",
"--dangerously-skip-install-hook-check",
]);
assert!(r.success, "learn: {}", r.stderr);
let ambiguous = sb.link_identity("dev");
let r = sb.mind(&["hooks", "list", &ambiguous]);
assert!(
!r.success,
"an exact source/item collision must error, not silently resolve: {}\n{}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("ambiguous"),
"must say ambiguous: {combined}"
);
assert!(
combined.contains(&ambiguous),
"must name the ambiguous target: {combined}"
);
assert!(
combined.contains(&format!("source:{ambiguous}")),
"must name the source-targeting escape: {combined}"
);
assert!(
combined.contains("skill:dev"),
"must name the kind-qualified item escape: {combined}"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
&ambiguous,
]);
assert!(
!r.success,
"hooks run must also refuse the ambiguous target: {}\n{}",
r.stdout, r.stderr
);
}
#[test]
fn hooks_target_source_only_match_resolves_as_source() {
let sb = Sandbox::new("src-only");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"root setup\"\n",
"run = \"echo root-setup-ran\"\n",
"event = \"install\"\n",
),
);
sb.write_and_commit("dev/SKILL.md", "---\ndescription: dev skill\n---\n# dev\n");
let r = sb.mind(&["learn", &sb.link("tree/main/dev")]);
assert!(r.success, "learn <url>: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("dev");
let r = sb.mind(&["hooks", "list", &identity]);
assert!(
r.success,
"an unambiguous exact source match must still resolve as a source: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("source:"),
"must be read as a source target: {}",
r.stdout
);
}
#[test]
fn hooks_target_item_only_match_resolves_as_item() {
let sb = Sandbox::new("item-only");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"widget\"\n",
"path = \"skills/widget\"\n",
"install = \"echo widget-installed\"\n",
),
);
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: widget\n---\n# widget\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "widget", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "list", "item-only#widget"]);
assert!(
r.success,
"must resolve as an item ref: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("item:"),
"must be read as an item target: {}",
r.stdout
);
}
#[test]
fn hooks_target_kind_qualified_escape_forces_item_resolution() {
let sb = Sandbox::new("amb-kind-esc");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"dev\"\n",
"path = \"dev\"\n",
"install = \"echo dev-installed\"\n",
),
);
sb.write_and_commit("dev/SKILL.md", "---\ndescription: dev skill\n---\n# dev\n");
let r = sb.mind(&["meld", &sb.link("tree/main/dev"), "--register-only"]);
assert!(r.success, "link meld: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"learn",
"amb-kind-esc#dev",
"--dangerously-skip-install-hook-check",
]);
assert!(r.success, "learn: {}", r.stderr);
let source_part = format!(
"local/{}/{}",
sb.base.file_name().unwrap().to_string_lossy(),
sb.source.file_name().unwrap().to_string_lossy(),
);
let escaped = format!("{source_part}#skill:dev");
let r = sb.mind(&["hooks", "list", &escaped]);
assert!(
r.success,
"the kind-qualified escape must resolve unambiguously: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("item:"),
"must be read as an item target: {}",
r.stdout
);
assert!(
r.stdout.contains("echo dev-installed") || r.stdout.contains("dev-installed"),
"must list the installed item's own hook: {}",
r.stdout
);
}
#[test]
fn hooks_target_source_prefix_escape_forces_source_resolution() {
let sb = Sandbox::new("amb-src-esc");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"dev\"\n",
"path = \"dev\"\n",
),
);
sb.write_and_commit("dev/SKILL.md", "---\ndescription: dev skill\n---\n# dev\n");
let r = sb.mind(&["meld", &sb.link("tree/main/dev"), "--register-only"]);
assert!(r.success, "link meld: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"learn",
"amb-src-esc#dev",
"--dangerously-skip-install-hook-check",
]);
assert!(r.success, "learn: {}", r.stderr);
let identity = sb.link_identity("dev");
let escaped = format!("source:{identity}");
let r = sb.mind(&["hooks", "list", &escaped]);
assert!(
r.success,
"the source: escape must resolve unambiguously: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("source:"),
"must be read as a source target: {}",
r.stdout
);
}
#[test]
fn hooks_run_hooks_not_run_emits_json_error_envelope() {
let sb = Sandbox::new("json-not-run");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"install",
"json-not-run",
]);
assert!(
!r.success,
"the HOOK-107 path must fail under --json too: {}\n{}",
r.stdout, r.stderr
);
assert_eq!(
r.code,
Some(1),
"a MindError must exit 1 (CLI-175), not another non-zero code: {}\n{}",
r.stdout,
r.stderr
);
let envelope = json_envelope(&r.stdout);
assert_eq!(
envelope["schema"], 1,
"envelope must carry schema 1: {envelope}"
);
assert_eq!(
envelope["error"]["kind"], "hooks-not-run",
"the stable per-variant kind slug must be emitted: {envelope}"
);
let message = envelope["error"]["message"]
.as_str()
.unwrap_or_else(|| panic!("message must be a string: {envelope}"));
assert!(
message.contains("skipped for want of consent"),
"the envelope message must be the full Display text: {message}"
);
assert!(
message.contains("--dangerously-skip-install-hook-check"),
"the envelope message must carry the remedy: {message}"
);
assert!(
message.contains("json-not-run"),
"the envelope message must name the target: {message}"
);
}
#[test]
fn hooks_run_hooks_not_run_json_writes_only_the_routed_note_to_stderr() {
let sb = Sandbox::new("json-stderr");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"install",
"json-stderr",
]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
!r.stderr.contains("error:"),
"CLI-181: the main error handler must not write its own error text to \
stderr under --json: {:?}",
r.stderr
);
assert!(
r.stderr.contains("note: skipped install hook 'setup'"),
"CLI-217: the HOOK-106 note is routed to stderr, not dropped, under \
--json: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_json_stdout_is_only_the_envelope() {
let sb = Sandbox::new("json-pure");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["--json", "hooks", "run", "--event", "install", "json-pure"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
serde_json::from_str::<serde_json::Value>(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"stdout under --json must parse as a single JSON document ({e}): {:?}",
r.stdout
)
});
}
#[test]
fn hooks_run_force_on_already_ran_hook_in_non_tty_is_hooks_not_run() {
let sb = Sandbox::new("force-not-run");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup-ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"force-not-run",
]);
assert!(r.success, "bypassed run: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--force",
"force-not-run",
]);
assert!(
!r.success,
"--force reconsiders an already-ran hook, so a non-TTY run has work it \
cannot consent to and must be a HOOK-107 error: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"must report exactly the one reconsidered hook: {}",
r.stderr
);
}
#[test]
fn hooks_run_hooks_not_run_counts_every_skipped_hook() {
let sb = Sandbox::new("count-two");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"first\"\n",
"run = \"echo one\"\n",
"event = \"install\"\n",
"\n",
"[[hooks]]\n",
"name = \"second\"\n",
"run = \"echo two\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "count-two"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
r.stderr.contains("2 hook(s) had work to do"),
"the skipped count must be the real number of skipped hooks: {}",
r.stderr
);
let notes = r
.stdout
.lines()
.filter(|l| l.contains("not a terminal"))
.count();
assert_eq!(
notes, 2,
"each skipped hook must get its own HOOK-106 note: {}",
r.stdout
);
assert!(
r.stdout.contains("'first'") && r.stdout.contains("'second'"),
"each note must name its own hook label: {}",
r.stdout
);
}
#[test]
fn hooks_run_install_event_ignores_an_uninstall_only_source_and_exits_zero() {
let sb = Sandbox::new("uninstall-only");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"teardown\"\n",
"run = \"echo teardown\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "uninstall-only"]);
assert!(
r.success,
"an uninstall-only source has no install work, so --event install stays \
exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("no install hooks declared"),
"should note the absence for the selected event: {}",
r.stdout
);
}
#[test]
fn hooks_run_glob_counts_only_the_source_with_pending_work() {
let sb = Sandbox::new("glob-mixed-a");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"a-setup\"\n",
"run = \"echo a\"\n",
"event = \"install\"\n",
),
);
let src_b = sb.base.join("glob-mixed-b");
init_source_repo(
&src_b,
concat!(
"[[hooks]]\n",
"name = \"b-setup\"\n",
"run = \"echo b\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld a: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", src_b.to_string_lossy().as_ref()]);
assert!(r.success, "meld b: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"glob-mixed-a",
]);
assert!(r.success, "retire a: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "*"]);
assert!(
!r.success,
"a fan-out with one pending source must be a HOOK-107 error: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"only source b's hook had work; source a must not be counted: {}",
r.stderr
);
assert!(
r.stdout.contains("'b-setup'"),
"source b's pending hook must be noted: {}",
r.stdout
);
assert!(
!r.stdout.contains("'a-setup'"),
"source a's retired hook must never be offered, so it cannot be noted \
as skipped: {}",
r.stdout
);
}
#[test]
fn hooks_run_glob_with_nothing_to_do_anywhere_stays_exit_zero() {
let sb = Sandbox::new("glob-quiet-a");
let src_b = sb.base.join("glob-quiet-b");
init_source_repo(
&src_b,
concat!(
"[[hooks]]\n",
"name = \"b-setup\"\n",
"run = \"echo b\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld a: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", src_b.to_string_lossy().as_ref()]);
assert!(r.success, "meld b: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"glob-quiet-b",
]);
assert!(r.success, "retire b: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "*"]);
assert!(
r.success,
"no source had work, so the fan-out must stay exit 0: {}\n{}",
r.stdout, r.stderr
);
}
#[test]
fn hooks_run_with_bypass_never_reports_hooks_not_run() {
let sb = Sandbox::new("bypass-clean");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"bypass-clean",
]);
assert!(r.success, "bypassed run: {}\n{}", r.stdout, r.stderr);
assert!(
!r.stdout.contains("not a terminal") && !r.stderr.contains("want of consent"),
"a bypassed run consents to every hook, so neither the HOOK-106 note \
nor the HOOK-107 error may appear: {}\n{}",
r.stdout,
r.stderr
);
}
#[test]
fn hooks_run_item_target_pending_install_hook_is_hooks_not_run_in_non_tty() {
let sb = Sandbox::new("item-silent");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"touch item-hook-ran.sentinel\"\n",
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let sentinel = sb
.mind_home
.join("store/skill/scanner/item-hook-ran.sentinel");
let _ = std::fs::remove_file(&sentinel);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-silent#scanner"]);
assert!(
!r.success,
"an item target with a hook it could not consent to must be a non-zero \
exit: {}\n{}",
r.stdout, r.stderr
);
assert_eq!(
r.code,
Some(1),
"a MindError must exit 1 (CLI-175): {}\n{}",
r.stdout,
r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"the error must count the item's skipped hook: {}",
r.stderr
);
assert!(
r.stderr.contains(
"mind hooks run 'item-silent#scanner' --event install \
--dangerously-skip-install-hook-check"
),
"the error must carry the copy-pasteable remedy naming the target as \
typed: {}",
r.stderr
);
assert!(
!sentinel.exists(),
"the item hook must not have run in a non-TTY run: {}",
sentinel.display()
);
}
#[test]
fn hooks_run_item_single_match_glob_remedy_names_the_resolved_identity_not_the_glob() {
let sb = Sandbox::new("item-glob-one");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"touch item-hook-ran.sentinel\"\n",
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let sentinel = sb
.mind_home
.join("store/skill/scanner/item-hook-ran.sentinel");
let _ = std::fs::remove_file(&sentinel);
let source_identity = format!(
"local/{}/item-glob-one",
sb.base.file_name().unwrap().to_string_lossy()
);
let glob_target = format!("{source_identity}#sc*");
let r = sb.mind(&["hooks", "run", "--event", "install", &glob_target]);
assert!(
!r.success,
"the one matched item's hook was skipped for want of consent: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains(&glob_target),
"the remedy must never echo the raw glob selector back into a \
command-shaped string: {}",
r.stderr
);
let concrete_identity = format!("{source_identity}#skill:scanner");
assert!(
r.stderr.contains(&format!(
"mind hooks run '{concrete_identity}' --event install \
--dangerously-skip-install-hook-check"
)),
"the remedy must name the resolved concrete identity instead: {}",
r.stderr
);
let redo = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
&concrete_identity,
]);
assert!(
redo.success,
"the resolved identity from the remedy must actually run: {}\n{}",
redo.stdout, redo.stderr
);
assert!(
sentinel.exists(),
"the remedy's resolved identity must have run the item's install hook"
);
}
#[test]
fn hooks_run_skip_note_remedy_names_the_resolved_source_identity() {
let sb = Sandbox::new("remedy-identity");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let identity = format!(
"local/{}/remedy-identity",
sb.base.file_name().unwrap().to_string_lossy()
);
let r = sb.mind(&["hooks", "run", "--event", "install", "*"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains(&format!(
"mind hooks run '{identity}' --event install --dangerously-skip-install-hook-check"
)),
"the note's remedy must name the resolved identity (shell-quoted), not \
the '*' selector: {}",
r.stdout
);
}
#[test]
fn hooks_run_uninstall_skip_remedy_carries_the_event() {
let sb = Sandbox::new("remedy-event");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"teardown\"\n",
"run = \"echo teardown\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "uninstall", "remedy-event"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("--event uninstall"),
"the remedy for an uninstall skip must re-select the uninstall event, \
otherwise it silently runs the install hooks instead: {combined}"
);
}
#[test]
fn hooks_not_run_error_still_persists_the_recorded_skip() {
let sb = Sandbox::new("persist-skip");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "persist-skip"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "list", "persist-skip"]);
assert!(r.success, "hooks list after the error: {}", r.stderr);
assert!(
r.stdout.contains("pending"),
"the skipped hook must remain pending after the HOOK-107 error: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_glob_counts_every_skipped_item_hook() {
let sb = Sandbox::new("item-count");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"install = \"touch alpha-ran.sentinel\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
"install = \"touch beta-ran.sentinel\"\n",
),
);
sb.write_and_commit("skills/alpha/SKILL.md", "---\ndescription: a\n---\n# a\n");
sb.write_and_commit("skills/beta/SKILL.md", "---\ndescription: b\n---\n# b\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "beta"]);
assert!(r.success, "learn beta: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-count#*"]);
assert!(
!r.success,
"both items had a hook and neither could be consented to: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("2 hook(s) had work to do"),
"the count must accumulate across every matched item: {}",
r.stderr
);
}
#[test]
fn hooks_run_item_glob_matching_several_items_lists_names_not_one_command() {
let sb = Sandbox::new("item-multi");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"install = \"touch alpha-ran.sentinel\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
"install = \"touch beta-ran.sentinel\"\n",
),
);
sb.write_and_commit("skills/alpha/SKILL.md", "---\ndescription: a\n---\n# a\n");
sb.write_and_commit("skills/beta/SKILL.md", "---\ndescription: b\n---\n# b\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "beta"]);
assert!(r.success, "learn beta: {}", r.stderr);
let source_identity = format!(
"local/{}/item-multi",
sb.base.file_name().unwrap().to_string_lossy()
);
let identity_alpha = format!("{source_identity}#skill:alpha");
let identity_beta = format!("{source_identity}#skill:beta");
let r = sb.mind(&["hooks", "run", "--event", "install", "item-multi#*"]);
assert!(
!r.success,
"both items had a pending hook: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains(&identity_alpha) && r.stderr.contains(&identity_beta),
"the remedy must name every resolved item identity: {}",
r.stderr
);
assert!(
!r.stderr.contains("mind hooks run item-multi#* --event"),
"the remedy must never echo the raw '#*' glob back into a \
command-shaped string: {}",
r.stderr
);
assert!(
!r.stderr.contains(&format!(
"mind hooks run {identity_alpha} --event install \
--dangerously-skip-install-hook-check"
)) && !r.stderr.contains(&format!(
"mind hooks run {identity_beta} --event install \
--dangerously-skip-install-hook-check"
)),
"with two items contributing, neither alone is the whole remedy, so the \
error must not synthesize a single command naming just one: {}",
r.stderr
);
let redo = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
&identity_alpha,
]);
assert!(
redo.success,
"a single resolved item identity must be runnable as printed: {}\n{}",
redo.stdout, redo.stderr
);
}
#[test]
fn hooks_run_item_with_no_hooks_for_the_event_stays_exit_zero() {
let sb = Sandbox::new("item-nowork");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"plain\"\n",
"path = \"skills/plain\"\n",
"install = \"echo installed\"\n",
),
);
sb.write_and_commit(
"skills/plain/SKILL.md",
"---\ndescription: plain\n---\n# plain\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "plain", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "uninstall", "item-nowork#plain"]);
assert!(
r.success,
"no hook for the selected event is nothing to do, so exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("no uninstall hooks declared"),
"should note the absence for the selected event: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_build_skipped_in_non_tty_stays_exit_zero() {
let sb = Sandbox::new("item-build-nowork");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"builder\"\n",
"path = \"tools/builder\"\n",
"build = \"touch built.sentinel\"\n",
),
);
sb.write_and_commit("tools/builder/run.sh", "#!/bin/sh\necho hi\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "builder"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"build",
"item-build-nowork#builder",
]);
assert!(
r.success,
"a build-event run is not part of the HOOK-108 accounting and stays \
exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.mind_home
.join("store/tool/builder/built.sentinel")
.exists(),
"the build hook itself must still be skipped in a non-TTY run: {}",
r.stdout
);
}
#[test]
fn hooks_run_interactive_decline_prints_the_plain_note_and_exits_zero() {
let sb = Sandbox::new("tty-decline");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch declined.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind_env_stdin(
&["hooks", "run", "--event", "install", "tty-decline"],
&[("MIND_TTY", "1")],
"n\n",
);
assert!(
r.stdout.contains("====== hook: setup ======"),
"MIND_TTY must take the prompting branch, which discloses the hook \
first: {}",
r.stdout
);
assert!(
r.stdout.contains("skipped install hook 'setup'"),
"a declined hook is still noted as skipped: {}",
r.stdout
);
assert!(
!r.stdout.contains("not a terminal"),
"an interactive decline has no non-TTY cause to name: {}",
r.stdout
);
assert!(
!r.stdout.contains("--dangerously-skip-install-hook-check"),
"an interactive decline is an active choice, so no unattended remedy \
is suggested: {}",
r.stdout
);
assert!(
r.success,
"an interactive decline is not a consent failure, so exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.source.join("declined.sentinel").exists(),
"the declined hook must not have run"
);
}
#[test]
fn hooks_run_interactive_accept_runs_the_hook() {
let sb = Sandbox::new("tty-accept");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch accepted.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind_env_stdin(
&["hooks", "run", "--event", "install", "tty-accept"],
&[("MIND_TTY", "1")],
"y\n",
);
assert!(r.success, "accepted run: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("running install hook 'setup'"),
"the accepted hook must be reported as running: {}",
r.stdout
);
assert!(
sb.source.join("accepted.sentinel").exists(),
"the accepted hook must have run: {}",
r.stdout
);
}
#[test]
fn hooks_run_json_interactive_accept_prompts_on_stderr_and_runs_the_hook() {
let sb = Sandbox::new("tty-accept-json");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch accepted.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind_env_stdin(
&[
"--json",
"hooks",
"run",
"--event",
"install",
"tty-accept-json",
],
&[("MIND_TTY", "1")],
"y\n",
);
assert!(r.success, "accepted run --json: {}\n{}", r.stdout, r.stderr);
assert!(
sb.source.join("accepted.sentinel").exists(),
"the accepted hook must have run even though fd 1 is redirected under \
--json: {} {}",
r.stdout,
r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"hooks run --json stdout must parse as one JSON document ({e}): {:?}",
r.stdout
)
});
assert_eq!(doc["schema"], 1, "{doc}");
assert_eq!(doc["action"], "hooks-run", "{doc}");
assert_eq!(doc["target"], "tty-accept-json", "{doc}");
assert_eq!(doc["event"], "install", "{doc}");
assert_eq!(doc["existed"], 1, "{doc}");
assert_eq!(doc["ran"], 1, "{doc}");
assert_eq!(doc["skipped"], 0, "{doc}");
assert!(
r.stderr.contains("====== hook: setup ======") && r.stderr.contains("Run this hook?"),
"the interactive disclosure and prompt must still reach the user, on \
stderr, with fd 1 redirected: {:?}",
r.stderr
);
assert!(
r.stderr.contains("running install hook 'setup'"),
"the progress line must also be visible on stderr: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_falsey_mind_tty_keeps_the_non_tty_skip() {
let sb = Sandbox::new("tty-off");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch never.sentinel\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind_env_stdin(
&["hooks", "run", "--event", "install", "tty-off"],
&[("MIND_TTY", "0")],
"y\n",
);
assert!(
r.stdout.contains("not a terminal"),
"MIND_TTY=0 must keep the non-TTY skip and its cause: {}",
r.stdout
);
assert!(
!r.success,
"the non-TTY skip is still a HOOK-107 error: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.source.join("never.sentinel").exists(),
"a hook must never run on a reply the non-TTY branch did not read"
);
}
#[test]
fn hooks_run_item_interactive_decline_exits_zero() {
let sb = Sandbox::new("item-decline");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"touch item-declined.sentinel\"\n",
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let sentinel = sb
.mind_home
.join("store/skill/scanner/item-declined.sentinel");
std::fs::remove_file(&sentinel).expect("learn should have created the sentinel");
let r = sb.mind_env_stdin(
&["hooks", "run", "--event", "install", "item-decline#scanner"],
&[("MIND_TTY", "1")],
"n\n",
);
assert!(
r.success,
"an interactively declined item hook is an active choice, not a \
consent failure: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sentinel.exists(),
"the declined item hook must not have run: {}",
r.stdout
);
}
fn shell_split(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let mut i = 0;
let mut tokens = Vec::new();
let mut cur = String::new();
let mut in_token = false;
while i < n {
let c = chars[i];
if c.is_whitespace() {
if in_token {
tokens.push(std::mem::take(&mut cur));
in_token = false;
}
i += 1;
continue;
}
in_token = true;
if c == '\'' {
i += 1;
while i < n && chars[i] != '\'' {
cur.push(chars[i]);
i += 1;
}
assert!(i < n, "unterminated single quote in {s:?}");
i += 1; } else if c == '\\' && i + 1 < n && chars[i + 1] == '\'' {
cur.push('\'');
i += 2;
} else {
cur.push(c);
i += 1;
}
}
if in_token {
tokens.push(cur);
}
tokens
}
fn remedy_argv(text: &str) -> Vec<String> {
const LEAD: &str = "re-run unattended with:\n";
let start = text
.find(LEAD)
.unwrap_or_else(|| panic!("no remedy in output: {text:?}"));
let after_lead = &text[start + LEAD.len()..];
let line = after_lead.lines().next().unwrap_or(after_lead).trim();
let mut argv = shell_split(line);
assert_eq!(
argv.first().map(String::as_str),
Some("mind"),
"the remedy must be a `mind` invocation: {text:?}"
);
argv.remove(0);
assert!(
!argv.iter().any(|a| a.contains('<') || a.contains('>')),
"the remedy must carry no placeholder to fill in: {argv:?}"
);
argv
}
fn as_args(argv: &[String]) -> Vec<&str> {
argv.iter().map(String::as_str).collect()
}
#[test]
fn remedy_from_a_source_install_skip_runs_the_skipped_hook_verbatim() {
let sb = Sandbox::new("remedy-rt-install");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("install-ran.sentinel")
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let skipped = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--force",
"remedy-rt-install",
]);
assert!(!skipped.success, "must fail: {}", skipped.stderr);
assert!(
!sb.sentinel("install-ran.sentinel").exists(),
"the hook must not have run yet"
);
let argv = remedy_argv(&skipped.stderr);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the printed remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
sb.sentinel("install-ran.sentinel").exists(),
"the remedy must actually run the skipped install hook: {argv:?}\n{}",
redo.stdout
);
}
#[test]
fn remedy_from_an_uninstall_skip_runs_the_uninstall_hook_not_the_install_one() {
let sb = Sandbox::new("remedy-rt-uninstall");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
"\n",
"[[hooks]]\n",
"name = \"cleanup\"\n",
"run = \"{}\"\n",
"event = \"uninstall\"\n",
),
sb.touch("install-ran.sentinel"),
sb.touch("uninstall-ran.sentinel"),
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
assert!(
!sb.sentinel("install-ran.sentinel").exists(),
"meld in a non-TTY must have skipped the install hook"
);
let skipped = sb.mind(&[
"hooks",
"run",
"--event",
"uninstall",
"remedy-rt-uninstall",
]);
assert!(!skipped.success, "must fail: {}", skipped.stderr);
for (channel, text) in [("note", &skipped.stdout), ("error", &skipped.stderr)] {
let argv = remedy_argv(text);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the {channel} remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
!sb.sentinel("install-ran.sentinel").exists(),
"the {channel} remedy ran the source's INSTALL hook instead of the \
uninstall hook that was skipped -- the printed command silently \
names different code: {argv:?}\n{}",
redo.stdout
);
assert!(
sb.sentinel("uninstall-ran.sentinel").exists(),
"the {channel} remedy must run the hook that was skipped: {argv:?}\n{}",
redo.stdout
);
assert!(
argv.windows(2)
.any(|w| w[0] == "--event" && w[1] == "uninstall"),
"the {channel} remedy must re-select the uninstall event: {argv:?}"
);
std::fs::remove_file(sb.sentinel("uninstall-ran.sentinel")).expect("clear sentinel");
}
}
#[test]
fn remedy_from_an_item_target_runs_the_items_hook_verbatim() {
let sb = Sandbox::new("remedy-rt-item");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("item-install-ran.sentinel")
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
assert!(
!sb.sentinel("item-install-ran.sentinel").exists(),
"a non-TTY learn must have skipped the item hook"
);
let skipped = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"remedy-rt-item#scanner",
]);
assert!(!skipped.success, "must fail: {}", skipped.stderr);
let argv = remedy_argv(&skipped.stderr);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the printed remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
sb.sentinel("item-install-ran.sentinel").exists(),
"the remedy must actually run the skipped item hook: {argv:?}\n{}",
redo.stdout
);
}
#[test]
fn remedy_from_an_item_uninstall_target_runs_the_uninstall_hook_only() {
let sb = Sandbox::new("remedy-rt-item-un");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
"uninstall = \"{}\"\n",
),
sb.touch("item-inst.sentinel"),
sb.touch("item-uninst.sentinel"),
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let skipped = sb.mind(&[
"hooks",
"run",
"--event",
"uninstall",
"remedy-rt-item-un#scanner",
]);
assert!(!skipped.success, "must fail: {}", skipped.stderr);
let argv = remedy_argv(&skipped.stderr);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the printed remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
sb.sentinel("item-uninst.sentinel").exists(),
"the remedy must run the item's UNINSTALL hook: {argv:?}\n{}",
redo.stdout
);
assert!(
!sb.sentinel("item-inst.sentinel").exists(),
"the remedy must not fall back to the item's install hook: \
{argv:?}\n{}",
redo.stdout
);
}
#[test]
fn remedy_from_a_kind_qualified_item_target_round_trips() {
let sb = Sandbox::new("remedy-rt-kind");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("kind-item-ran.sentinel")
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let target = "remedy-rt-kind#skill:scanner";
let skipped = sb.mind(&["hooks", "run", "--event", "install", target]);
assert!(!skipped.success, "must fail: {}", skipped.stderr);
let argv = remedy_argv(&skipped.stderr);
assert!(
argv.iter().any(|a| a == target),
"the remedy must name the target exactly as typed: {argv:?}"
);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the printed remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
sb.sentinel("kind-item-ran.sentinel").exists(),
"the remedy must run the skipped item hook: {argv:?}\n{}",
redo.stdout
);
}
#[test]
fn remedy_from_a_link_instance_identity_round_trips_as_a_source_target() {
let sb = Sandbox::new("remedy-rt-link");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("link-hook-ran.sentinel")
),
);
sb.write_and_commit(
"skills/deep/SKILL.md",
"---\ndescription: deep\n---\n# deep\n",
);
let r = sb.mind(&["learn", &sb.link("tree/main/skills/deep")]);
assert!(r.success, "learn link: {}\n{}", r.stdout, r.stderr);
let identity = sb.link_identity("skills/deep");
let skipped = sb.mind(&["hooks", "run", "--event", "install", "--force", &identity]);
assert!(
!skipped.success,
"must fail: {}\n{}",
skipped.stdout, skipped.stderr
);
let argv = remedy_argv(&skipped.stderr);
assert!(
argv.contains(&identity),
"the remedy must carry the link instance's full identity: {argv:?}"
);
let redo = sb.mind(&as_args(&argv));
assert!(
redo.success,
"the printed remedy must run clean: {argv:?}\n{}\n{}",
redo.stdout, redo.stderr
);
assert!(
sb.sentinel("link-hook-ran.sentinel").exists(),
"the remedy must run the link instance's skipped source hook: \
{argv:?}\n{}",
redo.stdout
);
}
#[test]
fn glob_selector_remedy_differs_between_the_note_and_the_error() {
let sb = Sandbox::new("remedy-glob");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let identity = format!(
"local/{}/remedy-glob",
sb.base.file_name().unwrap().to_string_lossy()
);
let r = sb.mind(&["hooks", "run", "--event", "install", "*"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
let note_argv = remedy_argv(&r.stdout);
assert!(
note_argv.contains(&identity),
"the note's remedy resolves the glob to the concrete identity: \
{note_argv:?}"
);
let err_argv = remedy_argv(&r.stderr);
assert!(
err_argv.contains(&identity),
"the error's remedy must also resolve the glob to the concrete \
identity, agreeing with the note: {err_argv:?}"
);
assert!(
!err_argv.iter().any(|a| a.as_str() == "*"),
"the error's remedy must never echo the raw '*' selector back into a \
command: {err_argv:?}"
);
let redo_note = sb.mind(&as_args(¬e_argv));
assert!(
redo_note.success,
"the note's remedy must run clean: {note_argv:?}\n{}\n{}",
redo_note.stdout, redo_note.stderr
);
let redo_err = sb.mind(&as_args(&err_argv));
assert!(
redo_err.success,
"the error's remedy must run clean: {err_argv:?}\n{}\n{}",
redo_err.stdout, redo_err.stderr
);
}
#[test]
fn hooks_run_glob_matching_several_sources_lists_names_not_one_command() {
let sb = Sandbox::new("remedy-glob-multi-a");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"a-setup\"\n",
"run = \"echo a\"\n",
"event = \"install\"\n",
),
);
let src_b = sb.base.join("remedy-glob-multi-b");
init_source_repo(
&src_b,
concat!(
"[[hooks]]\n",
"name = \"b-setup\"\n",
"run = \"echo b\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld a: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["meld", src_b.to_string_lossy().as_ref()]);
assert!(r.success, "meld b: {}\n{}", r.stdout, r.stderr);
let identity_a = format!(
"local/{}/remedy-glob-multi-a",
sb.base.file_name().unwrap().to_string_lossy()
);
let identity_b = format!(
"local/{}/remedy-glob-multi-b",
sb.base.file_name().unwrap().to_string_lossy()
);
let r = sb.mind(&["hooks", "run", "--event", "install", "*"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
r.stderr.contains(&identity_a) && r.stderr.contains(&identity_b),
"the error must name every resolved source that had a pending hook: {}",
r.stderr
);
assert!(
!r.stderr.contains("mind hooks run * --event"),
"the error must never echo the raw '*' selector back into a \
command-shaped string: {}",
r.stderr
);
assert!(
!r.stderr.contains(&format!(
"mind hooks run {identity_a} --event install \
--dangerously-skip-install-hook-check"
)) && !r.stderr.contains(&format!(
"mind hooks run {identity_b} --event install \
--dangerously-skip-install-hook-check"
)),
"with two sources contributing, neither alone is the whole remedy, so \
the error must not synthesize a single command naming just one: {}",
r.stderr
);
}
#[test]
fn hooks_run_item_target_under_bypass_runs_and_reports_nothing() {
let sb = Sandbox::new("item-bypass");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("bypass-item-ran.sentinel")
),
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-bypass#scanner",
]);
assert!(r.success, "bypassed item run: {}\n{}", r.stdout, r.stderr);
assert!(
!r.stderr.contains("want of consent"),
"a bypassed run consents to every hook: {}",
r.stderr
);
assert!(
sb.sentinel("bypass-item-ran.sentinel").exists(),
"the tally counted the hook as RAN, so it had better have run: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_with_two_install_hooks_counts_hooks_not_items() {
let sb = Sandbox::new("item-two-hooks");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"dual\"\n",
"path = \"skills/dual\"\n",
"install = \"{}\"\n",
"\n",
"[[items.hooks]]\n",
"name = \"second\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("dual-one.sentinel"),
sb.touch("dual-two.sentinel"),
),
);
sb.write_and_commit("skills/dual/SKILL.md", "---\ndescription: d\n---\n# d\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "dual"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-two-hooks#dual"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
r.stderr.contains("2 hook(s) had work to do"),
"one item with two install hooks must be counted as two: {}",
r.stderr
);
let argv = remedy_argv(&r.stderr);
let redo = sb.mind(&as_args(&argv));
assert!(redo.success, "remedy: {}\n{}", redo.stdout, redo.stderr);
assert!(
sb.sentinel("dual-one.sentinel").exists() && sb.sentinel("dual-two.sentinel").exists(),
"both counted hooks must actually run: {}",
redo.stdout
);
}
#[test]
fn hooks_run_item_glob_over_mixed_items_counts_only_the_hooked_one() {
let sb = Sandbox::new("item-mixed");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"install = \"{}\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
),
sb.touch("mixed-alpha.sentinel")
),
);
sb.write_and_commit("skills/alpha/SKILL.md", "---\ndescription: a\n---\n# a\n");
sb.write_and_commit("skills/beta/SKILL.md", "---\ndescription: b\n---\n# b\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "beta"]);
assert!(r.success, "learn beta: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-mixed#*"]);
assert!(!r.success, "must fail: {}\n{}", r.stdout, r.stderr);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"the hookless item must contribute nothing to the count: {}",
r.stderr
);
assert!(
r.stdout.contains("no install hooks declared"),
"the hookless item is still reported as having nothing to do: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_hook_failure_outranks_the_hooks_not_run_report() {
let sb = Sandbox::new("item-fails");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"boom\"\n",
"path = \"skills/boom\"\n",
"install = \"exit 3\"\n",
),
);
sb.write_and_commit("skills/boom/SKILL.md", "---\ndescription: b\n---\n# b\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "boom"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-fails#boom",
]);
assert!(!r.success, "a failing hook must fail the run: {}", r.stdout);
assert!(
!r.stderr.contains("want of consent"),
"the reported error must be the failure, not a consent report: {}",
r.stderr
);
assert!(
r.stderr.contains("hook") && r.stderr.contains("failed"),
"the failure must be reported as a hook failure: {}",
r.stderr
);
}
#[test]
fn hooks_run_item_glob_stops_at_the_first_failing_hook() {
let sb = Sandbox::new("item-halt");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"install = \"{} && exit 3\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"zeta\"\n",
"path = \"skills/zeta\"\n",
"install = \"{}\"\n",
),
sb.touch("halt-alpha.sentinel"),
sb.touch("halt-zeta.sentinel"),
),
);
sb.write_and_commit("skills/alpha/SKILL.md", "---\ndescription: a\n---\n# a\n");
sb.write_and_commit("skills/zeta/SKILL.md", "---\ndescription: z\n---\n# z\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "zeta"]);
assert!(r.success, "learn zeta: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-halt#*",
]);
assert!(
!r.success,
"the batch must fail: {}\n{}",
r.stdout, r.stderr
);
assert!(
sb.sentinel("halt-alpha.sentinel").exists(),
"the first item's hook must have started: {}",
r.stdout
);
assert!(
!sb.sentinel("halt-zeta.sentinel").exists(),
"a failure must stop the batch before the next item: {}",
r.stdout
);
}
#[test]
fn item_install_hooks_get_an_already_ran_record_so_a_repeat_run_settles() {
let sb = Sandbox::new("item-reco");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("reco.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
assert!(
sb.sentinel("reco.sentinel").exists(),
"learn with the bypass must have run the item hook"
);
for attempt in 1..=2 {
let r = sb.mind(&["hooks", "run", "--event", "install", "item-reco#scanner"]);
assert!(
r.success,
"attempt {attempt}: an item hook already run by learn must not be \
re-offered: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stderr.contains("had work to do"),
"attempt {attempt}: {}",
r.stderr
);
}
}
#[test]
fn hooks_run_item_install_hook_pending_again_after_upgrade_to_a_new_commit() {
let sb = Sandbox::new("item-upg");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("upg.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
assert!(
sb.sentinel("upg.sentinel").exists(),
"learn with the bypass must have run the item hook"
);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-upg#scanner"]);
assert!(
r.success,
"before upgrade, nothing should be pending: {}\n{}",
r.stdout, r.stderr
);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: s2\n---\n# s\n",
);
let r = sb.mind(&["upgrade", "--yes"]);
assert!(r.success, "upgrade: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-upg#scanner"]);
assert!(
!r.success,
"after upgrade to a new commit, the install hook must be pending \
again, not suppressed by the old commit's record: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"{}",
r.stderr
);
}
#[test]
fn hooks_run_item_force_reruns_an_already_recorded_install_hook() {
let sb = Sandbox::new("item-force");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("force.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-force#scanner"]);
assert!(
r.success,
"a repeat run with no --force must find nothing pending: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.stdout.contains("running install hook"),
"a plain repeat run must not re-run the already-recorded hook: {}",
r.stdout
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--force",
"--dangerously-skip-install-hook-check",
"item-force#scanner",
]);
assert!(
r.success,
"--force must re-offer and (under the bypass) re-run the recorded \
hook: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("running install hook"),
"--force must have re-run the item's install hook, not filtered it \
out as already-recorded: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_force_on_already_ran_hook_in_non_tty_is_hooks_not_run() {
let sb = Sandbox::new("item-force-nontty");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("force-nontty.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"item-force-nontty#scanner",
]);
assert!(
r.success,
"no --force: nothing pending: {}\n{}",
r.stdout, r.stderr
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--force",
"item-force-nontty#scanner",
]);
assert!(
!r.success,
"--force re-offers the recorded hook, which then has no consent \
available in non-TTY: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"{}",
r.stderr
);
}
#[test]
fn hooks_run_item_install_hook_record_does_not_survive_forget_and_relearn() {
let sb = Sandbox::new("item-forget");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("forget.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner", "--dangerously-skip-install-hook-check"]);
assert!(r.success, "learn: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-forget#scanner"]);
assert!(r.success, "before forget: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["forget", "scanner"]);
assert!(r.success, "forget: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "relearn: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-forget#scanner"]);
assert!(
!r.success,
"a re-learned item must start with no run record, not one carried \
over from before forget: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"{}",
r.stderr
);
}
#[test]
fn hooks_run_item_install_hook_interactive_decline_records_not_run() {
let sb = Sandbox::new("item-decline");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"{}\"\n",
),
sb.touch("decline.sentinel")
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}", r.stderr);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&["hooks", "run", "--event", "install", "item-decline#scanner"],
&[("MIND_TTY", "1")],
"n\n",
60,
);
assert!(
!timed_out,
"the run must terminate: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.success,
"an interactive decline is the user's choice, not a consent failure, so \
it must exit 0: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.sentinel("decline.sentinel").exists(),
"a declined hook must not run its side effect: {}",
r.stdout
);
let manifest =
std::fs::read_to_string(sb.mind_home.join("manifest.json")).expect("read manifest.json");
assert!(
manifest.contains("\"install_hooks\""),
"the item's manifest record must carry an install_hooks field: {manifest}"
);
assert!(
manifest.contains("\"ran_at\": null"),
"a declined item install hook must be recorded with ran_at = null, not \
a commit: {manifest}"
);
let r = sb.mind(&["hooks", "run", "--event", "install", "item-decline#scanner"]);
assert!(
!r.success,
"a declined (not run) hook must stay pending on a later non-TTY run: \
{}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"{}",
r.stderr
);
}
#[test]
fn hooks_run_item_partial_failure_keeps_the_records_of_hooks_that_already_ran() {
let sb = Sandbox::new("item-partial");
let counter = sb.sentinel("partial-counter").display().to_string();
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"sh -c 'printf x >> {}'\"\n",
"\n",
"[[items.hooks]]\n",
"name = \"boom\"\n",
"run = \"exit 7\"\n",
"event = \"install\"\n",
),
counter,
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert!(
!std::path::Path::new(&counter).exists(),
"the counting hook must not have run during a plain learn"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-partial#scanner",
]);
assert!(
!r.success,
"the batch must fail on hook 2: {}\n{}",
r.stdout, r.stderr
);
let after_first = std::fs::read(&counter).expect("counter after first run");
assert_eq!(
after_first.len(),
1,
"hook 1 must have run exactly once so far"
);
let manifest =
std::fs::read_to_string(sb.mind_home.join("manifest.json")).expect("read manifest.json");
let doc: serde_json::Value =
serde_json::from_str(&manifest).expect("manifest.json is valid JSON");
let install_hooks = doc["items"]["skill:scanner"]["install_hooks"]
.as_array()
.expect("skill:scanner has an install_hooks array");
let counting_hook = install_hooks
.iter()
.find(|h| {
h["command"]
.as_str()
.unwrap_or_default()
.contains("printf x")
})
.expect("the counting hook is recorded in install_hooks");
assert!(
counting_hook["ran_at"].is_string(),
"the counting hook ran, so it must be recorded with ran_at = Some(commit), \
not null: {manifest}"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-partial#scanner",
]);
assert!(
!r.success,
"the batch must fail again on hook 2: {}\n{}",
r.stdout, r.stderr
);
let after_second = std::fs::read(&counter).expect("counter after second run");
assert_eq!(
after_second.len(),
1,
"the earlier hook's record must have survived the first batch's \
failure, so it must NOT be offered (or re-run) again"
);
}
#[test]
fn hooks_run_item_partial_failure_in_a_long_batch_keeps_every_earlier_record() {
let sb = Sandbox::new("item-long");
let counter1 = sb.sentinel("long-counter-1").display().to_string();
let counter2 = sb.sentinel("long-counter-2").display().to_string();
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"sh -c 'printf x >> {c1}'\"\n",
"\n",
"[[items.hooks]]\n",
"name = \"second\"\n",
"run = \"sh -c 'printf x >> {c2}'\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"name = \"boom\"\n",
"run = \"exit 7\"\n",
"event = \"install\"\n",
),
c1 = counter1,
c2 = counter2,
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-long#scanner",
]);
assert!(
!r.success,
"the batch must fail on hook 3: {}\n{}",
r.stdout, r.stderr
);
assert_eq!(
std::fs::read(&counter1)
.expect("counter1 after first run")
.len(),
1,
"hook 1 must have run exactly once so far"
);
assert_eq!(
std::fs::read(&counter2)
.expect("counter2 after first run")
.len(),
1,
"hook 2 must have run exactly once so far"
);
let manifest =
std::fs::read_to_string(sb.mind_home.join("manifest.json")).expect("read manifest.json");
let doc: serde_json::Value =
serde_json::from_str(&manifest).expect("manifest.json is valid JSON");
let install_hooks = doc["items"]["skill:scanner"]["install_hooks"]
.as_array()
.expect("skill:scanner has an install_hooks array");
for (needle, which) in [("long-counter-1", "hook 1"), ("long-counter-2", "hook 2")] {
let rec = install_hooks
.iter()
.find(|h| h["command"].as_str().unwrap_or_default().contains(needle))
.unwrap_or_else(|| panic!("{which} is recorded in install_hooks: {manifest}"));
assert!(
rec["ran_at"].is_string(),
"{which} ran, so it must be recorded with ran_at = Some(commit): {manifest}"
);
}
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-long#scanner",
]);
assert!(
!r.success,
"the batch must fail again on hook 3: {}\n{}",
r.stdout, r.stderr
);
assert_eq!(
std::fs::read(&counter1)
.expect("counter1 after second run")
.len(),
1,
"hook 1's record must have survived, so it must NOT be re-run"
);
assert_eq!(
std::fs::read(&counter2)
.expect("counter2 after second run")
.len(),
1,
"hook 2's record must have survived, so it must NOT be re-run"
);
}
#[test]
fn hooks_run_item_retry_tally_excludes_the_already_ran_hook() {
let sb = Sandbox::new("item-retry-tally");
let counter = sb.sentinel("retry-counter").display().to_string();
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"scanner\"\n",
"path = \"skills/scanner\"\n",
"install = \"sh -c 'printf x >> {}'\"\n",
"\n",
"[[items.hooks]]\n",
"name = \"boom\"\n",
"run = \"exit 7\"\n",
"event = \"install\"\n",
),
counter,
),
);
sb.write_and_commit("skills/scanner/SKILL.md", "---\ndescription: s\n---\n# s\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "scanner"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"item-retry-tally#scanner",
]);
assert!(!r.success, "hook 2 must fail: {}\n{}", r.stdout, r.stderr);
assert_eq!(
std::fs::read(&counter).expect("counter").len(),
1,
"hook 1 must have run once"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"install",
"item-retry-tally#scanner",
]);
assert!(
!r.success,
"the pending hook 2 has no consent in a non-TTY run: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stderr.contains("1 hook(s) had work to do"),
"the already-ran hook 1 must be filtered out of the tally, so exactly 1 \
hook is reported skipped, not 2: {}",
r.stderr
);
assert!(
!r.stderr.contains("2 hook(s) had work to do"),
"the already-ran hook must not be re-counted: {}",
r.stderr
);
assert_eq!(
std::fs::read(&counter).expect("counter after retry").len(),
1,
"the non-TTY retry must not re-run hook 1"
);
}
#[test]
fn hooks_run_item_build_does_not_rerun_the_items_install_hook() {
let sb = Sandbox::new("build-vs-install");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"builder\"\n",
"path = \"tools/builder\"\n",
"build = \"{}\"\n",
"install = \"{}\"\n",
),
sb.touch("built.sentinel"),
sb.touch("installed.sentinel"),
),
);
sb.write_and_commit("tools/builder/run.sh", "#!/bin/sh\necho hi\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "builder"]);
assert!(r.success, "learn: {}", r.stderr);
assert!(
!sb.sentinel("built.sentinel").exists() && !sb.sentinel("installed.sentinel").exists(),
"a non-TTY learn skips both hooks"
);
let r = sb.mind(&[
"hooks",
"run",
"--event",
"build",
"--dangerously-skip-build-hook-check",
"build-vs-install#builder",
]);
assert!(r.success, "build run: {}\n{}", r.stdout, r.stderr);
assert!(
sb.sentinel("built.sentinel").exists(),
"the build hook must have run under its own bypass flag: {}",
r.stdout
);
assert!(
!sb.sentinel("installed.sentinel").exists(),
"a --event build run must not re-run the item's INSTALL hook: {}",
r.stdout
);
}
#[test]
fn hooks_run_item_build_glob_over_mixed_items_stays_exit_zero() {
let sb = Sandbox::new("build-mixed");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"alpha\"\n",
"path = \"tools/alpha\"\n",
"build = \"{}\"\n",
"\n",
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"zeta\"\n",
"path = \"tools/zeta\"\n",
),
sb.touch("mixed-build.sentinel")
),
);
sb.write_and_commit("tools/alpha/run.sh", "#!/bin/sh\n");
sb.write_and_commit("tools/zeta/run.sh", "#!/bin/sh\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "alpha"]);
assert!(r.success, "learn alpha: {}", r.stderr);
let r = sb.mind(&["learn", "zeta"]);
assert!(r.success, "learn zeta: {}", r.stderr);
let r = sb.mind(&["hooks", "run", "--event", "build", "build-mixed#*"]);
assert!(
r.success,
"a build run over a mixed glob stays exit 0 even with the build hook \
skipped: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.sentinel("mixed-build.sentinel").exists(),
"the build hook is still skipped in a non-TTY run: {}",
r.stdout
);
assert!(
r.stdout.contains("rebuilding tool:alpha") && r.stdout.contains("rebuilding tool:zeta"),
"both matched items must be re-installed: {}",
r.stdout
);
}
#[test]
fn hooks_run_json_item_glob_accumulates_one_tally_over_all_matches() {
let sb = Sandbox::new("tally-glob");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"alpha\"\n",
"path = \"tools/alpha\"\n",
"install = \"echo a\"\n",
"\n",
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"zeta\"\n",
"path = \"tools/zeta\"\n",
"install = \"echo z\"\n",
),
);
sb.write_and_commit("tools/alpha/run.sh", "#!/bin/sh\n");
sb.write_and_commit("tools/zeta/run.sh", "#!/bin/sh\n");
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "*"]).success);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"install",
"--dangerously-skip-install-hook-check",
"tally-glob#*",
]);
assert!(
r.success,
"hooks run --json glob: {} {}",
r.stdout, r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| {
panic!(
"a multi-item glob must still be ONE document ({e}): {:?}",
r.stdout
)
});
assert_eq!(doc["action"], "hooks-run", "{doc}");
assert_eq!(doc["target"], "tally-glob#*", "{doc}");
assert_eq!(doc["existed"], 2, "one hook per matched item: {doc}");
assert_eq!(doc["ran"], 2, "{doc}");
assert_eq!(doc["skipped"], 0, "{doc}");
}
#[test]
fn hooks_run_json_build_event_answers_with_a_zeroed_tally() {
let sb = Sandbox::new("tally-build");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"alpha\"\n",
"path = \"tools/alpha\"\n",
"\n",
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"zeta\"\n",
"path = \"tools/zeta\"\n",
),
);
sb.write_and_commit("tools/alpha/run.sh", "#!/bin/sh\n");
sb.write_and_commit("tools/zeta/run.sh", "#!/bin/sh\n");
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "*"]).success);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"build",
"tally-build#*",
]);
assert!(
r.success,
"hooks run --json build: {} {}",
r.stdout, r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", r.stdout));
assert_eq!(doc["event"], "build", "{doc}");
assert_eq!(
doc["existed"], 0,
"a build run has no hook-by-hook count: {doc}"
);
assert_eq!(doc["ran"], 0, "{doc}");
assert_eq!(doc["skipped"], 0, "{doc}");
assert!(
r.stderr.contains("rebuilding tool:alpha") && r.stderr.contains("rebuilding tool:zeta"),
"both rebuild lines must reach the user on stderr: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_json_already_ran_hook_answers_with_a_zeroed_tally() {
let sb = Sandbox::new("tally-again");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo setup\"\n",
"event = \"install\"\n",
),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let first = sb.mind(&[
"--json",
"hooks",
"run",
"--dangerously-skip-install-hook-check",
"tally-again",
]);
assert!(
first.success,
"first run: {} {}",
first.stdout, first.stderr
);
let doc: serde_json::Value = serde_json::from_str(first.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", first.stdout));
assert_eq!(doc["existed"], 1, "{doc}");
assert_eq!(doc["ran"], 1, "{doc}");
let again = sb.mind(&[
"--json",
"hooks",
"run",
"--dangerously-skip-install-hook-check",
"tally-again",
]);
assert!(
again.success,
"repeat run: {} {}",
again.stdout, again.stderr
);
let doc: serde_json::Value = serde_json::from_str(again.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", again.stdout));
assert_eq!(
doc["existed"], 0,
"an already-ran hook is never considered: {doc}"
);
assert_eq!(doc["ran"], 0, "{doc}");
assert_eq!(doc["skipped"], 0, "{doc}");
let forced = sb.mind(&[
"--json",
"hooks",
"run",
"--force",
"--dangerously-skip-install-hook-check",
"tally-again",
]);
let doc: serde_json::Value = serde_json::from_str(forced.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", forced.stdout));
assert_eq!(doc["existed"], 1, "--force reconsiders it: {doc}");
assert_eq!(doc["ran"], 1, "{doc}");
}
#[test]
fn hooks_run_json_interactive_decline_reports_zero_ran_and_zero_skipped() {
let sb = Sandbox::new("tty-decline-json");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"touch declined.sentinel\"\n",
"event = \"install\"\n",
),
);
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let r = sb.mind_env_stdin(
&[
"--json",
"hooks",
"run",
"--event",
"install",
"tty-decline-json",
],
&[("MIND_TTY", "1")],
"n\n",
);
assert!(
r.success,
"an interactive decline stays exit 0: {} {}",
r.stdout, r.stderr
);
assert!(
!sb.source.join("declined.sentinel").exists(),
"the declined hook must not have run: {} {}",
r.stdout,
r.stderr
);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("one JSON document ({e}): {:?}", r.stdout));
assert_eq!(doc["existed"], 1, "the hook was considered: {doc}");
assert_eq!(doc["ran"], 0, "and declined: {doc}");
assert_eq!(
doc["skipped"], 0,
"`skipped` is the want-of-consent bucket only; an interactive decline \
is not one: {doc}"
);
}
#[test]
fn hooks_run_interactive_abort_of_a_required_hook_stops_the_run() {
let sb = Sandbox::new("tty-abort");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"first\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
"\n",
"[[hooks]]\n",
"name = \"second\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("abort-first.sentinel"),
sb.touch("abort-second.sentinel"),
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&["hooks", "run", "--event", "install", "--force", "tty-abort"],
&[("MIND_TTY", "1")],
"n\na\n",
60,
);
assert!(
!timed_out,
"the run must terminate: {}\n{}",
r.stdout, r.stderr
);
assert!(
!r.success,
"an aborted required hook is a non-zero exit: {}\n{}",
r.stdout, r.stderr
);
assert_eq!(r.code, Some(1), "a MindError exits 1: {}", r.stderr);
assert!(
r.stderr.contains("second"),
"the error must name the aborted hook: {}",
r.stderr
);
assert!(
!r.stderr.contains("want of consent"),
"an abort is an active choice, not a consent failure: {}",
r.stderr
);
assert!(
!sb.sentinel("abort-first.sentinel").exists()
&& !sb.sentinel("abort-second.sentinel").exists(),
"neither hook may run: {}",
r.stdout
);
let registry = std::fs::read_to_string(sb.mind_home.join("sources.json"))
.expect("sources.json must exist");
assert!(
registry.contains("abort-first.sentinel"),
"the pre-abort skip must be persisted: {registry}"
);
}
#[test]
fn hooks_run_interactive_optional_hook_never_aborts() {
let sb = Sandbox::new("tty-optional");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"maybe\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
"optional = true\n",
),
sb.touch("optional-ran.sentinel")
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&[
"hooks",
"run",
"--event",
"install",
"--force",
"tty-optional",
],
&[("MIND_TTY", "1")],
"a\n",
60,
);
assert!(
!timed_out,
"the run must terminate: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.stdout.contains("Run this optional hook?"),
"an optional hook must take the two-way prompt: {}",
r.stdout
);
assert!(
r.success,
"an optional hook can never abort the run: {}\n{}",
r.stdout, r.stderr
);
assert!(
!sb.sentinel("optional-ran.sentinel").exists(),
"an unclear reply must not run an optional hook: {}",
r.stdout
);
}
#[test]
fn hooks_run_prompts_outnumbering_replies_terminate_on_eof() {
let sb = Sandbox::new("tty-short-input");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"first\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
"\n",
"[[hooks]]\n",
"name = \"second\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("short-first.sentinel"),
sb.touch("short-second.sentinel"),
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&[
"hooks",
"run",
"--event",
"install",
"--force",
"tty-short-input",
],
&[("MIND_TTY", "1")],
"y\n",
60,
);
assert!(
!timed_out,
"a prompt with no reply left must resolve at EOF, not block: {}\n{}",
r.stdout, r.stderr
);
assert!(
r.success,
"an EOF skip is not an abort and not a consent failure: {}\n{}",
r.stdout, r.stderr
);
assert!(
sb.sentinel("short-first.sentinel").exists(),
"the answered hook must run: {}",
r.stdout
);
assert!(
!sb.sentinel("short-second.sentinel").exists(),
"the unanswered hook must be skipped, never run on a stale reply: {}",
r.stdout
);
}
#[test]
fn empty_mind_tty_is_not_a_terminal_end_to_end() {
let sb = Sandbox::new("tty-empty");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("empty-tty.sentinel")
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&["hooks", "run", "--event", "install", "--force", "tty-empty"],
&[("MIND_TTY", "")],
"y\n",
60,
);
assert!(!timed_out, "must terminate: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("not a terminal"),
"an empty MIND_TTY must keep the non-TTY skip: {}",
r.stdout
);
assert!(!r.success, "still a HOOK-107 error: {}", r.stdout);
assert!(
!sb.sentinel("empty-tty.sentinel").exists(),
"the waiting reply must not be read: {}",
r.stdout
);
}
#[test]
fn unset_mind_tty_leaves_a_piped_stdin_non_interactive() {
let sb = Sandbox::new("tty-unset");
sb.write_and_commit(
"mind.toml",
&format!(
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"{}\"\n",
"event = \"install\"\n",
),
sb.touch("unset-tty.sentinel")
),
);
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let (piped, timed_out) = sb.mind_env_stdin_bounded(
&["hooks", "run", "--event", "install", "--force", "tty-unset"],
&[],
"y\n",
60,
);
assert!(
!timed_out,
"must terminate: {}\n{}",
piped.stdout, piped.stderr
);
assert!(
piped.stdout.contains("not a terminal"),
"a pipe is not a terminal when MIND_TTY is unset: {}",
piped.stdout
);
assert!(
!sb.sentinel("unset-tty.sentinel").exists(),
"the reply on the pipe must not be read as consent: {}",
piped.stdout
);
let null = sb.mind(&["hooks", "run", "--event", "install", "--force", "tty-unset"]);
assert_eq!(
null.stdout, piped.stdout,
"an unset MIND_TTY must produce identical output on a pipe and on \
/dev/null"
);
assert_eq!(null.code, piped.code, "and the same exit code");
}
#[test]
fn mind_tty_makes_other_verbs_prompts_reachable_and_correct() {
let sb = Sandbox::new("tty-other-verb");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: widget\n---\n# widget\n",
);
let (r, timed_out) =
sb.mind_env_stdin_bounded(&["meld", &sb.source_spec()], &[("MIND_TTY", "1")], "", 60);
assert!(
!timed_out,
"an EOF at another verb's prompt must not hang: {}\n{}",
r.stdout, r.stderr
);
assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);
assert!(
r.stdout.contains("install these"),
"MIND_TTY must make the CLI-23 prompt reachable: {}",
r.stdout
);
assert!(
!sb.claude_home.join("skills/widget").exists(),
"an EOF reply must decline, not install: {}",
r.stdout
);
let (r, timed_out) = sb.mind_env_stdin_bounded(
&["learn", "tty-other-verb#*"],
&[("MIND_TTY", "1")],
"y\n",
60,
);
assert!(!timed_out, "must terminate: {}\n{}", r.stdout, r.stderr);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert!(
sb.claude_home.join("skills/widget").exists(),
"the item must install: {}",
r.stdout
);
}
fn json_envelope(stdout: &str) -> serde_json::Value {
let start = stdout
.lines()
.position(|l| l.trim_end() == "{")
.unwrap_or_else(|| panic!("no JSON envelope found in stdout: {stdout:?}"));
let body: String = stdout.lines().skip(start).collect::<Vec<_>>().join("\n");
serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("envelope must parse as JSON ({e}): {body:?}"))
}
fn init_source_repo(dir: &Path, mind_toml: &str) {
write(&dir.join("README.md"), "# fixture\n");
write(&dir.join("mind.toml"), mind_toml);
git(dir, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(dir, &["config", "user.email", "t@t"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["add", "-A"]);
git(dir, &["commit", "-qm", "initial"]);
}
fn find_sentinel(mind_home: &Path, filename: &str) -> Option<PathBuf> {
let sources_dir = mind_home.join("sources");
walk_find(&sources_dir, filename)
}
fn walk_find(dir: &Path, name: &str) -> Option<PathBuf> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return None,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(found) = walk_find(&path, name) {
return Some(found);
}
} else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
return Some(path);
}
}
None
}
fn read_log_file(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_owned)
.collect()
}
#[allow(dead_code)]
fn read_log_in_clone(mind_home: &Path, filename: &str) -> Vec<String> {
match find_sentinel(mind_home, filename) {
None => vec![],
Some(path) => std::fs::read_to_string(&path)
.unwrap_or_default()
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_owned)
.collect(),
}
}