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-js-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join(name);
let sb = Sandbox {
base: base.clone(),
source: source.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
write(&source.join("README.md"), "# fixture\n");
git(&source, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&source, &["config", "user.email", "t@t"]);
git(&source, &["config", "user.name", "t"]);
git(&source, &["add", "-A"]);
git(&source, &["commit", "-qm", "initial"]);
sb
}
fn mind(&self, args: &[&str]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.env("MIND_HOME", &self.mind_home)
.env("CLAUDE_HOME", &self.claude_home)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.output()
.expect("run mind");
Run {
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
}
}
fn write_and_commit(&self, rel: &str, contents: &str) {
write(&self.source.join(rel), contents);
git(&self.source, &["add", "-A"]);
git(&self.source, &["commit", "-qm", "fixture"]);
}
fn source_spec(&self) -> String {
self.source.to_string_lossy().into_owned()
}
fn write_config(&self, body: &str) {
write(&self.mind_home.join("config.toml"), body);
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {dir:?}");
}
fn assert_stdout_is_one_json_document(stdout: &str) -> serde_json::Value {
serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!(
"stdout under --json must parse as exactly one JSON document \
and nothing else ({e}): {stdout:?}"
)
})
}
fn stdout_without_hook_records(stdout: &str) -> String {
let mut v = assert_stdout_is_one_json_document(stdout);
if let Some(obj) = v.as_object_mut() {
obj.remove("hooks");
}
v.to_string()
}
#[test]
fn hooks_run_skip_note_then_error_is_one_document() {
let sb = Sandbox::new("hooks-note");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo 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", "hooks-note"]);
assert!(
!r.success,
"the HOOK-107 path must fail: {}\n{}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["error"]["kind"], "hooks-not-run", "{v}");
}
#[test]
fn learn_unreachable_lobe_note_then_success_is_one_document() {
let sb = Sandbox::new("lobe-note");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\n# widget\n",
);
let unreachable = sb.base.join("does-not-exist-parent").join("lobe");
sb.write_config(&format!(
"lobes = [\"{}\", \"{}\"]\n",
sb.claude_home.display(),
unreachable.display()
));
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["--json", "learn", "widget"]);
assert!(
r.success,
"learn must still succeed: {}\n{}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["outcome"], "installed", "{v}");
assert!(
r.stderr.contains("is unreachable"),
"the HARN-16 note must be routed to stderr, not dropped: {:?}",
r.stderr
);
assert!(
!r.stdout.contains("unreachable"),
"the note must not also land on stdout: {:?}",
r.stdout
);
}
#[test]
fn meld_skipped_install_hook_note_is_one_document() {
let sb = Sandbox::new("meld-note");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"echo ran\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["--json", "meld", &sb.source_spec(), "--register-only"]);
assert!(
r.success,
"meld must succeed on a skip: {}\n{}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "meld", "{v}");
assert!(
r.stderr.contains("skipped install hook"),
"the note must be routed to stderr: {:?}",
r.stderr
);
}
#[test]
fn unmeld_skipped_uninstall_hook_note_is_one_document() {
let sb = Sandbox::new("unmeld-note");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"teardown\"\n",
"run = \"echo done\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["--json", "unmeld", "unmeld-note", "--keep-items", "--yes"]);
assert!(
r.success,
"unmeld must succeed on a skip: {}\n{}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "unmeld", "{v}");
assert!(
r.stderr.contains("skipped uninstall hook"),
"the note must be routed to stderr: {:?}",
r.stderr
);
}
#[test]
fn config_lobes_add_backfill_warn_is_one_document() {
let sb = Sandbox::new("lobe-add-note");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\n# widget\n",
);
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["learn", "widget"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
let new_lobe = sb.base.join("newlobe");
write(&new_lobe.join("skills/widget"), "not mind's file\n");
let r = sb.mind(&[
"--json",
"config",
"lobes",
"add",
"--yes",
&new_lobe.to_string_lossy(),
]);
assert!(
r.success,
"adding the lobe succeeds even though one backfill was blocked: {}\n{}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "lobe-add", "{v}");
assert!(
r.stderr.contains("could not link"),
"the HARN-17 warn must be routed to stderr: {:?}",
r.stderr
);
}
#[test]
fn meld_running_install_hook_is_one_document() {
let sb = Sandbox::new("meld-leak");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"setup\"\n",
"run = \"true\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&[
"--json",
"meld",
&sb.source_spec(),
"--register-only",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {}\n{}", r.stdout, r.stderr);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stderr.contains("running install hook"),
"the progress line must be routed to stderr, not dropped: {:?}",
r.stderr
);
}
#[test]
fn unmeld_running_uninstall_hook_is_one_document() {
let sb = Sandbox::new("unmeld-leak");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"teardown\"\n",
"run = \"true\"\n",
"event = \"uninstall\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"unmeld",
"unmeld-leak",
"--keep-items",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "unmeld: {}\n{}", r.stdout, r.stderr);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stderr.contains("running uninstall hook"),
"the progress line must be routed to stderr, not dropped: {:?}",
r.stderr
);
}
#[test]
fn learn_running_build_hook_is_one_document() {
let sb = Sandbox::new("learn-build-leak");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"tool\"\n",
"name = \"builder\"\n",
"path = \"tools/builder\"\n",
"build = \"true\"\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(&["--json", "learn", "builder", "--accept-hooks=build"]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stderr.contains("running build hook"),
"the progress line must be routed to stderr, not dropped: {:?}",
r.stderr
);
}
#[test]
fn learn_running_item_install_hook_is_one_document() {
let sb = Sandbox::new("learn-item-hook-leak");
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"widget\"\n",
"path = \"widget\"\n",
"install = \"true\"\n",
),
);
sb.write_and_commit("widget/SKILL.md", "---\ndescription: d\n---\n# widget\n");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"learn",
"widget",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "learn: {}\n{}", r.stdout, r.stderr);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stderr.contains("running install hook for"),
"the progress line must be routed to stderr, not dropped: {:?}",
r.stderr
);
}
#[test]
fn hooks_run_running_hook_is_one_document() {
let sb = Sandbox::new("hooks-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",
"--accept-hooks=install,update,uninstall",
"hooks-running-leak",
]);
assert!(
!r.success,
"second hook must fail the run: {}\n{}",
r.stdout, r.stderr
);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stderr.contains("running install hook"),
"the progress line must be routed to stderr, not dropped: {:?}",
r.stderr
);
}
#[test]
fn a_hooks_own_output_cannot_reach_json_stdout() {
let sb = Sandbox::new("hook-output");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"loud\"\n",
"run = \"echo '{\\\"schema\\\":1,\\\"action\\\":\\\"forged\\\"}'; \
echo to-stderr >&2\"\n",
"event = \"install\"\n",
),
);
let r = sb.mind(&["meld", &sb.source_spec(), "--register-only"]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&[
"--json",
"hooks",
"run",
"--event",
"install",
"--accept-hooks=install,update,uninstall",
"hook-output",
]);
assert!(r.success, "hooks run: {}\n{}", r.stdout, r.stderr);
assert!(
!stdout_without_hook_records(&r.stdout).contains("forged"),
"a hook's stdout must never reach --json stdout: {:?}",
r.stdout
);
assert!(
r.stderr.contains("forged") && r.stderr.contains("to-stderr"),
"both of the hook's streams must still be shown, on stderr: {:?}",
r.stderr
);
}
#[test]
fn learn_link_json_answers_with_learn_not_meld() {
let sb = Sandbox::new("link-src");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\n# widget\n",
);
let url = format!(
"file://{}/tree/main/skills/widget",
sb.source.to_string_lossy()
);
let r = sb.mind(&["--json", "learn", &url]);
assert!(r.success, "learn <url> --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(
v["action"], "learn",
"the item-link install answers as `learn`, not the internal `meld` \
registration step: {v}"
);
assert_eq!(v["outcome"], "installed", "{v}");
}
#[test]
fn absorb_json_answers_with_absorb_not_meld() {
let sb = Sandbox::new("absorb-src");
write(
&sb.claude_home.join("skills/orphan/SKILL.md"),
"---\ndescription: not managed by mind\n---\n# orphan\n",
);
let dest = sb.base.join("absorb-dest");
std::fs::create_dir_all(&dest).unwrap();
git(&dest, &["-c", "init.defaultBranch=main", "init", "-q"]);
git(&dest, &["config", "user.email", "t@t"]);
git(&dest, &["config", "user.name", "t"]);
let r = sb.mind(&[
"--json",
"absorb",
"skill:orphan",
"--to",
&dest.to_string_lossy(),
"--yes",
]);
assert!(r.success, "absorb --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(
v["action"], "absorb",
"absorb answers as `absorb`, not the internal `meld` step that \
registers its (freshly created) destination: {v}"
);
assert_eq!(v["outcome"], "absorbed", "{v}");
}
#[test]
fn review_json_clean_source_answers_clean_outcome() {
let sb = Sandbox::new("review-clean");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\n# widget\n",
);
let r = sb.mind(&["--json", "review", &sb.source_spec()]);
assert!(r.success, "review --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["schema"], 1, "{v}");
assert_eq!(v["action"], "review", "{v}");
assert_eq!(v["outcome"], "clean", "{v}");
assert_eq!(v["hard"], serde_json::json!([]), "{v}");
assert_eq!(v["advisory"], serde_json::json!([]), "{v}");
assert_eq!(v["fixed"], serde_json::json!([]), "{v}");
}
#[test]
fn review_json_advisory_only_answers_advisory_outcome() {
let sb = Sandbox::new("review-advisory");
sb.write_and_commit("agents/dev.md", "# dev agent\nno frontmatter here\n");
let r = sb.mind(&["--json", "review", &sb.source_spec()]);
assert!(r.success, "review --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["outcome"], "advisory", "{v}");
assert_eq!(v["hard"], serde_json::json!([]), "{v}");
let advisory = v["advisory"]
.as_array()
.unwrap_or_else(|| panic!("advisory must be an array: {v}"));
assert!(
advisory.iter().any(|f| f["kind"] == "missing-description"),
"{v}"
);
}
#[test]
fn review_json_hard_finding_is_folded_into_the_error_envelope_details() {
let sb = Sandbox::new("review-hard");
sb.write_and_commit("mind.toml", "[[[[bad toml");
let r = sb.mind(&["--json", "review", &sb.source_spec()]);
assert!(
!r.success,
"a hard finding must still fail review under --json: {} {}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["schema"], 1, "{v}");
assert_eq!(
v["error"]["kind"], "review-failed",
"the CLI-181 envelope, not the CLI-219 success document: {v}"
);
assert_eq!(v["details"]["action"], "review", "{v}");
assert_eq!(v["details"]["outcome"], "failed", "{v}");
let hard = v["details"]["hard"]
.as_array()
.unwrap_or_else(|| panic!("details.hard must be an array: {v}"));
assert!(hard.iter().any(|f| f["kind"] == "toml-parse-error"), "{v}");
}
#[test]
fn review_policy_json_clean_answers_clean_outcome() {
let sb = Sandbox::new("review-policy-clean");
let policy = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/policy/policy.toml");
let r = sb.mind(&["--json", "review", "--policy", &policy.to_string_lossy()]);
assert!(
r.success,
"review --policy --json: {} {}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "review", "{v}");
assert_eq!(v["outcome"], "clean", "{v}");
}
#[test]
fn review_policy_json_hard_finding_is_folded_into_the_error_envelope_details() {
let sb = Sandbox::new("review-policy-hard");
let policy = sb.base.join("bad-policy.toml");
write(&policy, "not valid toml [[[");
let r = sb.mind(&["--json", "review", "--policy", &policy.to_string_lossy()]);
assert!(
!r.success,
"an invalid policy file must fail under --json: {} {}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["error"]["kind"], "review-failed", "{v}");
let hard = v["details"]["hard"]
.as_array()
.unwrap_or_else(|| panic!("details.hard must be an array: {v}"));
assert!(hard.iter().any(|f| f["kind"] == "invalid-policy"), "{v}");
}
#[test]
fn hooks_list_json_is_one_document() {
let sb = Sandbox::new("hooks-list-doc");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["--json", "hooks", "list", "hooks-list-doc"]);
assert!(r.success, "hooks list --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "hooks-list", "{v}");
}
#[test]
fn hooks_run_json_no_hooks_still_answers_with_zeroed_document() {
let sb = Sandbox::new("hooks-run-doc");
let r = sb.mind(&["meld", &sb.source_spec()]);
assert!(r.success, "meld: {}", r.stderr);
let r = sb.mind(&["--json", "hooks", "run", "hooks-run-doc"]);
assert!(r.success, "hooks run --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "hooks-run", "{v}");
assert_eq!(v["existed"], 0, "{v}");
assert_eq!(v["ran"], 0, "{v}");
assert_eq!(v["skipped"], 0, "{v}");
}
#[test]
fn cli_218_every_driven_verb_is_json_or_a_named_exclusion() {
let sb = Sandbox::new("boundary");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\n# widget\n",
);
let spec = sb.source_spec();
let policy = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples/policy/policy.toml")
.to_string_lossy()
.into_owned();
let included: Vec<Vec<&str>> = vec![
vec!["--json", "meld", &spec, "--register-only"],
vec!["--json", "meld", &spec, "--register-only"], vec!["--json", "learn", "widget"],
vec!["--json", "recall"],
vec!["--json", "probe", "--no-tui"],
vec!["--json", "review", &spec],
vec!["--json", "review", "--policy", &policy],
vec!["--json", "introspect"],
vec!["--json", "config", "show"],
vec!["--json", "config", "lobes", "list"],
vec!["--json", "hooks", "list", "boundary"],
vec!["--json", "hooks", "run", "boundary"],
vec!["--json", "sync"],
vec!["--json", "upgrade", "--yes"],
vec!["--json", "forget", "widget", "--yes"],
vec!["--json", "prune", "--yes"],
vec!["--json", "unmeld", "boundary", "--yes"],
];
for args in included {
let r = sb.mind(&args);
assert_stdout_is_one_json_document(&r.stdout);
assert!(
r.stdout.trim().lines().count() >= 1,
"{args:?}: {:?} / {:?}",
r.stdout,
r.stderr
);
}
for args in [
vec!["--json", "dump"],
vec!["--json", "completions", "bash"],
vec!["--json", "man"],
] {
let r = sb.mind(&args);
assert!(
serde_json::from_str::<serde_json::Value>(r.stdout.trim()).is_err(),
"{args:?} is a named CLI-218 exclusion; its stdout must not parse \
as JSON: {:?}",
r.stdout
);
}
}
fn extra_source_repo(parent: &Path, name: &str) -> PathBuf {
let dir = parent.join(name);
write(&dir.join("README.md"), "# extra source\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"]);
dir
}
#[test]
fn upgrade_json_yes_keeps_stdout_one_document_with_disclosure_on_stderr() {
let sb = Sandbox::new("skills");
sb.write_and_commit(
"skills/widget-a/SKILL.md",
"---\nname: widget-a\ndescription: a\n---\n# widget a\n",
);
let two = extra_source_repo(&sb.base.join("other"), "skills");
write(
&two.join("skills/widget-b/SKILL.md"),
"---\nname: widget-b\ndescription: b\n---\n# widget b\n",
);
git(&two, &["add", "-A"]);
git(&two, &["commit", "-qm", "widget-b"]);
let two_spec = two.to_string_lossy().into_owned();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["meld", &two_spec]).success);
assert!(sb.mind(&["learn", "skill:widget-a"]).success);
assert!(sb.mind(&["learn", "skill:widget-b"]).success);
sb.write_and_commit(
"skills/widget-a/SKILL.md",
"---\nname: widget-a\ndescription: a\n---\n# widget a\nedited\n",
);
write(
&two.join("skills/widget-b/SKILL.md"),
"---\nname: widget-b\ndescription: b\n---\n# widget b\nedited\n",
);
git(&two, &["add", "-A"]);
git(&two, &["commit", "-qm", "edit widget-b"]);
let r = sb.mind(&["--json", "upgrade", "skills#*", "--yes"]);
assert!(
r.success,
"upgrade 'skills#*' --json --yes: {} {}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "upgrade", "{v}");
assert!(
r.stderr.contains("matched 2 sources"),
"the multi-source disclosure must still reach the user, on stderr: {:?}",
r.stderr
);
assert!(
!r.stdout.contains("matched 2 sources"),
"the disclosure must not also land on stdout: {:?}",
r.stdout
);
}
#[test]
fn error_envelope_without_recorded_details_has_no_details_member() {
let sb = Sandbox::new("no-details");
let r = sb.mind(&["--json", "learn", "does-not-exist"]);
assert!(
!r.success,
"learn of a missing item must fail: {}",
r.stdout
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert!(v["error"]["kind"].is_string(), "{v}");
assert!(
!v.as_object().is_some_and(|o| o.contains_key("details")),
"an error with no recorded findings must carry no `details` member: {v}"
);
}
#[test]
fn recorded_details_do_not_leak_into_a_later_unrelated_failure() {
let sb = Sandbox::new("leak");
sb.write_and_commit("mind.toml", "[[[[bad toml");
let failed_review = sb.mind(&["--json", "review", &sb.source_spec()]);
assert!(!failed_review.success, "{}", failed_review.stdout);
let v = assert_stdout_is_one_json_document(&failed_review.stdout);
assert!(v["details"].is_object(), "precondition: {v}");
let next = sb.mind(&["--json", "forget", "nothing-installed"]);
assert!(!next.success, "{}", next.stdout);
let v = assert_stdout_is_one_json_document(&next.stdout);
assert!(
!v.as_object().is_some_and(|o| o.contains_key("details")),
"the previous run's review findings must not appear here: {v}"
);
}
#[test]
fn review_json_error_details_carry_advisory_and_fixed_too() {
let sb = Sandbox::new("review-both");
sb.write_and_commit(
"skills/widget/SKILL.md",
"---\ndescription: d\n---\nhand off to {{ns:nope}}\n",
);
sb.write_and_commit("agents/dev.md", "# dev agent\n");
let r = sb.mind(&["--json", "review", &sb.source_spec()]);
assert!(
!r.success,
"a hard finding must fail: {} {}",
r.stdout, r.stderr
);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["error"]["kind"], "review-failed", "{v}");
assert_eq!(v["details"]["outcome"], "failed", "{v}");
let hard = v["details"]["hard"]
.as_array()
.unwrap_or_else(|| panic!("details.hard must be an array: {v}"));
assert!(
hard.iter().any(|f| f["kind"] == "bad-reference"),
"the hard finding: {v}"
);
let advisory = v["details"]["advisory"]
.as_array()
.unwrap_or_else(|| panic!("details.advisory must be an array: {v}"));
assert!(
advisory.iter().any(|f| f["kind"] == "missing-description"),
"the advisory findings must survive into `details`, not be dropped \
because the run failed: {v}"
);
assert!(
v["details"]["fixed"].is_array(),
"`fixed` must be present (empty) rather than omitted: {v}"
);
assert!(
hard.iter()
.chain(advisory.iter())
.all(|f| f["message"].as_str().is_some_and(|m| !m.is_empty())),
"{v}"
);
}
#[test]
fn review_fix_json_reports_the_rewritten_files_and_one_document() {
let sb = Sandbox::new("review-fix");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\ndescription: review\n---\nrun ~/.claude/skills/review/run.sh; hand off to dev\n",
);
sb.write_and_commit("agents/dev.md", "---\ndescription: dev\n---\n# dev\n");
let r = sb.mind(&["--json", "review", &sb.source_spec(), "--fix"]);
assert!(r.success, "review --fix --json: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(v["action"], "review", "{v}");
let fixed = v["fixed"]
.as_array()
.unwrap_or_else(|| panic!("fixed must be an array: {v}"));
assert!(
fixed.iter().any(|f| f
.as_str()
.is_some_and(|s| s.ends_with("skills/review/SKILL.md"))),
"the rewritten file must be reported in `fixed`: {v}"
);
let rewritten = std::fs::read_to_string(sb.source.join("skills/review/SKILL.md")).unwrap();
assert!(
rewritten.contains("{{ns:dev}}"),
"the bare sibling name must have been templatized: {rewritten}"
);
assert!(
r.stderr.contains("fixed"),
"the per-file `fixed <path>` line must reach the user on stderr: {:?}",
r.stderr
);
}
#[test]
fn a_streaming_hook_cannot_reach_the_json_document_on_stdout() {
let sb = Sandbox::new("json-stream-hook");
sb.write_and_commit(
"mind.toml",
"[[hooks]]\nrun = \"echo NOISE-ON-STDOUT; echo NOISE-ON-STDERR 1>&2\"\n\
name = \"build\"\nevent = \"install\"\n",
);
let spec = sb.source_spec();
let r = sb.mind(&[
"--json",
"meld",
&spec,
"--register-only",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld failed: {} {}", r.stdout, r.stderr);
let doc: serde_json::Value = serde_json::from_str(r.stdout.trim())
.unwrap_or_else(|e| panic!("stdout must be one JSON document ({e}): {}", r.stdout));
assert!(doc.get("action").is_some(), "the meld result: {}", r.stdout);
let rest = stdout_without_hook_records(&r.stdout);
assert!(
!rest.contains("NOISE-ON-STDOUT") && !r.stdout.contains("====== (hook:"),
"neither the hook output nor its frame may reach stdout: {}",
r.stdout
);
assert!(
r.stderr.contains("NOISE-ON-STDOUT") && r.stderr.contains("NOISE-ON-STDERR"),
"the hook's output must still be visible on stderr: {}",
r.stderr
);
}
#[test]
fn a_hooks_write_to_the_saved_stdout_fd_cannot_reach_json_stdout() {
let sb = Sandbox::new("fd3-hook");
sb.write_and_commit(
"mind.toml",
concat!(
"[[hooks]]\n",
"name = \"leaky\"\n",
"run = \"{ echo '{\\\"schema\\\":1,\\\"action\\\":\\\"forged\\\",\\\"outcome\\\":\\\"clean\\\"}' >&3; } 2>/dev/null; true\"\n",
"event = \"install\"\n",
),
);
let spec = sb.source_spec();
let r = sb.mind(&[
"--json",
"meld",
&spec,
"--register-only",
"--accept-hooks=install,update,uninstall",
]);
assert!(r.success, "meld: {} {}", r.stdout, r.stderr);
let v = assert_stdout_is_one_json_document(&r.stdout);
assert_eq!(
v["action"], "meld",
"a hook writing through the saved real-stdout fd must not replace \
mind's own result document with a forged one: {v}"
);
assert!(
!stdout_without_hook_records(&r.stdout).contains("forged"),
"the hook's forged document must never reach stdout, whether through \
an inherited fd 1 (HOOK-32's original guarantee) or an inherited \
saved-stdout fd (M1-a): {:?}",
r.stdout
);
}