use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
base: PathBuf,
source: PathBuf,
mind_home: PathBuf,
claude_home: PathBuf,
}
struct Run {
stdout: String,
stderr: String,
success: bool,
}
impl Sandbox {
fn new(name: &str) -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-bld-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join(name);
let sb = Sandbox {
base: base.clone(),
source: source.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
write(&source.join("README.md"), "# fixture\n");
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
sb
}
fn mind(&self, args: &[&str]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.output()
.expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
fn write_and_commit(&self, rel: &str, contents: &str) {
write(&self.source.join(rel), contents);
git(&self.source, &["add", "-A"]);
git(&self.source, &["commit", "-qm", "fixture"]);
}
fn source_spec(&self) -> String {
self.source.to_string_lossy().into_owned()
}
}
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 build_hook_runs_when_flag_is_given() {
let sb = Sandbox::new("bld");
let sentinel_name = "built.txt";
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"mytool\"\n",
"path = \"tools/mytool\"\n",
"build = \"touch {sentinel}\"\n",
),
sentinel = sentinel_name
);
sb.write_and_commit(
"tools/mytool/TOOL.md",
"---\ndescription: test tool\n---\n# mytool\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&["learn", "tool:mytool", "--accept-hooks=build"]);
assert!(
learn.success,
"learn with --accept-hooks=build must succeed: {} {}",
learn.stdout, learn.stderr
);
let store_sentinel = sb.mind_home.join("store/tool/mytool").join(sentinel_name);
assert!(
store_sentinel.exists(),
"build hook must have created {sentinel_name} in the store: {store_sentinel:?}"
);
}
#[test]
fn failing_build_hook_rolls_back_install() {
let sb = Sandbox::new("fail");
let toml = concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"badtool\"\n",
"path = \"tools/badtool\"\n",
"build = \"exit 7\"\n",
);
sb.write_and_commit(
"tools/badtool/TOOL.md",
"---\ndescription: failing build\n---\n# badtool\n",
);
sb.write_and_commit("mind.toml", toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&["learn", "tool:badtool", "--accept-hooks=build"]);
assert!(
!learn.success,
"learn must fail when the build hook exits non-zero (HOOK-71): {} {}",
learn.stdout, learn.stderr
);
let store_path = sb.mind_home.join("store/tool/badtool");
assert!(
!store_path.exists(),
"store copy must not exist after a failed build hook (HOOK-71): {store_path:?}"
);
let combined = format!("{}{}", learn.stdout, learn.stderr);
assert!(
combined.contains("exit 7") || combined.contains("failed") || combined.contains("build"),
"failure output must reference the build hook error: {combined}"
);
}
#[test]
fn build_hook_reruns_on_reinstall() {
let sb = Sandbox::new("rerun");
let run_log = sb.base.join("run.log");
let log_path = run_log.display().to_string();
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"counter\"\n",
"path = \"tools/counter\"\n",
"build = \"echo RAN >> {log}\"\n",
),
log = log_path
);
sb.write_and_commit(
"tools/counter/TOOL.md",
"---\ndescription: counter tool\n---\n# counter\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn1 = sb.mind(&["learn", "tool:counter", "--accept-hooks=build"]);
assert!(
learn1.success,
"first learn must succeed: {} {}",
learn1.stdout, learn1.stderr
);
let lines_after_first = std::fs::read_to_string(&run_log)
.unwrap_or_default()
.lines()
.filter(|l| l.contains("RAN"))
.count();
assert_eq!(
lines_after_first, 1,
"build hook must have run once after the first learn (HOOK-73)"
);
assert!(sb.mind(&["forget", "tool:counter"]).success);
let learn2 = sb.mind(&["learn", "tool:counter", "--accept-hooks=build"]);
assert!(
learn2.success,
"second learn must succeed: {} {}",
learn2.stdout, learn2.stderr
);
let lines_after_second = std::fs::read_to_string(&run_log)
.unwrap_or_default()
.lines()
.filter(|l| l.contains("RAN"))
.count();
assert_eq!(
lines_after_second, 2,
"build hook must re-run on reinstall (HOOK-73): total runs should be 2"
);
}
#[test]
fn build_hook_reruns_on_upgrade() {
let sb = Sandbox::new("upg");
let run_log = sb.base.join("upg.log");
let log_path = run_log.display().to_string();
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"utool\"\n",
"path = \"tools/utool\"\n",
"build = \"echo RAN >> {log}\"\n",
),
log = log_path
);
sb.write_and_commit(
"tools/utool/TOOL.md",
"---\ndescription: upgradeable tool\n---\n# utool\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&["learn", "tool:utool", "--accept-hooks=build"]);
assert!(
learn.success,
"initial learn must succeed: {} {}",
learn.stdout, learn.stderr
);
assert_eq!(
std::fs::read_to_string(&run_log)
.unwrap_or_default()
.lines()
.filter(|l| l.contains("RAN"))
.count(),
1,
"build hook ran once after initial learn"
);
sb.write_and_commit(
"tools/utool/TOOL.md",
"---\ndescription: upgradeable tool v2\n---\n# utool\n",
);
let upgrade = sb.mind(&["upgrade", "--yes", "--accept-hooks=build"]);
assert!(
upgrade.success,
"upgrade must succeed: {} {}",
upgrade.stdout, upgrade.stderr
);
let total_runs = std::fs::read_to_string(&run_log)
.unwrap_or_default()
.lines()
.filter(|l| l.contains("RAN"))
.count();
assert_eq!(
total_runs, 2,
"build hook must re-run on upgrade (HOOK-73): total runs should be 2, got {total_runs}"
);
}
#[test]
fn build_hook_is_skipped_without_flag_in_non_tty() {
let sb = Sandbox::new("skip");
let sentinel_name = "should_not_exist.txt";
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"skiptool\"\n",
"path = \"tools/skiptool\"\n",
"build = \"touch {sentinel}\"\n",
),
sentinel = sentinel_name
);
sb.write_and_commit(
"tools/skiptool/TOOL.md",
"---\ndescription: skip tool\n---\n# skiptool\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&["learn", "tool:skiptool"]);
assert!(
learn.success,
"learn without the flag must still succeed (item installs unbuilt, HOOK-72): {} {}",
learn.stdout, learn.stderr
);
assert!(
sb.mind_home.join("store/tool/skiptool").exists(),
"item must be in the store even when its build hook was skipped"
);
let store_sentinel = sb.mind_home.join("store/tool/skiptool").join(sentinel_name);
assert!(
!store_sentinel.exists(),
"build hook must NOT run in a non-TTY context without the flag (HOOK-72): {store_sentinel:?}"
);
let combined = format!("{}{}", learn.stdout, learn.stderr);
assert!(
combined.contains("skipped") && combined.contains("build"),
"output must mention that the build hook was skipped (HOOK-72): {combined}"
);
}
#[test]
fn build_hook_output_streams_like_every_other_hook() {
let sb = Sandbox::new("bld-stream");
let toml = concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"mytool\"\n",
"path = \"tools/mytool\"\n",
"build = \"echo BUILD-ON-STDOUT; echo BUILD-ON-STDERR 1>&2\"\n",
);
sb.write_and_commit(
"tools/mytool/TOOL.md",
"---\ndescription: test tool\n---\n# mytool\n",
);
sb.write_and_commit("mind.toml", toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn = sb.mind(&["learn", "tool:mytool", "--accept-hooks=build"]);
assert!(
learn.success,
"learn failed: {} {}",
learn.stdout, learn.stderr
);
assert!(
learn.stdout.contains("====== (hook: build) ======")
&& learn.stdout.contains("====== (end hook: build) ======"),
"the build hook must be framed: {}",
learn.stdout
);
assert!(
learn.stdout.contains("BUILD-ON-STDOUT"),
"the build hook's stdout must reach mind's stdout: {}",
learn.stdout
);
assert!(
learn.stderr.contains("BUILD-ON-STDERR"),
"the build hook's stderr must reach mind's stderr: {}",
learn.stderr
);
assert!(
!learn.stdout.contains("BUILD-ON-STDERR"),
"and must not be copied onto stdout: {}",
learn.stdout
);
}