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-itlc-{}-{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 mind_cwd(&self, args: &[&str], cwd: &Path) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_mind"))
.args(args)
.current_dir(cwd)
.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:?}");
}
fn read_log(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.filter(|l| !l.is_empty())
.collect()
}
#[test]
fn item_hooks_array_runs_install_then_uninstall_in_declaration_order() {
let sb = Sandbox::new("arr");
let log = sb.base.join("order.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo i1 >> {lg}\"\n",
"name = \"first install\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo u1 >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo i2 >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo u2 >> {lg}\"\n",
"event = \"uninstall\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\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",
"skill:greet",
"--accept-hooks=install,update,uninstall",
]);
assert!(
learn.success,
"learn should run both install hooks: {} {}",
learn.stdout, learn.stderr
);
assert_eq!(read_log(&log), vec!["i1", "i2"], "install hooks in order");
let forget = sb.mind(&[
"forget",
"skill:greet",
"--accept-hooks=install,update,uninstall",
]);
assert!(
forget.success,
"forget should run both uninstall hooks: {} {}",
forget.stdout, forget.stderr
);
assert_eq!(
read_log(&log),
vec!["i1", "i2", "u1", "u2"],
"uninstall hooks run in declaration order after install"
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"item removed after its uninstall hooks"
);
}
#[test]
fn scalar_install_uninstall_still_work_as_one_required_hook_each() {
let sb = Sandbox::new("scal");
let log = sb.base.join("scalar.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"install = \"echo SCALAR-INSTALL >> {lg}\"\n",
"uninstall = \"echo SCALAR-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert_eq!(read_log(&log), vec!["SCALAR-INSTALL"], "scalar install ran");
assert!(
sb.mind(&[
"forget",
"skill:greet",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert_eq!(
read_log(&log),
vec!["SCALAR-INSTALL", "SCALAR-UNINSTALL"],
"scalar uninstall ran on forget"
);
}
#[test]
fn unmeld_runs_item_uninstall_hooks_before_source_uninstall_hooks() {
let sb = Sandbox::new("nest");
let log = sb.base.join("teardown.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"echo ITEM-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall"
])
.success,
"item must install before unmeld"
);
assert!(read_log(&log).is_empty(), "no teardown hooks at meld/learn");
let unmeld = sb.mind(&[
"unmeld",
"nest",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(
unmeld.success,
"unmeld should succeed: {} {}",
unmeld.stdout, unmeld.stderr
);
assert_eq!(
read_log(&log),
vec!["ITEM-UNINSTALL", "SOURCE-UNINSTALL"],
"item uninstall hook must fire before the source uninstall hook"
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
!sources.contains("nest"),
"source must be removed after unmeld: {sources}"
);
}
#[test]
fn unmeld_non_tty_skips_hooks_but_still_removes_source_and_items() {
let sb = Sandbox::new("ntty");
let log = sb.base.join("skip.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"echo ITEM-UNINSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item installed before unmeld"
);
let unmeld = sb.mind(&["unmeld", "ntty", "--yes"]);
assert!(
unmeld.success,
"non-TTY unmeld still succeeds: {} {}",
unmeld.stdout, unmeld.stderr
);
assert!(
read_log(&log).is_empty(),
"non-TTY must skip both item and source uninstall hooks: {:?}",
read_log(&log)
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"item must still be removed even though its uninstall hook was skipped"
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
!sources.contains("ntty"),
"source must still be removed: {sources}"
);
}
#[test]
fn unmeld_yes_runs_item_uninstall_hooks_for_multiple_items_before_source() {
let sb = Sandbox::new("multi");
let log = sb.base.join("multi.log");
let lg = log.display();
write(
&sb.source.join("skills/alpha/SKILL.md"),
"---\ndescription: alpha\n---\n# alpha\n",
);
write(
&sb.source.join("skills/beta/SKILL.md"),
"---\ndescription: beta\n---\n# beta\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"alpha\"\n",
"path = \"skills/alpha\"\n",
"uninstall = \"echo ITEM-ALPHA >> {lg}\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"beta\"\n",
"path = \"skills/beta\"\n",
"uninstall = \"echo ITEM-BETA >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:alpha",
"--accept-hooks=install,update,uninstall"
])
.success
);
assert!(
sb.mind(&[
"learn",
"skill:beta",
"--accept-hooks=install,update,uninstall"
])
.success
);
let unmeld = sb.mind(&[
"unmeld",
"multi",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(
unmeld.success,
"multi-item unmeld --yes should succeed: {} {}",
unmeld.stdout, unmeld.stderr
);
let lines = read_log(&log);
let src_pos = lines
.iter()
.position(|l| l == "SOURCE-UNINSTALL")
.expect("source uninstall hook must run");
let alpha_pos = lines
.iter()
.position(|l| l == "ITEM-ALPHA")
.expect("alpha uninstall hook must run");
let beta_pos = lines
.iter()
.position(|l| l == "ITEM-BETA")
.expect("beta uninstall hook must run");
assert!(
alpha_pos < src_pos && beta_pos < src_pos,
"both item uninstall hooks must precede the source uninstall hook: {lines:?}"
);
assert!(
!sb.mind_home.join("store/skill/alpha").exists()
&& !sb.mind_home.join("store/skill/beta").exists(),
"both items removed after unmeld"
);
}
#[test]
fn meld_runs_source_install_hook_before_item_install_hooks() {
let sb = Sandbox::new("instord");
let log = sb.base.join("install_order.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-INSTALL >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"install = \"echo ITEM-INSTALL >> {lg}\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
let meld = sb.mind(&[
"meld",
&spec,
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(
meld.success,
"meld --yes --accept-hooks should succeed: {} {}",
meld.stdout, meld.stderr
);
let lines = read_log(&log);
assert!(
lines.contains(&"SOURCE-INSTALL".to_string()),
"source install hook must run: {lines:?}"
);
assert!(
lines.contains(&"ITEM-INSTALL".to_string()),
"item install hook must run: {lines:?}"
);
let src_pos = lines
.iter()
.position(|l| l == "SOURCE-INSTALL")
.expect("SOURCE-INSTALL must appear in the log");
let item_pos = lines
.iter()
.position(|l| l == "ITEM-INSTALL")
.expect("ITEM-INSTALL must appear in the log");
assert!(
src_pos < item_pos,
"source install hook (pos {src_pos}) must precede item install hook (pos {item_pos}): {lines:?}"
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item must be in the store after a successful meld --yes"
);
}
#[test]
fn unmeld_item_uninstall_hook_failure_leaves_source_melded() {
let sb = Sandbox::new("failun");
let log = sb.base.join("fail.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[hooks]]\n",
"run = \"echo SOURCE-UNINSTALL >> {lg}\"\n",
"event = \"uninstall\"\n",
"\n",
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"exit 7\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall"
])
.success
);
let unmeld = sb.mind(&[
"unmeld",
"failun",
"--yes",
"--accept-hooks=install,update,uninstall",
]);
assert!(
!unmeld.success,
"an item uninstall-hook failure must fail the unmeld: {} {}",
unmeld.stdout, unmeld.stderr
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item must remain installed after its uninstall hook failed"
);
assert!(
!read_log(&log).contains(&"SOURCE-UNINSTALL".to_string()),
"source uninstall hook must NOT run when an item uninstall hook failed: {:?}",
read_log(&log)
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
sources.contains("failun"),
"source must remain melded after a failed item uninstall hook: {sources}"
);
let combined = format!("{}{}", unmeld.stdout, unmeld.stderr);
assert!(
combined.contains("greet"),
"the failure output must reference the failing item 'greet': {combined}"
);
assert!(
combined.contains("exit 7")
|| combined.contains("HookFailed")
|| combined.contains("failed"),
"the failure output must surface the hook exit code or error: {combined}"
);
}
#[test]
fn item_install_hook_failure_rolls_back_the_item_install() {
let sb = Sandbox::new("failin");
let log = sb.base.join("failin.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo FIRST-RAN >> {lg}\"\n",
"event = \"install\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"exit 3\"\n",
"event = \"install\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\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",
"skill:greet",
"--accept-hooks=install,update,uninstall",
]);
assert!(
!learn.success,
"a failing item install hook must fail the learn: {} {}",
learn.stdout, learn.stderr
);
assert_eq!(
read_log(&log),
vec!["FIRST-RAN"],
"install hooks run in order; the first ran before the second failed"
);
assert!(
!sb.mind_home.join("store/skill/greet").exists(),
"the failed item install must be rolled back (store copy removed)"
);
let manifest = std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap_or_default();
assert!(
!manifest.contains("skill:greet"),
"the rolled-back item must not be recorded in the manifest: {manifest}"
);
}
#[test]
fn optional_item_hook_parses_and_runs_two_way_without_abort() {
let sb = Sandbox::new("opt");
let log = sb.base.join("opt.log");
let lg = log.display();
write(
&sb.source.join("skills/greet/SKILL.md"),
"---\ndescription: greet\n---\n# greet\n",
);
let toml = format!(
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"\n",
"[[items.hooks]]\n",
"run = \"echo OPT-RAN >> {lg}\"\n",
"optional = true\n",
"event = \"install\"\n",
),
lg = lg,
);
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit("mind.toml", &toml);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
let learn_skip = sb.mind(&["learn", "skill:greet"]);
assert!(
learn_skip.success,
"non-TTY install with an optional hook still installs the item: {} {}",
learn_skip.stdout, learn_skip.stderr
);
assert!(
sb.mind_home.join("store/skill/greet").exists(),
"item installs even though its optional hook was skipped"
);
assert!(
read_log(&log).is_empty(),
"non-TTY skip: the optional hook must not have run: {:?}",
read_log(&log)
);
assert!(sb.mind(&["forget", "skill:greet"]).success);
let learn_run = sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall",
]);
assert!(
learn_run.success,
"optional hook runs unattended under the dangerous flag: {} {}",
learn_run.stdout, learn_run.stderr
);
assert_eq!(
read_log(&log),
vec!["OPT-RAN"],
"the optional install hook ran unattended"
);
}
fn init_fixture(name: &str, prefix: Option<&str>) -> Sandbox {
let sb = Sandbox::new(name);
write(
&sb.source.join("agents/dev.md"),
"---\ndescription: dev agent\n---\n# dev\nHand off to review when done.\n",
);
write(
&sb.source.join("agents/review.md"),
"---\ndescription: review agent\n---\n# review\n",
);
if let Some(p) = prefix {
write(
&sb.source.join("mind.toml"),
&format!("[source]\nprefix = \"{p}\"\n"),
);
}
sb
}
#[test]
fn init_source_without_prefix_emits_no_unguarded_reference_advisory() {
let sb = init_fixture("noprefix", None);
let r = sb.mind_cwd(&["init-source", "."], &sb.source);
assert!(
r.success,
"init-source should succeed: {} {}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
!combined.contains("unguarded-reference"),
"no prefix => no unguarded-reference advisory: {combined}"
);
}
#[test]
fn init_source_with_prefix_emits_the_unguarded_reference_advisory() {
let sb = init_fixture("prefixed", Some("jk"));
let r = sb.mind_cwd(&["init-source", "."], &sb.source);
assert!(
r.success,
"init-source should succeed: {} {}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("unguarded-reference"),
"a prefix in force must flag the bare reference: {combined}"
);
let advisory_line = combined.lines().find(|l| l.contains("unguarded-reference"));
assert!(
advisory_line.is_some(),
"must have a line containing 'unguarded-reference': {combined}"
);
let advisory_line = advisory_line.unwrap();
assert!(
advisory_line.contains("review"),
"the unguarded-reference advisory line must name the sibling 'review': {advisory_line}"
);
assert!(
advisory_line.contains("dev"),
"the unguarded-reference advisory line must name the referencing item 'dev': {advisory_line}"
);
}
#[test]
fn init_source_does_not_flag_a_sibling_mention_inside_a_code_span() {
let sb = init_fixture("codespan", Some("jk"));
write(
&sb.source.join("agents/dev.md"),
"---\ndescription: dev agent\n---\n# dev\nRun `review` locally first.\n",
);
let r = sb.mind_cwd(&["init-source", "."], &sb.source);
assert!(
r.success,
"init-source should succeed: {} {}",
r.stdout, r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
!combined.contains("references sibling(s) in prose: review"),
"a code-span mention is not a reference and must not be flagged: {combined}"
);
}
#[test]
fn learn_url_pin_on_already_melded_link_prints_ignored_note() {
let sb = Sandbox::new("pin-note-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let url = format!("file://{}/tree/main/skills/review", sb.source_spec());
let first = sb.mind(&["learn", &url]);
assert!(
first.success,
"first `learn <url>` failed: {} {}",
first.stdout, first.stderr
);
assert!(
sb.claude_home.join("skills/review").exists(),
"the linked skill must install on first learn"
);
let second = sb.mind(&["learn", &url, "--pin"]);
assert!(
second.success,
"second `learn <url> --pin` must still exit 0: {} {}",
second.stdout, second.stderr
);
let combined = format!("{}{}", second.stdout, second.stderr);
assert!(
combined.contains("--pin") && combined.contains("already melded"),
"a second `learn <url> --pin` must note that --pin was ignored because \
the instance is already melded: {combined}"
);
}
#[test]
fn learn_url_pin_note_suppressed_under_json() {
let sb = Sandbox::new("pin-note-json-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let url = format!("file://{}/tree/main/skills/review", sb.source_spec());
let first = sb.mind(&["learn", &url]);
assert!(
first.success,
"first learn failed: {} {}",
first.stdout, first.stderr
);
let second = sb.mind(&["--json", "learn", &url, "--pin"]);
assert!(
second.success,
"second `--json learn <url> --pin` must exit 0: {} {}",
second.stdout, second.stderr
);
assert!(
!second.stdout.contains("already melded"),
"the --pin-ignored note must be suppressed under --json (stdout): {}",
second.stdout
);
}
#[test]
fn learn_url_pin_first_learn_never_prints_ignored_note() {
let sb = Sandbox::new("pin-note-first-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let url = format!("file://{}/tree/main/skills/review", sb.source_spec());
let first = sb.mind(&["learn", &url, "--pin"]);
assert!(
first.success,
"first `learn <url> --pin` failed: {} {}",
first.stdout, first.stderr
);
let combined = format!("{}{}", first.stdout, first.stderr);
assert!(
!combined.contains("already melded"),
"a first-ever `learn <url> --pin` must not claim --pin was ignored \
(it was honored at registration): {combined}"
);
assert!(
sb.claude_home.join("skills/review").exists(),
"the linked skill must still install on a pinned first learn"
);
}
#[test]
fn learn_url_pin_after_alias_only_meld_registers_coexisting_bare_instance() {
let sb = Sandbox::new("alias-coexist-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let url = format!("file://{}/tree/main/skills/review", sb.source_spec());
let aliased = sb.mind(&["meld", &url, "--as", "myalias", "--yes"]);
assert!(
aliased.success,
"aliased meld failed: {} {}",
aliased.stdout, aliased.stderr
);
assert!(
sb.claude_home.join("skills/myalias:review").exists(),
"the aliased instance must install its item under the alias prefix"
);
let learn = sb.mind(&["learn", &url, "--pin"]);
assert!(
learn.success,
"`learn <url> --pin` failed: {} {}",
learn.stdout, learn.stderr
);
let combined = format!("{}{}", learn.stdout, learn.stderr);
assert!(
!combined.contains("already melded"),
"registering a new coexisting bare instance must NOT print the \
CLI-203 already-melded note (--pin was honored on the new \
registration): {combined}"
);
let sources = sb.mind(&["recall", "--sources"]).stdout;
assert!(
sources.contains("#skills/review@myalias"),
"the aliased instance must remain registered: {sources}"
);
assert!(
sources.contains("#skills/review")
&& !sources
.lines()
.filter(|l| l.contains("#skills/review") && !l.contains("@myalias"))
.collect::<Vec<_>>()
.is_empty(),
"a distinct, coexisting bare instance must also be registered: {sources}"
);
assert!(
sb.claude_home.join("skills/myalias:review").exists(),
"the aliased instance's item must remain installed and untouched"
);
assert!(
sb.claude_home.join("skills/review").exists(),
"the new bare instance's item must be installed"
);
let sources_json = |sb: &Sandbox| -> serde_json::Value {
let r = sb.mind(&["recall", "--sources", "--json"]);
assert!(r.success, "recall --sources --json failed: {}", r.stderr);
serde_json::from_str(&r.stdout).expect("recall --sources --json must be valid JSON")
};
let find_commit = |env: &serde_json::Value, name_contains: &str, exclude: &str| -> String {
env["items"]
.as_array()
.expect("items array")
.iter()
.find(|s| {
let n = s["name"].as_str().unwrap_or("");
n.contains(name_contains) && (exclude.is_empty() || !n.contains(exclude))
})
.unwrap_or_else(|| panic!("no source with name containing {name_contains:?}"))["commit"]
.as_str()
.expect("commit must be a string")
.to_string()
};
let before = sources_json(&sb);
let bare_commit_before = find_commit(&before, "#skills/review", "@myalias");
let aliased_commit_before = find_commit(&before, "#skills/review@myalias", "");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill v2\n---\n# review\n",
);
let sync = sb.mind(&["sync"]);
assert!(sync.success, "sync failed: {} {}", sync.stdout, sync.stderr);
let after = sources_json(&sb);
let bare_commit_after = find_commit(&after, "#skills/review", "@myalias");
let aliased_commit_after = find_commit(&after, "#skills/review@myalias", "");
assert_eq!(
bare_commit_before, bare_commit_after,
"the frozen (--pin) bare instance's commit must not move on sync, \
regardless of the aliased instance's own sync"
);
assert_ne!(
aliased_commit_before, aliased_commit_after,
"the unpinned aliased instance must still advance to the new commit \
on sync, unaffected by the other instance's pin"
);
}
#[test]
fn meld_fork_of_already_melded_repo_prints_new_instance_note() {
let sb = Sandbox::new("fork-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let spec = sb.source_spec();
let base = sb.mind(&["meld", &spec, "--link-only"]);
assert!(
base.success,
"bare meld failed: {} {}",
base.stdout, base.stderr
);
assert!(
!base.stdout.contains("registered a new instance"),
"the first (bare) meld must NOT print the fork note: {}",
base.stdout
);
let fork = sb.mind(&["meld", &spec, "--as", "fork", "--link-only"]);
assert!(
fork.success,
"fork meld --as failed: {} {}",
fork.stdout, fork.stderr
);
assert!(
fork.stdout.contains("registered a new instance") && fork.stdout.contains("remains"),
"forking a second aliased instance must print the new-instance note: {}",
fork.stdout
);
}
#[test]
fn meld_fork_note_suppressed_under_json() {
let sb = Sandbox::new("fork-json-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let spec = sb.source_spec();
let base = sb.mind(&["meld", &spec, "--link-only"]);
assert!(
base.success,
"bare meld failed: {} {}",
base.stdout, base.stderr
);
let fork = sb.mind(&["--json", "meld", &spec, "--as", "fork", "--link-only"]);
assert!(
fork.success,
"fork meld --json failed: {} {}",
fork.stdout, fork.stderr
);
assert!(
!fork.stdout.contains("registered a new instance"),
"the fork note must be suppressed under --json: {}",
fork.stdout
);
}
#[test]
fn meld_fork_third_instance_also_prints_new_instance_note() {
let sb = Sandbox::new("fork-third-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
let spec = sb.source_spec();
let base = sb.mind(&["meld", &spec, "--link-only"]);
assert!(
base.success,
"bare meld failed: {} {}",
base.stdout, base.stderr
);
let fork1 = sb.mind(&["meld", &spec, "--as", "fork-one", "--link-only"]);
assert!(
fork1.success,
"first fork meld failed: {} {}",
fork1.stdout, fork1.stderr
);
assert!(
fork1.stdout.contains("registered a new instance"),
"the second instance overall (first fork) must print the note: {}",
fork1.stdout
);
let fork2 = sb.mind(&["meld", &spec, "--as", "fork-two", "--link-only"]);
assert!(
fork2.success,
"second fork meld failed: {} {}",
fork2.stdout, fork2.stderr
);
assert!(
fork2.stdout.contains("registered a new instance") && fork2.stdout.contains("remain"),
"a THIRD instance (second fork, with two prior instances already \
registered) must also print the note: {}",
fork2.stdout
);
assert!(
fork2.stdout.contains("fork-one"),
"the note must name the actual registered instances, which are the \
handles `unmeld` accepts: {}",
fork2.stdout
);
}
#[test]
fn learn_url_second_item_link_instance_without_alias_does_not_print_fork_note() {
let sb = Sandbox::new("link-no-alias-src");
sb.write_and_commit(
"skills/review/SKILL.md",
"---\nname: review\ndescription: Review skill\n---\n# review\n",
);
sb.write_and_commit(
"skills/dev/SKILL.md",
"---\nname: dev\ndescription: Dev skill\n---\n# dev\n",
);
let url_a = format!("file://{}/tree/main/skills/review", sb.source_spec());
let url_b = format!("file://{}/tree/main/skills/dev", sb.source_spec());
let first = sb.mind(&["learn", &url_a]);
assert!(
first.success,
"first item-link learn failed: {} {}",
first.stdout, first.stderr
);
assert!(
!first.stdout.contains("registered a new instance"),
"the first item-link instance must not print the fork note: {}",
first.stdout
);
let second = sb.mind(&["learn", &url_b]);
assert!(
second.success,
"second item-link learn failed: {} {}",
second.stdout, second.stderr
);
assert!(
!second.stdout.contains("registered a new instance"),
"a second un-aliased item-link instance of the same repo must NOT \
print the fork note (it has no @alias): {}",
second.stdout
);
}
#[test]
fn forget_double_failure_reports_hook_error_not_the_save_error() {
use std::os::unix::fs::PermissionsExt;
let sb = Sandbox::new("dblfail");
sb.write_and_commit(
"skills/greet/SKILL.md",
"---\ndescription: greet\n---\n# greet\n",
);
sb.write_and_commit(
"mind.toml",
concat!(
"[[items]]\n",
"kind = \"skill\"\n",
"name = \"greet\"\n",
"path = \"skills/greet\"\n",
"uninstall = \"exit 3\"\n",
),
);
let spec = sb.source_spec();
assert!(sb.mind(&["meld", &spec, "--link-only"]).success);
assert!(
sb.mind(&[
"learn",
"skill:greet",
"--accept-hooks=install,update,uninstall",
])
.success
);
std::fs::set_permissions(&sb.mind_home, std::fs::Permissions::from_mode(0o555)).unwrap();
let r = sb.mind(&[
"forget",
"skill:greet",
"--accept-hooks=install,update,uninstall",
]);
std::fs::set_permissions(&sb.mind_home, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!r.success, "forget must fail: {} {}", r.stdout, r.stderr);
assert!(
r.stderr.contains("hook"),
"stderr must carry the hook failure as the root error: {}",
r.stderr
);
assert!(
!r.stderr.contains(".tmp."),
"the manifest temp-write Io error must not be the propagated error: {}",
r.stderr
);
let combined = format!("{}{}", r.stdout, r.stderr);
assert!(
combined.contains("also failed to persist the manifest"),
"the save failure must surface as the persist warning: {combined}"
);
assert!(
combined.contains("introspect --fix"),
"the warning must name the remedy: {combined}"
);
}