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,
combined: String,
}
impl Sandbox {
fn new(name: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-hd-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join(name);
Sandbox {
base: base.clone(),
source,
mind_home: base.join("mind"),
claude_home: base.join("claude"),
}
}
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");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let combined = format!("{stdout}{stderr}");
Run {
stdout,
stderr,
success: out.status.success(),
combined,
}
}
fn write_and_commit(&self, rel: &str, contents: &str) {
write_file(&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 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");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let combined = format!("{stdout}{stderr}");
Run {
stdout,
stderr,
success: out.status.success(),
combined,
}
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write_file(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {dir:?}");
}
fn init_source(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
write_file(&dir.join("README.md"), "# fixture\n");
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"]);
}
#[test]
fn local_path_meld_hook_disclosure_has_no_browse_url_line() {
let sb = Sandbox::new("noBrowse");
init_source(&sb.source);
let toml = concat!(
"[[hooks]]\n",
"run = \"echo hook-ran\"\n",
"event = \"install\"\n",
);
sb.write_and_commit("mind.toml", toml);
sb.write_and_commit(
"agents/helper.md",
"---\ndescription: helper\n---\n# helper\n",
);
let spec = sb.source_spec();
let result = sb.mind(&["meld", &spec]);
assert!(
result.success,
"meld of local-path source with install hook must succeed (hook skipped by HOOK-22): {} {}",
result.stdout, result.stderr
);
assert!(
result.combined.contains("skipped install hook"),
"expected 'skipped install hook' note in output; got: {}",
result.combined
);
assert!(
!result.combined.contains("Browse:"),
"local-path meld disclosure must not contain a Browse: line; got: {}",
result.combined
);
}
#[test]
fn browse_url_method_produces_tree_url_for_github_host() {
let sb = Sandbox::new("smoke");
init_source(&sb.source);
let toml = concat!(
"[[hooks]]\n",
"run = \"echo smoke-ran\"\n",
"event = \"install\"\n",
);
sb.write_and_commit("mind.toml", toml);
let spec = sb.source_spec();
let result = sb.mind(&["meld", &spec]);
assert!(
result.success,
"smoke meld must succeed: {} {}",
result.stdout, result.stderr
);
let recall = sb.mind(&["recall", "--sources"]);
assert!(recall.success, "recall --sources must succeed after meld");
}
#[test]
fn item_update_hook_disclosure_names_event_and_label() {
let sb = Sandbox::new("item-upd-disc");
init_source(&sb.source);
sb.write_and_commit(
"skills/scanner/SKILL.md",
"---\ndescription: scanner\n---\n# scanner\n",
);
sb.write_and_commit(
"skills/scanner/mind.toml",
"[[hooks]]\nrun = \"true\"\nname = \"Install step\"\n",
);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec]).success, "meld must succeed");
let learn = sb.mind(&[
"learn",
"scanner",
"--accept-hooks=install,update,uninstall",
]);
assert!(learn.success, "learn: {}\n{}", learn.stdout, learn.stderr);
sb.write_and_commit(
"skills/scanner/mind.toml",
concat!(
"[[hooks]]\n",
"run = \"true\"\n",
"name = \"Install step\"\n",
"\n",
"[[hooks]]\n",
"run = \"true # set-up\"\n",
"name = \"Set up\"\n",
"event = \"update\"\n",
),
);
assert!(sb.mind(&["sync"]).success, "sync must succeed");
let result = sb.mind_env_stdin(
&["hooks", "run", "--event", "update", "item-upd-disc#scanner"],
&[("MIND_TTY", "1")],
"y\n",
);
assert!(
result.success,
"interactive update hook run must succeed: {}\n{}",
result.stdout, result.stderr
);
assert!(
result.combined.contains("Event: update"),
"the item update hook disclosure must name its own event on a \
dedicated Event: line: {}",
result.combined
);
assert!(
!result.combined.contains("(per-item update)"),
"the event must not be smuggled into the Pin: field: {}",
result.combined
);
assert!(
result.combined.contains("Set up"),
"the item update hook disclosure must show the hook's declared \
name: {}",
result.combined
);
assert!(
result
.combined
.contains("running update hook for skill:scanner"),
"the progress line must read 'running update hook for \
skill:scanner': {}",
result.combined
);
}