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() -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-lobes-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
let sb = Sandbox {
base: base.clone(),
source: source.clone(),
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
write(
&source.join("skills/review/SKILL.md"),
"---\nname: review\ndescription: Review the diff for bugs\n---\n# review skill\n",
);
write(
&source.join("rules/style.md"),
"---\ndescription: ASCII only\n---\n# style rule\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"]);
std::fs::create_dir_all(&sb.mind_home).unwrap();
sb
}
fn mind(&self, args: &[&str]) -> Run {
self.run(args, &[])
}
fn mind_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Run {
self.run(args, envs)
}
fn run(&self, args: &[&str], envs: &[(&str, &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)
.env_remove("MIND_AGENT_HOMES")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in envs {
cmd.env(k, v);
}
let out = cmd.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 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(repo: &Path, args: &[&str]) {
std::fs::create_dir_all(repo).unwrap();
let status = Command::new("git")
.args(args)
.current_dir(repo)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
fn parse_json(stdout: &str) -> serde_json::Value {
serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("not JSON: {e}\n{stdout}"))
}
#[test]
fn preset_add_records_path_and_kinds() {
let sb = Sandbox::new();
let added = sb.mind(&["config", "lobes", "add", "--preset", "gemini"]);
assert!(added.success, "preset add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
let gemini = lobes
.iter()
.find(|l| {
l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
})
.expect("a .gemini/config lobe entry");
let kinds: Vec<&str> = gemini["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["skill"], "gemini is skill-only");
let bad = sb.mind(&["config", "lobes", "add", "--preset", "emacs"]);
assert!(!bad.success, "unknown preset must fail");
assert!(bad.stderr.contains("preset"), "{}", bad.stderr);
let ag = sb.mind(&["config", "lobes", "add", "--preset", "antigravity"]);
assert!(!ag.success, "removed antigravity preset must fail");
let agcli = sb.mind(&["config", "lobes", "add", "--preset", "antigravity-cli"]);
assert!(!agcli.success, "removed antigravity-cli preset must fail");
let both = sb.mind(&["config", "lobes", "add", "/tmp/x", "--preset", "gemini"]);
assert!(!both.success, "path + --preset must conflict");
}
#[test]
fn kinds_filter_excludes_rule_from_skill_only_lobe() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("gemini-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"skill must link into the Claude lobe"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_ok(),
"skill must link into the skill-only lobe"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style")).is_ok()
|| std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_ok(),
"rule must link into the Claude lobe"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("rules/style.md")).is_err(),
"rule must NOT link into a skill-only lobe (HARN-3)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let rule_links: Vec<&str> = manifest["items"]["rule:style"]["links"]
.as_array()
.expect("rule links array")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!rule_links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"rule manifest links must omit the skill-only lobe: {rule_links:?}"
);
assert!(
rule_links
.iter()
.any(|l| l.starts_with(&sb.claude_home.display().to_string())),
"rule manifest links must include the Claude lobe: {rule_links:?}"
);
let linked = std::fs::read_to_string(skill_lobe.join("skills/review/SKILL.md")).unwrap();
let original = std::fs::read_to_string(sb.source.join("skills/review/SKILL.md")).unwrap();
assert_eq!(
linked, original,
"mind links skill/agent files verbatim; no frontmatter rewrite (HARN-6)"
);
}
#[test]
fn list_and_show_display_kinds() {
let sb = Sandbox::new();
assert!(
sb.mind(&["config", "lobes", "add", "--preset", "gemini"])
.success
);
let list = sb.mind(&["config", "lobes", "list"]).stdout;
assert!(
list.contains(".gemini/config") && list.contains("skill"),
"list must show the kinds filter: {list}"
);
let show = sb.mind(&["config", "show"]).stdout;
assert!(
show.contains(".gemini/config") && show.contains("skill"),
"show must surface the kinds filter: {show}"
);
}
#[test]
fn detect_reports_then_yes_adds() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let report = sb.mind_env(
&["config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(report.success, "detect failed: {}", report.stderr);
assert!(
report.stdout.contains("gemini"),
"detect must report gemini: {}",
report.stdout
);
let after_report = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&after_report.stdout);
assert!(
!v["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))),
"report-only detect must NOT add the lobe: {}",
after_report.stdout
);
let json = sb.mind_env(
&["config", "lobes", "detect", "--json"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
assert_eq!(jv["action"], "lobe-detect", "{}", json.stdout);
assert_eq!(
jv["added"], false,
"json report must not mutate: {}",
json.stdout
);
assert!(
jv["detected"]
.as_array()
.unwrap()
.iter()
.any(|d| d["preset"] == "gemini"),
"detected must list gemini: {}",
json.stdout
);
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
let after_add = sb.mind(&["config", "lobes", "list", "--json"]);
let av = parse_json(&after_add.stdout);
let gemini = av["lobes"]
.as_array()
.unwrap()
.iter()
.find(|l| {
l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
})
.expect("detect --yes must add the gemini/config lobe");
assert_eq!(
gemini["path"].as_str().unwrap(),
detect_home.join(".gemini/config").display().to_string(),
"added lobe path must be under the detection base"
);
}
#[test]
fn detect_yes_text_output_reports_and_persists() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
assert!(
added.stdout.contains("gemini") && added.stdout.contains("added"),
"text --yes must report the added gemini lobe: {}",
added.stdout
);
let list = sb.mind(&["config", "lobes", "list"]).stdout;
assert!(
list.contains(".gemini/config"),
"detect --yes must persist the lobe: {list}"
);
}
#[test]
fn detect_no_homes_reports_nothing_and_mutates_nothing() {
let sb = Sandbox::new();
let detect_home = sb.base.join("empty-detect");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let text = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(text.success, "detect failed: {}", text.stderr);
assert!(
text.stdout.contains("no new harness homes"),
"empty detection must report none found: {}",
text.stdout
);
let json = sb.mind_env(
&["config", "lobes", "detect", "--json", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
assert_eq!(jv["action"], "lobe-detect");
assert_eq!(
jv["added"], false,
"no candidates => added=false: {}",
json.stdout
);
assert!(
jv["detected"].as_array().unwrap().is_empty(),
"detected must be empty: {}",
json.stdout
);
let list = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&list.stdout);
assert!(
!lv["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config") || p.ends_with(".agents"))),
"empty detection must not have added any harness lobe: {}",
list.stdout
);
}
#[test]
fn detect_dedups_codex_and_universal_same_path() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-dup");
std::fs::create_dir_all(detect_home.join(".codex")).unwrap();
std::fs::create_dir_all(detect_home.join(".agents")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let json = sb.mind_env(
&["config", "lobes", "detect", "--json"],
&[("MIND_DETECT_HOME", &detect_str)],
);
let jv = parse_json(&json.stdout);
let agents_path = detect_home.join(".agents").display().to_string();
let agents_entries: Vec<&serde_json::Value> = jv["detected"]
.as_array()
.unwrap()
.iter()
.filter(|d| d["path"].as_str() == Some(agents_path.as_str()))
.collect();
assert_eq!(
agents_entries.len(),
1,
"codex+universal must collapse to ONE ~/.agents candidate: {}",
json.stdout
);
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "{}", added.stderr);
let list = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&list.stdout);
let agents_lobes: Vec<&serde_json::Value> = lv["lobes"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["path"].as_str() == Some(agents_path.as_str()))
.collect();
assert_eq!(
agents_lobes.len(),
1,
"only one ~/.agents lobe must be persisted: {}",
list.stdout
);
}
#[test]
fn preset_add_codex() {
let sb = Sandbox::new();
let added = sb.mind(&["config", "lobes", "add", "--preset", "codex"]);
assert!(added.success, "codex add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let entry = v["lobes"]
.as_array()
.expect("lobes array")
.iter()
.find(|l| l["path"].as_str().is_some_and(|p| p.ends_with(".agents")))
.unwrap_or_else(|| panic!("a .agents lobe entry for codex: {}", listed.stdout));
let kinds: Vec<&str> = entry["kinds"]
.as_array()
.expect("kinds array")
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["skill"], "codex kinds");
}
#[test]
fn kinds_filtered_lobe_lifecycle_forget_and_introspect() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("skill-only-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
let intro = sb.mind(&["introspect", "--json"]);
assert!(intro.success, "introspect failed: {}", intro.stderr);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.is_empty(),
"a kinds-filtered lobe must produce no drift/missing-link issues: {}",
intro.stdout
);
let forget_rule = sb.mind(&["forget", "style", "--yes"]);
assert!(
forget_rule.success,
"forget of a rule in a kinds-filtered setup must succeed: {}",
forget_rule.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_err(),
"the recorded Claude rule link must be removed by forget"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok()
&& std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_ok(),
"the skill must remain linked in both lobes after forgetting the rule"
);
let forget_skill = sb.mind(&["forget", "review", "--yes"]);
assert!(forget_skill.success, "{}", forget_skill.stderr);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_err()
&& std::fs::symlink_metadata(skill_lobe.join("skills/review")).is_err(),
"forget must remove the skill from every recorded lobe"
);
let intro2 = sb.mind(&["introspect", "--json"]);
let iv2 = parse_json(&intro2.stdout);
assert!(
iv2["issues"].as_array().unwrap().is_empty(),
"introspect must stay clean after forget: {}",
intro2.stdout
);
}
#[test]
fn upgrade_respects_kinds_filter() {
let sb = Sandbox::new();
let skill_lobe = sb.base.join("skill-only-lobe");
sb.write_config(&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = sb.claude_home.display(),
skill = skill_lobe.display(),
));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
write(
&sb.source.join("rules/style.md"),
"---\ndescription: ASCII only\n---\n# style rule v2\n",
);
git(&sb.source, &["commit", "-aqm", "bump rule"]);
assert!(sb.mind(&["sync"]).success, "sync failed");
let up = sb.mind(&["upgrade", "--yes"]);
assert!(
up.success,
"upgrade of a kinds-filtered rule must succeed: {}",
up.stderr
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("rules/style.md")).is_ok(),
"rule must remain linked in the Claude lobe after upgrade"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("rules/style.md")).is_err(),
"rule must NOT be linked into the skill-only lobe after upgrade (HARN-2)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(sb.mind_home.join("manifest.json")).unwrap())
.unwrap();
let rule_links: Vec<&str> = manifest["items"]["rule:style"]["links"]
.as_array()
.expect("rule links")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!rule_links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"rule links must still omit the skill-only lobe after upgrade: {rule_links:?}"
);
}
#[test]
fn lobes_add_without_path_or_preset_errors() {
let sb = Sandbox::new();
let run = sb.mind(&["config", "lobes", "add"]);
assert!(!run.success, "add with no target must fail");
assert!(
run.stderr.contains("path") && run.stderr.contains("--preset"),
"error must mention both a path and --preset: {}",
run.stderr
);
}
#[test]
fn preset_add_preserves_bare_entry_shape() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
let added = sb.mind(&["config", "lobes", "add", "--preset", "gemini"]);
assert!(added.success, "preset add failed: {}", added.stderr);
let raw = std::fs::read_to_string(sb.mind_home.join("config.toml")).unwrap();
let bare = format!("\"{}\"", sb.claude_home.display());
assert!(
raw.contains(&bare),
"the original bare lobe must remain a bare string after rewrite:\n{raw}"
);
assert!(
raw.contains("kinds") && raw.contains(".gemini/config"),
"the preset lobe must be a table with kinds:\n{raw}"
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().unwrap();
let claude_str = sb.claude_home.display().to_string();
assert!(
lobes
.iter()
.any(|l| l.as_str() == Some(claude_str.as_str())),
"a bare lobe must serialize as a plain JSON string (all-kinds): {}",
listed.stdout
);
assert!(
lobes.iter().any(|l| l.is_object()
&& l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))
&& l["kinds"].is_array()),
"the preset lobe must serialize as an object with a kinds array: {}",
listed.stdout
);
}
#[test]
fn remove_preset_added_detailed_lobe_by_path() {
let sb = Sandbox::new();
assert!(
sb.mind(&["config", "lobes", "add", "--preset", "gemini"])
.success
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let gemini_path = v["lobes"]
.as_array()
.unwrap()
.iter()
.find_map(|l| {
l["path"]
.as_str()
.filter(|p| p.ends_with(".gemini/config"))
.map(str::to_string)
})
.expect("gemini lobe path");
let removed = sb.mind(&["config", "lobes", "remove", &gemini_path]);
assert!(
removed.success,
"removing a detailed preset lobe by path must succeed: {}",
removed.stderr
);
let after = sb.mind(&["config", "lobes", "list", "--json"]);
let av = parse_json(&after.stdout);
assert!(
!av["lobes"].as_array().unwrap().iter().any(|l| l["path"]
.as_str()
.is_some_and(|p| p.ends_with(".gemini/config"))),
"the gemini lobe must be gone after remove: {}",
after.stdout
);
}
#[test]
fn tool_with_explicit_link_respects_kinds_filter() {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-tool-lobe-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let source = base.join("agents");
write(&source.join("toolkit/run.sh"), "#!/bin/sh\necho hi\n");
write(
&source.join("mind.toml"),
"[source]\ndescription = \"tool source\"\n\n[[items]]\nkind = \"tool\"\nname = \"toolkit\"\npath = \"toolkit\"\nlink = \"tools/toolkit\"\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"]);
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
let skill_lobe = base.join("skill-only-lobe");
let run = |args: &[&str]| -> Run {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_mind"));
cmd.args(args)
.env("MIND_HOME", &mind_home)
.env("CLAUDE_HOME", &claude_home)
.env_remove("MIND_AGENT_HOMES")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.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(),
}
};
write(
&mind_home.join("config.toml"),
&format!(
"lobes = [\"{claude}\", {{ path = \"{skill}\", kinds = [\"skill\"] }}]\n",
claude = claude_home.display(),
skill = skill_lobe.display(),
),
);
assert!(run(&["meld", &source.to_string_lossy()]).success);
let learned = run(&["learn", "toolkit"]);
assert!(learned.success, "learn tool failed: {}", learned.stderr);
assert!(
std::fs::symlink_metadata(claude_home.join("tools/toolkit")).is_ok(),
"a tool with an explicit link must link into a no-kinds lobe (TOOL-4)"
);
assert!(
std::fs::symlink_metadata(skill_lobe.join("tools/toolkit")).is_err(),
"a skill-only lobe must not receive a tool link (HARN-1)"
);
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(mind_home.join("manifest.json")).unwrap())
.unwrap();
let links: Vec<&str> = manifest["items"]["tool:toolkit"]["links"]
.as_array()
.expect("tool links")
.iter()
.map(|l| l.as_str().unwrap())
.collect();
let skill_lobe_str = skill_lobe.display().to_string();
assert!(
!links.iter().any(|l| l.starts_with(&skill_lobe_str)),
"tool links must omit the skill-only lobe: {links:?}"
);
assert!(
links
.iter()
.any(|l| l.starts_with(&claude_home.display().to_string())),
"tool links must include the no-kinds Claude lobe: {links:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn detect_short_yes_flag_is_accepted() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-empty");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let run = sb.mind_env(
&["config", "lobes", "detect", "-y"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(
run.success,
"detect -y must succeed (not error on the flag): stderr={}",
run.stderr
);
assert!(
!run.stderr.contains("unexpected argument"),
"detect -y must not produce 'unexpected argument': {}",
run.stderr
);
}
#[test]
fn detect_global_yes_pre_verb_is_accepted() {
let sb = Sandbox::new();
let detect_home = sb.base.join("detect-empty2");
std::fs::create_dir_all(&detect_home).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let run = sb.mind_env(
&["-y", "config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(
run.success,
"mind -y config lobes detect must succeed: stderr={}",
run.stderr
);
assert!(
!run.stderr.contains("unexpected argument"),
"mind -y detect must not error on the flag: {}",
run.stderr
);
let detect_home2 = sb.base.join("detect-empty3");
std::fs::create_dir_all(&detect_home2).unwrap();
let detect_str2 = detect_home2.to_string_lossy().into_owned();
let run2 = sb.mind_env(
&["--yes", "config", "lobes", "detect"],
&[("MIND_DETECT_HOME", &detect_str2)],
);
assert!(
run2.success,
"mind --yes config lobes detect must succeed: stderr={}",
run2.stderr
);
}
#[test]
fn lobe_add_backfills_installed_items_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(added.success, "lobe add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"the installed skill must be backfilled into the new lobe: {}",
added.stdout
);
}
#[test]
fn lobe_add_preset_backfills_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "gemini", "--yes"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(sb.base.join(".gemini/config/skills/review")).is_ok(),
"the installed skill must be backfilled into the gemini preset lobe: {}",
added.stdout
);
}
#[test]
fn lobe_detect_backfills_with_yes() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(detect_home.join(".gemini/config/skills/review")).is_ok(),
"the installed skill must be backfilled into the detected gemini lobe: {}",
added.stdout
);
}
#[test]
fn lobe_add_no_tty_no_yes_prints_note() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
added.stdout.contains("mind introspect --fix"),
"non-TTY add without --yes must print the introspect note: {}",
added.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"without --yes the skill must NOT be backfilled into the new lobe"
);
}
#[test]
fn introspect_reports_missing_lobe_links() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add failed"
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"introspect must report the uncovered lobe as missing-lobe-link: {}",
intro.stdout
);
}
#[test]
fn introspect_fix_creates_missing_lobe_links() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add failed"
);
let fixed = sb.mind(&["introspect", "--fix"]);
assert!(fixed.success, "introspect --fix failed: {}", fixed.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"introspect --fix must create the missing lobe link: {}",
fixed.stdout
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
!issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"after --fix no missing-lobe-link finding must remain: {}",
intro.stdout
);
}
#[test]
fn lobe_add_no_items_skips_backfill_silently() {
let sb = Sandbox::new();
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &new_lobe_str]);
assert!(added.success, "lobe add failed: {}", added.stderr);
assert!(
added.stdout.contains("added lobe"),
"add must confirm the lobe: {}",
added.stdout
);
assert!(
!added.stdout.contains("mind introspect --fix"),
"with nothing installed there is no backfill note: {}",
added.stdout
);
}
#[test]
fn harn7_json_no_yes_skips_backfill_silently() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe-json-noskip");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let run = sb.mind(&["config", "lobes", "add", "--json", &new_lobe_str]);
assert!(run.success, "lobe add --json failed: {}", run.stderr);
let v = parse_json(&run.stdout);
assert_eq!(
v["action"], "lobe-add",
"JSON must include action: {}",
run.stdout
);
assert_eq!(v["outcome"], "added", "lobe must be added: {}", run.stdout);
assert!(
!run.stdout.contains("introspect"),
"JSON mode must not emit the introspect note: {}",
run.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"without --yes the skill must NOT be backfilled in JSON mode"
);
}
#[test]
fn harn7_json_yes_backfills_silently() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("newlobe-json-yes");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let run = sb.mind(&["config", "lobes", "add", "--json", "--yes", &new_lobe_str]);
assert!(run.success, "lobe add --json --yes failed: {}", run.stderr);
let v = parse_json(&run.stdout);
assert_eq!(v["action"], "lobe-add", "{}", run.stdout);
assert_eq!(v["outcome"], "added", "{}", run.stdout);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"with --yes the skill must be backfilled in JSON mode: {}",
run.stdout
);
}
#[test]
fn harn7_backfill_preset_codex_skill_only_excludes_rule() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(sb.mind(&["learn", "style"]).success, "learn rule");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "codex", "--yes"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add --yes failed: {}", added.stderr);
assert!(
std::fs::symlink_metadata(sb.base.join(".agents/skills/review")).is_ok(),
"the skill must be backfilled into the codex (skill-only) lobe"
);
assert!(
std::fs::symlink_metadata(sb.base.join(".agents/rules/style.md")).is_err(),
"the rule must NOT be backfilled into a skill-only lobe (HARN-1/HARN-7)"
);
}
#[test]
fn harn7_no_op_lobe_add_does_not_backfill() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("existing-lobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
let first = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(first.success, "{}", first.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"first add must backfill the skill"
);
std::fs::remove_file(new_lobe.join("skills/review")).unwrap();
let second = sb.mind(&["config", "lobes", "add", "--yes", &new_lobe_str]);
assert!(second.success, "{}", second.stderr);
assert!(
second.stdout.contains("already configured"),
"must report lobe already configured: {}",
second.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"no-op add must not backfill items into an already-configured lobe"
);
}
#[test]
fn harn8_introspect_fix_clobber_reports_finding_and_continues() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("blocked-lobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add failed"
);
std::fs::create_dir_all(&new_lobe).unwrap();
std::fs::write(new_lobe.join("skills"), "blocking file").unwrap();
let run = sb.mind(&["introspect", "--fix", "--json"]);
assert!(
run.success,
"introspect --fix must not abort on a clobber conflict: {}",
run.stderr
);
let v = parse_json(&run.stdout);
let issues = v["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"a blocked link must produce a missing-lobe-link finding: {}",
run.stdout
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_err(),
"the blocked link must remain absent after --fix"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"the original claude_home skill symlink must be intact after a blocked lobe error (HARN-8)"
);
}
#[test]
fn harn8_introspect_fix_reports_exactly_one_finding_per_blocked_link() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
let new_lobe = sb.base.join("blocked-lobe-count");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add failed"
);
std::fs::create_dir_all(&new_lobe).unwrap();
std::fs::write(new_lobe.join("skills"), "blocking file").unwrap();
let run = sb.mind(&["introspect", "--fix", "--json"]);
assert!(
run.success,
"introspect --fix must not abort: {}",
run.stderr
);
let v = parse_json(&run.stdout);
let issues = v["issues"].as_array().expect("issues array");
let count = issues
.iter()
.filter(|i| i["kind"] == "missing-lobe-link")
.count();
assert_eq!(
count, 1,
"exactly one missing-lobe-link finding per blocked link, got {count}: {}",
run.stdout
);
}
#[test]
fn harn8_broken_recorded_link_fires_both_findings_without_fix() {
let sb = Sandbox::new();
sb.write_config(&format!("lobes = [\"{}\"]\n", sb.claude_home.display()));
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
std::fs::remove_file(sb.claude_home.join("skills/review")).unwrap();
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
issues.iter().any(|i| i["kind"] == "missing-link"),
"a broken recorded link must fire a missing-link finding: {}",
intro.stdout
);
assert!(
issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"a broken recorded link also fires missing-lobe-link (on-disk check, HARN-8): {}",
intro.stdout
);
let fixed = sb.mind(&["introspect", "--fix", "--json"]);
assert!(fixed.success, "{}", fixed.stderr);
let fv = parse_json(&fixed.stdout);
let fixed_issues = fv["issues"].as_array().expect("issues array");
assert!(
fixed_issues.is_empty(),
"after --fix no issues must remain for a broken recorded link: {}",
fixed.stdout
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"the symlink must be restored by --fix"
);
}
#[test]
fn introspect_fix_backfills_after_implicit_default_install() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
assert!(sb.mind(&["learn", "review"]).success, "learn skill");
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"skill must be linked into implicit claude_home"
);
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add failed"
);
let fixed = sb.mind(&["introspect", "--fix"]);
assert!(fixed.success, "introspect --fix failed: {}", fixed.stderr);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"introspect --fix must create the missing lobe link: {}",
fixed.stdout
);
let intro = sb.mind(&["introspect", "--json"]);
let iv = parse_json(&intro.stdout);
let issues = iv["issues"].as_array().expect("issues array");
assert!(
!issues.iter().any(|i| i["kind"] == "missing-lobe-link"),
"after --fix no missing-lobe-link must remain: {}",
intro.stdout
);
}
#[test]
fn first_lobe_add_preserves_claude_home() {
let sb = Sandbox::new();
assert!(sb.mind(&["meld", &sb.source_spec()]).success);
let new_lobe = sb.base.join("newlobe");
let new_lobe_str = new_lobe.to_string_lossy().into_owned();
assert!(
sb.mind(&["config", "lobes", "add", &new_lobe_str]).success,
"lobe add must succeed"
);
assert!(
sb.mind(&["learn", "review"]).success,
"learn after lobe-add failed"
);
assert!(
std::fs::symlink_metadata(sb.claude_home.join("skills/review")).is_ok(),
"new install must reach claude_home after first lobe-add"
);
assert!(
std::fs::symlink_metadata(new_lobe.join("skills/review")).is_ok(),
"new install must reach the explicitly-added lobe"
);
}
#[test]
fn lobe_add_claude_home_no_duplicate() {
let sb = Sandbox::new();
let ch = sb.claude_home.to_string_lossy().into_owned();
let added = sb.mind(&["config", "lobes", "add", &ch]);
assert!(
added.success,
"lobe add claude_home failed: {}",
added.stderr
);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let lv = parse_json(&listed.stdout);
let lobes = lv["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
1,
"adding claude_home must not create a duplicate: {}",
listed.stdout
);
}
#[test]
fn preset_add_preserves_claude_home_on_empty_lobes_config() {
let sb = Sandbox::new();
sb.write_config("lobes = []\n");
let home_str = sb.base.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "add", "--preset", "gemini"],
&[("HOME", &home_str)],
);
assert!(added.success, "preset add failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
2,
"claude_home must be auto-prepended before the gemini preset on empty config: {}",
listed.stdout
);
let ch = sb.claude_home.to_string_lossy().into_owned();
assert!(
lobes.iter().any(|l| {
l.as_str() == Some(ch.as_str()) || l["path"].as_str() == Some(ch.as_str())
}),
"claude_home must appear as first entry in the saved lobe list: {}",
listed.stdout
);
}
#[test]
fn detect_yes_preserves_claude_home_on_empty_lobes_config() {
let sb = Sandbox::new();
sb.write_config("lobes = []\n");
let detect_home = sb.base.join("detect");
std::fs::create_dir_all(detect_home.join(".gemini")).unwrap();
let detect_str = detect_home.to_string_lossy().into_owned();
let added = sb.mind_env(
&["config", "lobes", "detect", "--yes"],
&[("MIND_DETECT_HOME", &detect_str)],
);
assert!(added.success, "detect --yes failed: {}", added.stderr);
let listed = sb.mind(&["config", "lobes", "list", "--json"]);
let v = parse_json(&listed.stdout);
let lobes = v["lobes"].as_array().expect("lobes array");
assert_eq!(
lobes.len(),
2,
"claude_home must be auto-prepended by detect --yes on empty config: {}",
listed.stdout
);
let ch = sb.claude_home.to_string_lossy().into_owned();
assert!(
lobes.iter().any(|l| {
l.as_str() == Some(ch.as_str()) || l["path"].as_str() == Some(ch.as_str())
}),
"claude_home must appear in the saved lobe list after detect: {}",
listed.stdout
);
}