use cli::install::{run_install_with, run_uninstall, BinaryLocation, InstallOpts, Platform};
use std::fs;
use std::path::{Path, PathBuf};
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let d = std::env::temp_dir().join(format!(
"mushroomdb-install-test-{}-{}-{}",
label,
std::process::id(),
nanos
));
fs::create_dir_all(&d).unwrap();
d
}
fn read(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("missing {rel}: {e}"))
}
fn assert_absent(root: &Path, rel: &str) {
assert!(
!root.join(rel).exists(),
"expected {rel} to be absent but it exists"
);
}
fn install_on_path(root: &Path, home: &Path, opts: &InstallOpts) -> Result<String, cli::CliError> {
run_install_with(root, home, opts, &BinaryLocation::OnPath)
}
fn fake_exe(dir: &Path, bytes: &[u8]) -> PathBuf {
let p = dir.join("fake-mushroomdb");
fs::write(&p, bytes).unwrap();
p
}
fn claude_project_opts(db: &Path) -> InstallOpts {
InstallOpts {
platform: Some(Platform::ClaudeCode),
project: true,
db: Some(db.to_path_buf()),
}
}
fn cursor_project_opts(db: &Path) -> InstallOpts {
InstallOpts {
platform: Some(Platform::Cursor),
project: true,
db: Some(db.to_path_buf()),
}
}
fn all_project_opts(db: &Path) -> InstallOpts {
InstallOpts {
platform: Some(Platform::All),
project: true,
db: Some(db.to_path_buf()),
}
}
#[test]
fn claude_project_writes_skill_and_mcp() {
let root = temp_dir("cc-proj");
let home = temp_dir("cc-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install failed");
let skill = read(&root, ".claude/skills/mushroom/SKILL.md");
assert!(
skill.contains(db.to_str().unwrap()),
"SKILL.md does not contain db path"
);
let mcp_raw = read(&root, ".mcp.json");
let mcp: serde_json::Value = serde_json::from_str(&mcp_raw).expect("invalid mcp json");
let server = &mcp["mcpServers"]["mushroomdb"];
assert_eq!(server["command"], "mushroomdb");
assert_eq!(server["args"][0], "mcp");
assert_eq!(server["args"][1], db.to_str().unwrap());
let manifest_raw = read(&root, ".claude/skills/mushroom/.install-manifest.json");
let manifest: serde_json::Value =
serde_json::from_str(&manifest_raw).expect("invalid manifest json");
assert!(manifest["files"].is_array());
assert_absent(&home, ".claude/skills/mushroom/SKILL.md");
}
#[test]
fn install_is_idempotent() {
let root = temp_dir("idempotent");
let home = temp_dir("idempotent-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("first install failed");
let mcp_after_first = read(&root, ".mcp.json");
install_on_path(&root, &home, &opts).expect("second install should be a no-op (exit 0)");
let mcp_after_second = read(&root, ".mcp.json");
assert_eq!(mcp_after_first, mcp_after_second);
let mcp: serde_json::Value = serde_json::from_str(&mcp_after_second).unwrap();
let servers = mcp["mcpServers"].as_object().unwrap();
assert_eq!(
servers
.keys()
.filter(|k| k.as_str() == "mushroomdb")
.count(),
1,
"mushroomdb key duplicated in mcp.json"
);
}
#[test]
fn double_install_then_uninstall_cleans_up() {
let root = temp_dir("double-uninstall");
let home = temp_dir("double-uninstall-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("first install");
install_on_path(&root, &home, &opts).expect("second install (no-op)");
run_uninstall(&root, &home, &opts).expect("uninstall after double-install");
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
assert_absent(&root, ".claude/skills/mushroom/.install-manifest.json");
if root.join(".mcp.json").exists() {
let mcp: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
assert!(
mcp["mcpServers"]["mushroomdb"].is_null(),
"mushroomdb key still in .mcp.json after double-install → uninstall"
);
}
}
#[test]
fn partial_drift_reinstall_then_uninstall_cleans_all() {
let root = temp_dir("partial-drift");
let home = temp_dir("partial-drift-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("first install");
let skill_path = root.join(".claude/skills/mushroom/SKILL.md");
fs::write(&skill_path, "user edited this").unwrap();
install_on_path(&root, &home, &opts).expect("re-install after drift");
assert!(
skill_path.exists(),
"SKILL.md should be restored after re-install"
);
let skill_content = fs::read_to_string(&skill_path).unwrap();
assert!(
skill_content.contains(db.to_str().unwrap()),
"restored SKILL.md should contain the db path"
);
run_uninstall(&root, &home, &opts).expect("uninstall after partial drift");
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
assert_absent(&root, ".claude/skills/mushroom/.install-manifest.json");
if root.join(".mcp.json").exists() {
let mcp: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
assert!(
mcp["mcpServers"]["mushroomdb"].is_null(),
"mushroomdb mcp key orphaned after partial-drift uninstall"
);
}
}
#[test]
fn install_refuses_conflicting_mcp_entry() {
let root = temp_dir("conflict");
let home = temp_dir("conflict-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
let existing_mcp = serde_json::json!({
"mcpServers": {
"mushroomdb": {
"command": "mushroomdb",
"args": ["mcp", "/some/other/db"]
}
}
});
fs::write(
root.join(".mcp.json"),
serde_json::to_string_pretty(&existing_mcp).unwrap(),
)
.unwrap();
let result = install_on_path(&root, &home, &opts);
assert!(result.is_err(), "expected error on conflicting mcp entry");
let mcp_after: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
assert_eq!(
mcp_after["mcpServers"]["mushroomdb"]["args"][1],
"/some/other/db"
);
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
}
#[test]
fn uninstall_removes_manifest_contents() {
let root = temp_dir("uninstall");
let home = temp_dir("uninstall-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
fs::create_dir_all(root.join(".claude/skills/mushroom")).unwrap();
fs::write(
root.join(".claude/skills/mushroom/user-notes.md"),
"my notes",
)
.unwrap();
install_on_path(&root, &home, &opts).expect("install");
run_uninstall(&root, &home, &opts).expect("uninstall");
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
assert_absent(&root, ".claude/skills/mushroom/.install-manifest.json");
if root.join(".mcp.json").exists() {
let mcp: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
assert!(
mcp["mcpServers"]["mushroomdb"].is_null(),
"mushroomdb key still in .mcp.json after uninstall"
);
}
assert!(
root.join(".claude/skills/mushroom/user-notes.md").exists(),
"uninstall removed a user file"
);
}
#[test]
fn cursor_only_writes_cursor_artifacts() {
let root = temp_dir("cursor");
let home = temp_dir("cursor-home");
let db = root.join("mushroom-memory");
let opts = cursor_project_opts(&db);
install_on_path(&root, &home, &opts).expect("cursor install");
let rules = read(&root, ".cursor/rules/mushroom.mdc");
assert!(
rules.contains("alwaysApply: true"),
"cursor rules missing alwaysApply frontmatter"
);
assert!(
rules.contains(db.to_str().unwrap()),
"cursor rules do not contain db path"
);
let mcp_raw = read(&root, ".cursor/mcp.json");
let mcp: serde_json::Value = serde_json::from_str(&mcp_raw).expect("invalid cursor mcp json");
assert_eq!(mcp["mcpServers"]["mushroomdb"]["command"], "mushroomdb");
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
assert_absent(&root, ".mcp.json");
}
#[test]
fn all_platform_writes_both_artifacts() {
let root = temp_dir("all");
let home = temp_dir("all-home");
let db = root.join("mushroom-memory");
let opts = all_project_opts(&db);
install_on_path(&root, &home, &opts).expect("all-platform install");
assert!(root.join(".claude/skills/mushroom/SKILL.md").exists());
assert!(root.join(".cursor/rules/mushroom.mdc").exists());
assert!(root.join(".mcp.json").exists());
assert!(root.join(".cursor/mcp.json").exists());
}
#[test]
fn autodetect_home_claude_selects_claude_code() {
let root = temp_dir("autodetect-cc");
let home = temp_dir("autodetect-cc-home");
let db = root.join("mushroom-memory");
fs::create_dir_all(home.join(".claude")).unwrap();
let opts = InstallOpts {
platform: None,
project: true,
db: Some(db.clone()),
};
install_on_path(&root, &home, &opts).expect("autodetect install");
assert!(root.join(".claude/skills/mushroom/SKILL.md").exists());
assert_absent(&root, ".cursor/rules/mushroom.mdc");
}
#[test]
fn autodetect_project_cursor_selects_cursor() {
let root = temp_dir("autodetect-cursor");
let home = temp_dir("autodetect-cursor-home");
let db = root.join("mushroom-memory");
fs::create_dir_all(root.join(".cursor")).unwrap();
let opts = InstallOpts {
platform: None,
project: true,
db: Some(db.clone()),
};
install_on_path(&root, &home, &opts).expect("autodetect cursor install");
assert!(root.join(".cursor/rules/mushroom.mdc").exists());
assert_absent(&root, ".claude/skills/mushroom/SKILL.md");
}
#[test]
fn autodetect_neither_returns_error() {
let root = temp_dir("autodetect-none");
let home = temp_dir("autodetect-none-home");
let db = root.join("mushroom-memory");
let opts = InstallOpts {
platform: None,
project: true,
db: Some(db),
};
let result = install_on_path(&root, &home, &opts);
assert!(result.is_err(), "expected error when no platform detected");
let msg = result.unwrap_err().0;
assert!(
msg.contains("claude") || msg.contains("cursor") || msg.contains("detect"),
"error message should mention detection: {msg}"
);
}
#[test]
fn parse_install_platform_flag() {
use cli::parse_args;
use cli::Command;
let cmd = parse_args(&["install", "--platform", "cursor", "--project"]).unwrap();
match cmd {
Command::Install(opts) => {
assert_eq!(opts.platform, Some(Platform::Cursor));
assert!(opts.project);
}
other => panic!("expected Install, got {other:?}"),
}
}
#[test]
fn parse_uninstall_platform_flag() {
use cli::parse_args;
use cli::Command;
let cmd = parse_args(&["uninstall", "--platform", "claude-code"]).unwrap();
match cmd {
Command::Uninstall(opts) => {
assert_eq!(opts.platform, Some(Platform::ClaudeCode));
assert!(!opts.project);
}
other => panic!("expected Uninstall, got {other:?}"),
}
}
#[test]
fn parse_install_db_flag() {
use cli::parse_args;
use cli::Command;
let cmd = parse_args(&["install", "--db", "/tmp/mydb", "--platform", "all"]).unwrap();
match cmd {
Command::Install(opts) => {
assert_eq!(opts.db, Some(PathBuf::from("/tmp/mydb")));
assert_eq!(opts.platform, Some(Platform::All));
}
other => panic!("expected Install, got {other:?}"),
}
}
#[test]
fn off_path_install_copies_binary_and_writes_absolute_command() {
let root = temp_dir("offpath");
let home = temp_dir("offpath-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
let src = fake_exe(&root, b"#!/bin/sh\necho v1\n");
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(src.clone()))
.expect("off-path install failed");
let copied = home.join(".mushroomdb/bin/mushroomdb");
assert!(
copied.exists(),
"binary was not copied to ~/.mushroomdb/bin"
);
assert_eq!(fs::read(&copied).unwrap(), fs::read(&src).unwrap());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&copied).unwrap().permissions().mode();
assert!(
mode & 0o111 != 0,
"copied binary is not executable: {mode:o}"
);
}
let mcp: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
let server = &mcp["mcpServers"]["mushroomdb"];
assert_eq!(server["command"], copied.to_str().unwrap());
assert_eq!(server["args"][0], "mcp");
assert_eq!(server["args"][1], db.to_str().unwrap());
let skill = read(&root, ".claude/skills/mushroom/SKILL.md");
assert!(
skill.contains(&format!("'{}' demo '{}'", copied.display(), db.display())),
"SKILL.md bootstrap does not use the copied binary path"
);
assert!(
!skill.contains("{{BIN}}"),
"unsubstituted {{{{BIN}}}} in SKILL.md"
);
let manifest: serde_json::Value = serde_json::from_str(&read(
&root,
".claude/skills/mushroom/.install-manifest.json",
))
.unwrap();
let files: Vec<&str> = manifest["files"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
files.contains(&copied.to_str().unwrap()),
"manifest does not track copied binary: {files:?}"
);
run_uninstall(&root, &home, &opts).expect("uninstall");
assert!(!copied.exists(), "uninstall left the copied binary behind");
}
#[test]
fn reinstall_repairs_stale_command_for_same_db() {
let root = temp_dir("repair");
let home = temp_dir("repair-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
let src = fake_exe(&root, b"v1");
let stale = serde_json::json!({
"mcpServers": {
"mushroomdb": { "command": "mushroomdb", "args": ["mcp", db.to_str().unwrap()] },
"other": { "command": "other-tool", "args": [] }
}
});
fs::write(
root.join(".mcp.json"),
serde_json::to_string_pretty(&stale).unwrap(),
)
.unwrap();
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(src))
.expect("re-install with same db must repair, not conflict");
let mcp: serde_json::Value = serde_json::from_str(&read(&root, ".mcp.json")).unwrap();
let copied = home.join(".mushroomdb/bin/mushroomdb");
assert_eq!(
mcp["mcpServers"]["mushroomdb"]["command"],
copied.to_str().unwrap()
);
assert_eq!(
mcp["mcpServers"]["mushroomdb"]["args"][1],
db.to_str().unwrap()
);
assert_eq!(mcp["mcpServers"]["other"]["command"], "other-tool");
let skill = read(&root, ".claude/skills/mushroom/SKILL.md");
assert!(skill.contains(&format!("'{}' demo '{}'", copied.display(), db.display())));
}
#[test]
fn reinstall_from_stable_copy_is_noop() {
let root = temp_dir("selfcopy");
let home = temp_dir("selfcopy-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
let copied = home.join(".mushroomdb/bin/mushroomdb");
let src = fake_exe(&root, b"v1");
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(src)).expect("first");
let out = run_install_with(
&root,
&home,
&opts,
&BinaryLocation::CopyFrom(copied.clone()),
)
.expect("re-install from the stable copy");
assert!(out.contains("already installed"), "{out}");
assert_eq!(fs::read(&copied).unwrap(), b"v1");
}
#[test]
#[cfg(unix)]
fn partial_failure_after_copy_still_tracks_binary() {
use std::os::unix::fs::PermissionsExt;
let root = temp_dir("partial-fail");
let home = temp_dir("partial-fail-home");
let db = root.join("mushroom-memory");
let opts = all_project_opts(&db);
let src = fake_exe(&root, b"v1");
let rules_dir = root.join(".cursor/rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::set_permissions(&rules_dir, fs::Permissions::from_mode(0o500)).unwrap();
let result = run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(src));
fs::set_permissions(&rules_dir, fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_err(), "expected cursor rules write to fail");
let copied = home.join(".mushroomdb/bin/mushroomdb");
assert!(copied.exists());
let manifest: serde_json::Value = serde_json::from_str(&read(
&root,
".claude/skills/mushroom/.install-manifest.json",
))
.unwrap();
let files: Vec<&str> = manifest["files"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(files.contains(&copied.to_str().unwrap()), "{files:?}");
run_uninstall(&root, &home, &opts).expect("uninstall after partial failure");
assert!(!copied.exists(), "orphaned binary after uninstall");
}
#[test]
fn off_path_reinstall_refreshes_copied_binary() {
let root = temp_dir("refresh");
let home = temp_dir("refresh-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
let copied = home.join(".mushroomdb/bin/mushroomdb");
let v1 = fake_exe(&root, b"v1");
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(v1)).expect("v1");
assert_eq!(fs::read(&copied).unwrap(), b"v1");
let v1_again = fake_exe(&root, b"v1");
let out = run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(v1_again))
.expect("v1 again");
assert!(
out.contains("already installed"),
"same binary should be a no-op: {out}"
);
let v2 = fake_exe(&root, b"v2-newer");
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(v2)).expect("v2");
assert_eq!(fs::read(&copied).unwrap(), b"v2-newer");
}
#[test]
fn on_path_install_uses_bare_name_everywhere() {
let root = temp_dir("onpath-bin");
let home = temp_dir("onpath-bin-home");
let db = root.join("mushroom-memory");
let opts = all_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let skill = read(&root, ".claude/skills/mushroom/SKILL.md");
assert!(skill.contains(&format!("'mushroomdb' demo '{}'", db.display())));
assert!(!skill.contains("{{BIN}}"));
let rules = read(&root, ".cursor/rules/mushroom.mdc");
assert!(rules.contains(&format!("'mushroomdb' demo '{}'", db.display())));
assert!(!rules.contains("{{BIN}}"));
assert_absent(&home, ".mushroomdb/bin/mushroomdb");
}
#[test]
fn install_adds_recall_hook_and_uninstall_removes_only_it() {
let root = temp_dir("hook");
let home = temp_dir("hook-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
fs::create_dir_all(root.join(".claude")).unwrap();
fs::write(
root.join(".claude/settings.json"),
r#"{"permissions":{"allow":["Bash"]},"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo hi"}]}]}}"#,
)
.unwrap();
install_on_path(&root, &home, &opts).expect("install");
let s: serde_json::Value = serde_json::from_str(&read(&root, ".claude/settings.json")).unwrap();
let ups = s["hooks"]["UserPromptSubmit"]
.as_array()
.expect("UserPromptSubmit array");
assert_eq!(ups.len(), 1);
let cmd = ups[0]["hooks"][0]["command"].as_str().unwrap();
assert_eq!(cmd, format!("'mushroomdb' recall '{}'", db.display()));
assert_eq!(ups[0]["hooks"][0]["timeout"], 5);
assert_eq!(s["permissions"]["allow"][0], "Bash");
assert_eq!(
s["hooks"]["SessionStart"][0]["hooks"][0]["command"],
"echo hi"
);
let out = install_on_path(&root, &home, &opts).expect("second install");
assert!(out.contains("already installed"), "{out}");
let s2: serde_json::Value =
serde_json::from_str(&read(&root, ".claude/settings.json")).unwrap();
assert_eq!(s2["hooks"]["UserPromptSubmit"].as_array().unwrap().len(), 1);
let m: serde_json::Value = serde_json::from_str(&read(
&root,
".claude/skills/mushroom/.install-manifest.json",
))
.unwrap();
assert_eq!(m["hooks"][0]["event"], "UserPromptSubmit");
run_uninstall(&root, &home, &opts).expect("uninstall");
let s3: serde_json::Value =
serde_json::from_str(&read(&root, ".claude/settings.json")).unwrap();
assert!(
s3["hooks"]["UserPromptSubmit"].is_null()
|| s3["hooks"]["UserPromptSubmit"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
s3["hooks"]["SessionStart"][0]["hooks"][0]["command"],
"echo hi"
);
assert_eq!(s3["permissions"]["allow"][0], "Bash");
}
#[test]
fn user_scope_hook_goes_to_home_settings_and_uses_absolute_bin() {
let root = temp_dir("hook-user");
let home = temp_dir("hook-user-home");
fs::create_dir_all(home.join(".claude")).unwrap();
let opts = InstallOpts {
platform: Some(Platform::ClaudeCode),
project: false,
db: None,
};
let src = fake_exe(&root, b"v1");
run_install_with(&root, &home, &opts, &BinaryLocation::CopyFrom(src)).expect("install");
let s: serde_json::Value = serde_json::from_str(&read(&home, ".claude/settings.json")).unwrap();
let cmd = s["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
cmd.starts_with(&format!(
"'{}",
home.join(".mushroomdb/bin/mushroomdb").display()
)),
"{cmd}"
);
assert!(
cmd.ends_with(&format!(
"recall '{}'",
home.join(".mushroomdb/memory").display()
)),
"{cmd}"
);
assert_absent(&root, ".claude/settings.json");
}
#[test]
fn old_manifest_without_hooks_field_still_uninstalls() {
let root = temp_dir("old-manifest");
let home = temp_dir("old-manifest-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let p = root.join(".claude/skills/mushroom/.install-manifest.json");
let mut m: serde_json::Value = serde_json::from_str(&fs::read_to_string(&p).unwrap()).unwrap();
m.as_object_mut().unwrap().remove("hooks");
fs::write(&p, serde_json::to_string_pretty(&m).unwrap()).unwrap();
run_uninstall(&root, &home, &opts).expect("uninstall with old manifest");
}
#[test]
fn hook_command_is_shell_quoted_for_paths_with_spaces() {
let root = temp_dir("hook-space");
let home = temp_dir("hook-space-home");
let db = root.join("mushroom memory"); let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let s: serde_json::Value = serde_json::from_str(&read(&root, ".claude/settings.json")).unwrap();
let cmd = s["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert_eq!(cmd, format!("'mushroomdb' recall '{}'", db.display()));
}
#[test]
fn uninstall_after_hand_deleting_hooks_key_leaves_settings_unchanged() {
let root = temp_dir("hooks-key-deleted");
let home = temp_dir("hooks-key-deleted-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let settings_path = root.join(".claude/settings.json");
let mut s: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
s.as_object_mut().unwrap().remove("hooks");
let stripped = serde_json::to_string_pretty(&s).unwrap();
fs::write(&settings_path, &stripped).unwrap();
run_uninstall(&root, &home, &opts).expect("uninstall despite missing hooks key");
assert_eq!(read(&root, ".claude/settings.json"), stripped);
}
#[test]
fn install_refuses_when_hooks_key_is_not_an_object() {
let root = temp_dir("hooks-not-object");
let home = temp_dir("hooks-not-object-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
fs::create_dir_all(root.join(".claude")).unwrap();
let original = r#"{"hooks":[]}"#;
fs::write(root.join(".claude/settings.json"), original).unwrap();
let result = install_on_path(&root, &home, &opts);
assert!(
result.is_err(),
"expected error when hooks is not an object"
);
assert_eq!(read(&root, ".claude/settings.json"), original);
}
#[test]
fn install_refuses_when_settings_root_is_not_an_object() {
let root = temp_dir("settings-root-not-object");
let home = temp_dir("settings-root-not-object-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
fs::create_dir_all(root.join(".claude")).unwrap();
let original = "[]";
fs::write(root.join(".claude/settings.json"), original).unwrap();
let result = install_on_path(&root, &home, &opts);
assert!(
result.is_err(),
"expected a clean error, not a panic, for a non-object settings root"
);
assert_eq!(read(&root, ".claude/settings.json"), original);
}
#[test]
fn uninstall_leaves_mixed_hook_group_with_user_hook_intact() {
let root = temp_dir("mixed-group");
let home = temp_dir("mixed-group-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let settings_path = root.join(".claude/settings.json");
let our_cmd = format!("'mushroomdb' recall '{}'", db.display());
let mixed = serde_json::json!({
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{"type": "command", "command": "echo hi"},
{"type": "command", "command": our_cmd, "timeout": 5}
]
}
]
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&mixed).unwrap(),
)
.unwrap();
run_uninstall(&root, &home, &opts).expect("uninstall");
let s: serde_json::Value = serde_json::from_str(&read(&root, ".claude/settings.json")).unwrap();
let group_hooks = s["hooks"]["UserPromptSubmit"][0]["hooks"]
.as_array()
.expect("group must survive with the user's hook");
assert_eq!(
group_hooks.len(),
1,
"expected only the user's hook to remain: {group_hooks:?}"
);
assert_eq!(group_hooks[0]["command"], "echo hi");
}
#[test]
fn skill_text_is_truthful_about_masks_and_tool_args() {
let root = temp_dir("skill-truth");
let home = temp_dir("skill-truth-home");
let db = root.join("mushroom-memory");
let opts = all_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let skill = read(&root, ".claude/skills/mushroom/SKILL.md");
let rules = read(&root, ".cursor/rules/mushroom.mdc");
for (name, text) in [("SKILL.md", &skill), ("mushroom.mdc", &rules)] {
assert!(
text.contains("allow-list"),
"{name}: mask must be described as an allow-list"
);
assert!(
!text.contains("must not see"),
"{name}: inverted mask text still present"
);
assert!(
!text.contains("keys to exclude"),
"{name}: inverted mask text still present"
);
assert!(
!text.contains("keys to hide"),
"{name}: inverted mask text still present"
);
assert!(
text.contains("max_edges"),
"{name}: create_rule max_edges undocumented"
);
assert!(
text.contains("no auth"),
"{name}: MCP trust model undocumented"
);
assert!(
text.contains("ingest-git"),
"{name}: ingest-git bootstrap undocumented"
);
}
assert!(
skill.contains("`edges`"),
"SKILL.md: ingest_json edges arg undocumented"
);
assert!(
skill.contains("ambiguous target labels"),
"SKILL.md: polymorphic FK pattern undocumented"
);
}
#[test]
fn uninstall_does_not_reformat_settings_it_removes_nothing_from() {
let root = temp_dir("no-op-uninstall");
let home = temp_dir("no-op-uninstall-home");
let db = root.join("mushroom-memory");
let opts = claude_project_opts(&db);
install_on_path(&root, &home, &opts).expect("install");
let settings_path = root.join(".claude/settings.json");
let user_owned = "{\n \"hooks\": {\n \"UserPromptSubmit\": [\n {\n \"hooks\": [\n {\"type\": \"command\", \"command\": \"echo hi\"}\n ]\n }\n ]\n },\n \"env\": {\"FOO\": \"bar\"}\n}\n";
fs::write(&settings_path, user_owned).unwrap();
run_uninstall(&root, &home, &opts).expect("uninstall");
assert_eq!(
read(&root, ".claude/settings.json"),
user_owned,
"nothing of ours was in the file, so it must be byte-identical"
);
}
fn path_var(dirs: &[&Path]) -> std::ffi::OsString {
std::env::join_paths(dirs.iter().map(|d| d.as_os_str())).unwrap()
}
fn named_bin(dir: &Path, bytes: &[u8]) -> PathBuf {
let p = dir.join("mushroomdb");
fs::write(&p, bytes).unwrap();
p
}
#[test]
fn npm_shim_on_path_is_not_our_binary_so_we_copy() {
let shim_dir = temp_dir("classify-shim");
let exe_dir = temp_dir("classify-shim-exe");
named_bin(
&shim_dir,
b"#!/usr/bin/env node\nrequire('../lib/cli.js')\n",
);
let current_exe = fake_exe(&exe_dir, b"\x7fELF fake native binary\n");
assert_eq!(
cli::install::classify_binary_location(
Some(path_var(&[&shim_dir]).as_os_str()),
¤t_exe
),
BinaryLocation::CopyFrom(current_exe.clone()),
"a Node shim named mushroomdb must not count as our binary"
);
}
#[test]
fn symlink_on_path_pointing_at_current_exe_counts_as_on_path() {
let link_dir = temp_dir("classify-link");
let exe_dir = temp_dir("classify-link-exe");
let current_exe = fake_exe(&exe_dir, b"\x7fELF fake native binary\n");
#[cfg(unix)]
std::os::unix::fs::symlink(¤t_exe, link_dir.join("mushroomdb")).unwrap();
#[cfg(windows)]
fs::copy(¤t_exe, link_dir.join("mushroomdb")).unwrap();
assert_eq!(
cli::install::classify_binary_location(
Some(path_var(&[&link_dir]).as_os_str()),
¤t_exe
),
BinaryLocation::OnPath,
"a symlink to this executable is this executable"
);
}
#[test]
fn current_exe_itself_on_path_counts_as_on_path() {
let dir = temp_dir("classify-self");
let current_exe = named_bin(&dir, b"\x7fELF fake native binary\n");
assert_eq!(
cli::install::classify_binary_location(Some(path_var(&[&dir]).as_os_str()), ¤t_exe),
BinaryLocation::OnPath
);
}
#[test]
fn no_path_hit_copies_the_current_exe() {
let empty_dir = temp_dir("classify-empty");
let exe_dir = temp_dir("classify-empty-exe");
let current_exe = fake_exe(&exe_dir, b"\x7fELF fake native binary\n");
assert_eq!(
cli::install::classify_binary_location(
Some(path_var(&[&empty_dir]).as_os_str()),
¤t_exe
),
BinaryLocation::CopyFrom(current_exe.clone())
);
assert_eq!(
cli::install::classify_binary_location(None, ¤t_exe),
BinaryLocation::CopyFrom(current_exe)
);
}
#[test]
fn a_different_native_binary_first_on_path_shadows_us_so_we_copy() {
let other_dir = temp_dir("classify-other");
let exe_dir = temp_dir("classify-other-exe");
named_bin(&other_dir, b"\x7fELF a different build\n");
let current_exe = fake_exe(&exe_dir, b"\x7fELF fake native binary\n");
assert_eq!(
cli::install::classify_binary_location(
Some(path_var(&[&other_dir]).as_os_str()),
¤t_exe
),
BinaryLocation::CopyFrom(current_exe.clone()),
"PATH resolves to a binary that is not us, so name the copy explicitly"
);
}