use crate::CliError;
use serde::{Deserialize, Serialize};
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
const BIN_PLACEHOLDER: &str = "{{BIN}}";
pub(crate) const SERVER_NAME: &str = "mushroomdb";
const BIN_NAME: &str = "mushroomdb";
const NPM_PACKAGE: &str = "mushroomdb";
const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
const PREWARM_TIMEOUT_SECS: u64 = 180;
const NODE_BIN: &str = "node";
const PRINT_BINARY_FLAG: &str = "--print-binary";
const PRINT_LAUNCHER_FLAG: &str = "--print-launcher";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpCommand {
Npx { version: String },
NativeBinary {
binary: PathBuf,
version: String,
},
NodeLauncher {
launcher: PathBuf,
version: String,
},
Explicit(PathBuf),
OnPath,
}
impl McpCommand {
#[must_use]
pub fn npx() -> Self {
McpCommand::Npx {
version: CRATE_VERSION.to_string(),
}
}
fn program(&self) -> (String, Vec<String>) {
match self {
McpCommand::Npx { version } => (
"npx".to_string(),
vec!["-y".to_string(), format!("{NPM_PACKAGE}@{version}")],
),
McpCommand::NativeBinary { binary, .. } => {
(binary.to_string_lossy().into_owned(), Vec::new())
}
McpCommand::NodeLauncher { launcher, .. } => (
NODE_BIN.to_string(),
vec![launcher.to_string_lossy().into_owned()],
),
McpCommand::Explicit(p) => (p.to_string_lossy().into_owned(), Vec::new()),
McpCommand::OnPath => (BIN_NAME.to_string(), Vec::new()),
}
}
#[must_use]
pub fn json_entry(&self, sub: &str, db: &str) -> serde_json::Value {
let (command, mut args) = self.program();
args.push(sub.to_string());
args.push(db.to_string());
serde_json::json!({ "command": command, "args": args })
}
#[must_use]
pub fn shell(&self) -> String {
let (command, args) = self.program();
let mut out = match self {
McpCommand::Explicit(_) | McpCommand::NativeBinary { .. } => sh_quote(&command),
_ => command,
};
for a in args {
out.push(' ');
match self {
McpCommand::NodeLauncher { .. } => out.push_str(&sh_quote(&a)),
_ => out.push_str(&a),
}
}
out
}
fn argv(&self, sub: &str, db: &str) -> Vec<String> {
let (command, mut args) = self.program();
args.push(sub.to_string());
args.push(db.to_string());
let mut out = vec![command];
out.extend(args);
out
}
}
#[must_use]
pub fn detect_mcp_command(explicit: Option<&Path>) -> McpCommand {
if let Some(path) = explicit {
return McpCommand::Explicit(path.to_path_buf());
}
match std::env::current_exe() {
Ok(exe) => classify_mcp_command(std::env::var_os("PATH").as_deref(), &exe),
Err(_) => McpCommand::npx(),
}
}
#[must_use]
pub fn classify_mcp_command(path_var: Option<&OsStr>, current_exe: &Path) -> McpCommand {
let Some(hit) = path_var.and_then(|p| {
std::env::split_paths(p)
.map(|dir| dir.join(BIN_NAME))
.find(|candidate| candidate.is_file())
}) else {
return McpCommand::npx();
};
match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
(Ok(on_path), Ok(running)) if on_path == running => McpCommand::OnPath,
_ => McpCommand::npx(),
}
}
pub const AUTO_ARG: &str = "--auto";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreRef {
path: PathBuf,
auto: bool,
auto_equivalent: bool,
}
impl StoreRef {
#[must_use]
pub fn auto(path: impl Into<PathBuf>) -> Self {
let path = path.into();
StoreRef {
path,
auto: true,
auto_equivalent: true,
}
}
#[must_use]
pub fn pinned(path: impl Into<PathBuf>) -> Self {
StoreRef {
path: path.into(),
auto: false,
auto_equivalent: false,
}
}
#[must_use]
pub fn also_auto(mut self) -> Self {
self.auto_equivalent = true;
self
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn is_auto(&self) -> bool {
self.auto
}
#[must_use]
pub fn arg(&self) -> String {
if self.auto {
AUTO_ARG.to_string()
} else {
self.path.to_string_lossy().into_owned()
}
}
#[must_use]
pub fn shell_arg(&self) -> String {
if self.auto {
AUTO_ARG.to_string()
} else {
sh_quote(&self.path.to_string_lossy())
}
}
fn describe(&self) -> String {
if self.auto {
format!("{AUTO_ARG} (resolves to {})", self.path.display())
} else {
format!("{} (pinned)", self.path.display())
}
}
fn hook_tails(&self, sub: &str) -> Vec<String> {
let mut out = vec![format!(" {sub} {}", sh_quote(&self.path.to_string_lossy()))];
if self.auto_equivalent {
out.push(format!(" {sub} {AUTO_ARG}"));
}
out
}
fn names_same_store(&self, existing_arg: &str) -> bool {
if existing_arg == AUTO_ARG {
return self.auto_equivalent;
}
Path::new(existing_arg) == self.path
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Platform {
ClaudeCode,
Cursor,
Codex,
All,
}
impl Platform {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"claude-code" => Ok(Platform::ClaudeCode),
"cursor" => Ok(Platform::Cursor),
"codex" => Ok(Platform::Codex),
"all" => Ok(Platform::All),
other => Err(format!(
"--platform must be claude-code | cursor | codex | all, got: {other}"
)),
}
}
pub(crate) fn label(&self) -> &'static str {
match self {
Platform::ClaudeCode => "claude-code",
Platform::Cursor => "cursor",
Platform::Codex => "codex",
Platform::All => "all",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Project,
User,
}
impl Scope {
pub(crate) fn label(self) -> &'static str {
match self {
Scope::Project => "project",
Scope::User => "user",
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Delivery {
Cli,
Mcp,
#[default]
Both,
}
impl Delivery {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"cli" => Ok(Delivery::Cli),
"mcp" => Ok(Delivery::Mcp),
"both" => Ok(Delivery::Both),
other => Err(format!("--delivery must be cli | mcp | both, got: {other}")),
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Delivery::Cli => "cli",
Delivery::Mcp => "mcp",
Delivery::Both => "both",
}
}
pub(crate) fn wires_mcp(self) -> bool {
!matches!(self, Delivery::Cli)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOpts {
pub platform: Option<Platform>,
pub scope: Option<Scope>,
pub db: Option<PathBuf>,
pub command: Option<PathBuf>,
pub git_hooks: bool,
pub prewarm: bool,
pub delivery: Delivery,
pub intercept_grep: bool,
pub impact_before_edit: bool,
pub enrich_grep: bool,
pub always_load: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToggleOpts {
pub platform: Option<Platform>,
pub scope: Option<Scope>,
}
#[must_use]
pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
match scope {
Scope::Project => project_root.join("mushroom-memory"),
Scope::User => home.join(".mushroomdb").join("memory"),
}
}
fn resolves_at_runtime(platform: &Platform) -> bool {
match platform {
Platform::ClaudeCode => true,
Platform::Cursor | Platform::Codex => false,
Platform::All => false,
}
}
fn platform_stores(
project_root: &Path,
home: &Path,
scope: Scope,
db: Option<&Path>,
platforms: &[Platform],
) -> Vec<(Platform, StoreRef)> {
platforms
.iter()
.map(|p| {
(
p.clone(),
store_ref(project_root, home, scope, db, resolves_at_runtime(p)),
)
})
.collect()
}
fn describe_stores(stores: &[(Platform, StoreRef)]) -> String {
let all_same = stores.windows(2).all(|w| w[0].1 == w[1].1);
match stores.first() {
None => String::new(),
Some((_, first)) if all_same => format!(" store {}\n", first.describe()),
_ => stores
.iter()
.map(|(p, s)| format!(" store {}: {}\n", p.label(), s.describe()))
.collect(),
}
}
fn repo_store_ref(
project_root: &Path,
home: &Path,
scope: Scope,
db: Option<&Path>,
platforms: &[Platform],
) -> StoreRef {
let runtime_ok = platforms.iter().any(resolves_at_runtime);
store_ref(project_root, home, scope, db, runtime_ok)
}
fn store_ref(
project_root: &Path,
home: &Path,
scope: Scope,
db: Option<&Path>,
runtime_ok: bool,
) -> StoreRef {
let default = default_db(scope, project_root, home);
let Some(pinned) = db.map(|d| absolutise(d, project_root)) else {
if runtime_ok && scope == Scope::Project && project_root.join(".git").exists() {
return StoreRef::auto(default);
}
let pinned = StoreRef::pinned(default);
return if scope == Scope::Project {
pinned.also_auto()
} else {
pinned
};
};
let auto_here = default_db(Scope::Project, project_root, home);
if pinned == auto_here {
return StoreRef::pinned(pinned).also_auto();
}
StoreRef::pinned(pinned)
}
pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
match requested {
Some(s) => (s, false),
None if project_root.join(".git").exists() => (Scope::Project, true),
None => (Scope::User, true),
}
}
#[derive(Debug, Clone)]
pub struct Externals {
pub path: Option<OsString>,
pub prewarm_timeout: Duration,
}
impl Externals {
#[must_use]
pub fn from_env() -> Self {
Self::with_path(std::env::var_os("PATH"))
}
#[must_use]
pub fn with_path(path: Option<OsString>) -> Self {
Self {
path,
prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
}
}
pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
let path = self.path.as_ref()?;
std::env::split_paths(path)
.map(|dir| dir.join(program))
.find(|c| is_executable(c))
}
}
fn is_executable(path: &Path) -> bool {
let Ok(meta) = fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
let out = std::process::Command::new(bin)
.args(args)
.output()
.map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let detail = if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
};
Err(format!(
"{} {} exited with {}{detail}",
bin.display(),
args.join(" "),
out.status
))
}
fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
let mut child = std::process::Command::new(bin)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => return Err(format!("exited with {status}")),
Ok(None) => {}
Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(format!("timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(25));
}
}
fn capture_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<String, String> {
let mut child = std::process::Command::new(bin)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
let mut stdout = child.stdout.take().expect("stdout is piped");
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
use std::io::Read as _;
let mut out = String::new();
let _ = stdout.read_to_string(&mut out);
let _ = tx.send(out);
});
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => {
return Ok(rx.recv_timeout(Duration::from_secs(1)).unwrap_or_default());
}
Ok(Some(status)) => return Err(format!("exited with {status}")),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("timed out after {}s", timeout.as_secs()));
}
Ok(None) => std::thread::sleep(Duration::from_millis(25)),
Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
}
}
}
fn ask_package(version: &str, flag: &str, ext: &Externals) -> Result<PathBuf, String> {
let npx = ext
.which("npx")
.ok_or_else(|| "npx is not on PATH".to_string())?;
let args = vec![
"-y".to_string(),
format!("{NPM_PACKAGE}@{version}"),
flag.to_string(),
];
let out = capture_with_timeout(&npx, &args, ext.prewarm_timeout)?;
let path = out
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.ok_or_else(|| format!("{NPM_PACKAGE}@{version} {flag} printed nothing"))?;
let path = PathBuf::from(path);
if !path.is_absolute() {
return Err(format!("{} is not an absolute path", path.display()));
}
if !path.is_file() {
return Err(format!("{} does not exist", path.display()));
}
Ok(path)
}
fn resolve_fast_command(cmd: &McpCommand, ext: &Externals) -> (McpCommand, Option<String>, bool) {
let McpCommand::Npx { version } = cmd else {
return (cmd.clone(), None, false);
};
let fetched = ext.which("npx").is_some();
let binary_err = match ask_package(version, PRINT_BINARY_FLAG, ext) {
Ok(binary) => {
return (
McpCommand::NativeBinary {
binary,
version: version.clone(),
},
None,
fetched,
)
}
Err(e) => e,
};
if ext.which(NODE_BIN).is_some() {
if let Ok(launcher) = ask_package(version, PRINT_LAUNCHER_FLAG, ext) {
return (
McpCommand::NodeLauncher {
launcher,
version: version.clone(),
},
None,
fetched,
);
}
}
(
cmd.clone(),
Some(format!(
"warning: could not resolve {NPM_PACKAGE}@{version} to a path ({binary_err}) — \
the hooks will spawn npx on every prompt and every edit"
)),
fetched,
)
}
#[derive(Serialize, Deserialize, Default, Debug)]
struct Manifest {
files: Vec<PathBuf>,
mcp_keys: Vec<ManagedMcpKey>,
#[serde(default)]
hooks: Vec<ManagedHook>,
#[serde(default)]
git_hooks: Vec<PathBuf>,
#[serde(default)]
gitignore: Vec<ManagedLine>,
#[serde(default)]
codex: bool,
#[serde(default)]
disabled: bool,
#[serde(default)]
stashed_mcp: Vec<StashedMcpEntry>,
#[serde(default)]
requested_cmd: Option<StoredCommand>,
#[serde(default)]
delivery: Delivery,
#[serde(default)]
intercept_grep: bool,
#[serde(default)]
impact_before_edit: bool,
#[serde(default)]
enrich_grep: bool,
#[serde(default)]
always_load: bool,
}
impl Manifest {
fn sanitised(mut self) -> Self {
self.files
.retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
self
}
fn is_empty(&self) -> bool {
self.files.is_empty()
&& self.mcp_keys.is_empty()
&& self.hooks.is_empty()
&& self.git_hooks.is_empty()
&& self.gitignore.is_empty()
&& !self.codex
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct StashedMcpEntry {
file: PathBuf,
server: String,
entry: serde_json::Value,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
enum StoredCommand {
Npx { version: String },
Explicit(PathBuf),
OnPath,
}
impl StoredCommand {
fn from_mcp(cmd: &McpCommand) -> Option<Self> {
match cmd {
McpCommand::Npx { version } => Some(StoredCommand::Npx {
version: version.clone(),
}),
McpCommand::Explicit(p) => Some(StoredCommand::Explicit(p.clone())),
McpCommand::OnPath => Some(StoredCommand::OnPath),
McpCommand::NativeBinary { .. } | McpCommand::NodeLauncher { .. } => None,
}
}
fn into_mcp(self) -> McpCommand {
match self {
StoredCommand::Npx { version } => McpCommand::Npx { version },
StoredCommand::Explicit(p) => McpCommand::Explicit(p),
StoredCommand::OnPath => McpCommand::OnPath,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ManagedMcpKey {
file: PathBuf,
server: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ManagedHook {
file: PathBuf,
event: String,
command: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ManagedLine {
file: PathBuf,
line: String,
#[serde(default)]
created: bool,
}
pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
const HOOK_TIMEOUT_SECS: u64 = 5;
pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
const TOUCH_TIMEOUT_SECS: u64 = 30;
pub(crate) const BRIEF_EVENT: &str = "SessionStart";
pub(crate) const INTERCEPT_EVENT: &str = "PreToolUse";
const INTERCEPT_MATCHER: &str = "Grep";
pub(crate) const IMPACT_EVENT: &str = "PreToolUse";
const IMPACT_MATCHER: &str = TOUCH_MATCHER;
pub(crate) const ENRICH_EVENT: &str = "PostToolUse";
const ENRICH_MATCHER: &str = "Grep";
pub(crate) fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn hook_command(shell: &str, sub: &str, store: &StoreRef) -> String {
format!("{shell} {sub} {}", store.shell_arg())
}
fn hook_entry(command: &str) -> serde_json::Value {
serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
}
fn matched_hook_entry(
matcher: &str,
command: &str,
timeout: u64,
run_async: bool,
) -> serde_json::Value {
let mut hook = serde_json::json!({
"type": "command",
"command": command,
"timeout": timeout,
});
if run_async {
hook["async"] = serde_json::Value::Bool(true);
}
serde_json::json!({ "matcher": matcher, "hooks": [hook] })
}
pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
root["hooks"][event]
.as_array()
.map(|groups| {
groups.iter().any(|g| {
g["hooks"]
.as_array()
.map(|hs| hs.iter().any(|h| h["command"] == command))
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn merge_hook_entry(
settings_file: &Path,
event: &str,
command: &str,
entry: serde_json::Value,
manifest: &mut Manifest,
) -> Result<(), CliError> {
let mut root: serde_json::Value = if settings_file.exists() {
let raw = fs::read_to_string(settings_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
} else {
serde_json::json!({})
};
if !root.is_object() {
return Err(CliError(format!(
"{} is not a JSON object at its top level — refusing to add a hook",
settings_file.display()
)));
}
if settings_has_hook(&root, event, command) {
return Ok(());
}
match root.get("hooks") {
None => root["hooks"] = serde_json::json!({}),
Some(v) if v.is_object() => {}
Some(_) => {
return Err(CliError(format!(
"{}: \"hooks\" is not a JSON object — refusing to overwrite it",
settings_file.display()
)));
}
}
match root["hooks"].get(event) {
None => root["hooks"][event] = serde_json::json!([]),
Some(v) if v.is_array() => {}
Some(_) => {
return Err(CliError(format!(
"{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
settings_file.display()
)));
}
}
root["hooks"][event].as_array_mut().unwrap().push(entry);
let parent = settings_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
fs::write(settings_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
manifest.hooks.push(ManagedHook {
file: settings_file.to_path_buf(),
event: event.into(),
command: command.into(),
});
Ok(())
}
fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
drop_hooks(settings_file, event, |c| c == command)
}
pub(crate) fn is_our_hook_command(command: &str, sub: &str, store: &StoreRef) -> bool {
store
.hook_tails(sub)
.iter()
.any(|tail| command.ends_with(tail))
}
pub(crate) fn line_runs_for_store(line: &str, sub: &str, store: &StoreRef) -> bool {
store.hook_tails(sub).iter().any(|tail| line.contains(tail))
}
fn remove_stale_hooks(
settings_file: &Path,
event: &str,
sub: &str,
store: &StoreRef,
desired: &str,
) -> Result<bool, CliError> {
drop_hooks(settings_file, event, |c| {
c != desired && is_our_hook_command(c, sub, store)
})
}
fn drop_hooks(
settings_file: &Path,
event: &str,
drop_it: impl Fn(&str) -> bool,
) -> Result<bool, CliError> {
if !settings_file.exists() {
return Ok(false);
}
let raw = fs::read_to_string(settings_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
CliError(format!(
"corrupt settings json at {}: {e}",
settings_file.display()
))
})?;
let Some(mut groups) = root
.get("hooks")
.and_then(|h| h.get(event))
.and_then(|g| g.as_array())
.cloned()
else {
return Ok(false);
};
for g in groups.iter_mut() {
if let Some(hs) = g["hooks"].as_array_mut() {
hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
}
}
groups.retain(|g| {
g["hooks"]
.as_array()
.map(|hs| !hs.is_empty())
.unwrap_or(true)
});
let before = root.clone();
if groups.is_empty() {
root["hooks"].as_object_mut().unwrap().remove(event);
} else {
root["hooks"][event] = serde_json::Value::Array(groups);
}
if root == before {
return Ok(false);
}
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
fs::write(settings_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
Ok(true)
}
struct Ctx<'a> {
project_root: &'a Path,
home: &'a Path,
scope: Scope,
repo_store: &'a StoreRef,
cmd: &'a McpCommand,
ext: &'a Externals,
git_hooks: bool,
prewarm: bool,
delivery: Delivery,
intercept_grep: bool,
impact_before_edit: bool,
enrich_grep: bool,
always_load: bool,
}
fn absolutise(path: &Path, base: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
let mut out = PathBuf::new();
for c in joined.components() {
match c {
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn is_bare_program_name(p: &Path) -> bool {
let mut components = p.components();
matches!(
(components.next(), components.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
if is_bare_program_name(path) {
path.to_path_buf()
} else {
absolutise(path, base)
}
}
pub fn run_install(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
run_install_with(
project_root,
home,
opts,
&detect_mcp_command(opts.command.as_deref()),
&Externals::from_env(),
)
}
pub fn run_install_with(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
cmd: &McpCommand,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let cmd = match cmd {
McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
other => other.clone(),
};
let requested_cmd = StoredCommand::from_mcp(&cmd);
let (cmd, launcher_note, package_fetched) = if opts.prewarm {
resolve_fast_command(&cmd, ext)
} else {
(cmd, None, false)
};
let cmd = &cmd;
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let stores = platform_stores(project_root, home, scope, opts.db.as_deref(), &platforms);
let repo_store = repo_store_ref(project_root, home, scope, opts.db.as_deref(), &platforms);
for (plat, store) in &stores {
preflight_check(project_root, home, plat, scope, store, ext)?;
}
let ctx = Ctx {
project_root,
home,
scope,
repo_store: &repo_store,
cmd,
ext,
git_hooks: opts.git_hooks,
prewarm: opts.prewarm && !package_fetched,
delivery: opts.delivery,
intercept_grep: opts.intercept_grep,
impact_before_edit: opts.impact_before_edit,
enrich_grep: opts.enrich_grep,
always_load: opts.always_load,
};
let manifest_path = manifest_path(project_root, home, scope, &platforms);
let existing = load_manifest(&manifest_path);
let was_disabled = existing.disabled;
let doors_changed = existing.intercept_grep != opts.intercept_grep
|| existing.impact_before_edit != opts.impact_before_edit
|| existing.enrich_grep != opts.enrich_grep
|| existing.always_load != opts.always_load;
let mut manifest = Manifest {
requested_cmd,
delivery: opts.delivery,
intercept_grep: opts.intercept_grep,
impact_before_edit: opts.impact_before_edit,
enrich_grep: opts.enrich_grep,
always_load: opts.always_load,
..Manifest::default()
};
let mut notes: Vec<String> = Vec::new();
notes.extend(launcher_note);
if was_disabled {
notes.push("this install was disabled — install re-enabled it".to_string());
}
let outcome = write_everything(&ctx, &stores, &mut manifest, &mut notes);
if let Err(e) = outcome {
if !manifest.is_empty() {
let merged = union_manifests(load_manifest(&manifest_path), &manifest);
let _ = write_manifest(&manifest_path, &merged);
}
return Err(e);
}
let anything_written = !manifest.is_empty();
if anything_written || was_disabled || doors_changed {
let mut merged = if anything_written {
union_manifests(existing, &manifest)
} else {
existing
};
if was_disabled {
merged.disabled = false;
merged.stashed_mcp.clear();
}
if !opts.delivery.wires_mcp() {
merged.mcp_keys.retain(|k| has_our_server(&k.file));
}
merged.intercept_grep = opts.intercept_grep;
merged.impact_before_edit = opts.impact_before_edit;
merged.enrich_grep = opts.enrich_grep;
merged.always_load = opts.always_load;
for (on, sub) in [
(opts.intercept_grep, "intercept"),
(opts.impact_before_edit, "impact-hook"),
(opts.enrich_grep, "enrich"),
] {
if !on {
merged.hooks.retain(|h| {
!stores
.iter()
.any(|(_, store)| is_our_hook_command(&h.command, sub, store))
});
}
}
write_manifest(&manifest_path, &merged)?;
}
let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
out.push_str(&format!(
" scope {}{}\n",
scope.label(),
if auto_scope { " (auto-detected)" } else { "" }
));
for f in &manifest.files {
out.push_str(&format!(" wrote {}\n", f.display()));
}
for k in &manifest.mcp_keys {
out.push_str(&format!(
" added mcpServers.{} in {}\n",
k.server,
k.file.display()
));
}
for h in &manifest.hooks {
out.push_str(&format!(
" added {} hook in {}\n",
h.event,
h.file.display()
));
}
for g in &manifest.gitignore {
out.push_str(&format!(" added {} to {}\n", g.line, g.file.display()));
}
for h in &manifest.git_hooks {
out.push_str(&format!(" added git hook {}\n", h.display()));
}
if manifest.codex {
out.push_str(&format!(" added codex mcp server {SERVER_NAME}\n"));
}
if anything_written {
out.push_str(&format!(" manifest {}\n", manifest_path.display()));
let label = if opts.delivery.wires_mcp() {
"mcp command"
} else {
"command"
};
out.push_str(&format!(" {label} {}\n", cmd.shell()));
out.push_str(&describe_stores(&stores));
} else {
out.push_str(" (already installed — no changes)\n");
}
for n in ¬es {
out.push_str(&format!(" {n}\n"));
}
out.push_str(&format!(
"next: restart Claude Code in {}, then type /mushroom\n",
project_root.display()
));
Ok(out)
}
fn write_everything(
ctx: &Ctx<'_>,
stores: &[(Platform, StoreRef)],
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let platforms: Vec<Platform> = stores.iter().map(|(p, _)| p.clone()).collect();
if let Some(w) = scope_conflict_note(ctx, &platforms) {
notes.push(w);
}
for (plat, store) in stores {
install_platform(ctx, plat, store, manifest, notes)?;
}
let repo_wiring = platforms
.iter()
.any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
if ctx.scope == Scope::Project && repo_wiring {
ensure_gitignore_line(ctx, manifest)?;
if ctx.git_hooks {
install_git_hooks(ctx, manifest)?;
}
}
if let Some(w) = prewarm(ctx) {
notes.push(w);
}
Ok(())
}
fn locate_manifest(
project_root: &Path,
home: &Path,
scope: Scope,
auto_scope: bool,
platforms: &[Platform],
verb: &str,
) -> Result<(Scope, PathBuf), CliError> {
let mut scope = scope;
let mut path = manifest_path(project_root, home, scope, platforms);
if auto_scope && !path.exists() {
let other = match scope {
Scope::Project => Scope::User,
Scope::User => Scope::Project,
};
let alt = manifest_path(project_root, home, other, platforms);
if alt.exists() {
scope = other;
path = alt;
}
}
if !path.exists() {
return Err(CliError(format!(
"no install manifest found at {} — nothing to {verb}",
path.display()
)));
}
Ok((scope, path))
}
pub fn run_uninstall(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
run_uninstall_with(project_root, home, opts, &Externals::from_env())
}
pub fn run_uninstall_with(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let (scope, manifest_path) = locate_manifest(
project_root,
home,
scope,
auto_scope,
&platforms,
"uninstall",
)?;
let manifest = load_manifest(&manifest_path);
let mut removed = Vec::new();
for key in &manifest.mcp_keys {
if remove_mcp_key(&key.file, &key.server)? {
removed.push(format!(
"removed mcpServers.{} from {}",
key.server,
key.file.display()
));
}
}
for h in &manifest.hooks {
if remove_hook_entry(&h.file, &h.event, &h.command)? {
removed.push(format!(
"removed {} hook from {}",
h.event,
h.file.display()
));
}
}
for h in &manifest.git_hooks {
if remove_git_hook(h)? {
removed.push(format!("removed git hook block from {}", h.display()));
}
}
for g in &manifest.gitignore {
if remove_line(&g.file, &g.line)? {
removed.push(format!("removed {} from {}", g.line, g.file.display()));
}
if g.created && g.file.exists() && file_is_blank(&g.file) {
fs::remove_file(&g.file)
.map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
removed.push(format!("removed {}", g.file.display()));
}
}
if manifest.codex {
remove_codex(ext, &mut removed, "removed")?;
}
for f in &manifest.files {
if f.exists() {
fs::remove_file(f)
.map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
removed.push(format!("removed {}", f.display()));
}
}
if manifest_path.exists() {
fs::remove_file(&manifest_path)
.map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
}
let mut out = "mushroomdb uninstalled\n".to_string();
out.push_str(&format!(
" scope {}{}\n",
scope.label(),
if auto_scope { " (auto-detected)" } else { "" }
));
for line in &removed {
out.push_str(&format!(" {line}\n"));
}
Ok(out)
}
fn scope_dir(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
match scope {
Scope::Project => project_root.to_path_buf(),
Scope::User => home.to_path_buf(),
}
}
fn read_mcp_entry(mcp_file: &Path, server: &str) -> Result<Option<serde_json::Value>, CliError> {
if !mcp_file.exists() {
return Ok(None);
}
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
let root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
let entry = &root["mcpServers"][server];
Ok(if entry.is_null() {
None
} else {
Some(entry.clone())
})
}
fn store_from_arg(arg: &str, project_root: &Path, home: &Path) -> StoreRef {
if arg == AUTO_ARG {
return StoreRef::auto(crate::resolve_auto_db(None, project_root, home));
}
let path = PathBuf::from(arg);
if path == default_db(Scope::Project, project_root, home) {
return StoreRef::pinned(path).also_auto();
}
StoreRef::pinned(path)
}
fn hook_command_store_arg(command: &str, sub: &str) -> Option<String> {
let needle = format!(" {sub} ");
let at = command.rfind(&needle)?;
let arg = command[at + needle.len()..].trim();
if arg.is_empty() {
return None;
}
Some(
match arg.strip_prefix('\'').and_then(|a| a.strip_suffix('\'')) {
Some(inner) => inner.replace(r"'\''", "'"),
None => arg.to_string(),
},
)
}
fn store_from_hooks(manifest: &Manifest, project_root: &Path, home: &Path) -> Option<StoreRef> {
manifest
.hooks
.iter()
.find(|h| h.event == BRIEF_EVENT)
.and_then(|h| hook_command_store_arg(&h.command, "brief"))
.map(|arg| store_from_arg(&arg, project_root, home))
}
pub(crate) fn installed_shape(
project_root: &Path,
home: &Path,
scope: Scope,
platforms: &[Platform],
) -> (Delivery, Option<StoreRef>) {
let manifest = load_manifest(&manifest_path(project_root, home, scope, platforms));
let store = store_from_hooks(&manifest, project_root, home);
(manifest.delivery, store)
}
fn platform_mcp_file(
platform: &Platform,
project_root: &Path,
home: &Path,
scope: Scope,
) -> Option<PathBuf> {
match platform {
Platform::ClaudeCode => Some(claude_mcp_file(project_root, home, scope)),
Platform::Cursor => Some(cursor_mcp_file(project_root, home, scope)),
Platform::Codex | Platform::All => None,
}
}
fn recover_store_for(
manifest: &Manifest,
platform: &Platform,
project_root: &Path,
home: &Path,
scope: Scope,
) -> StoreRef {
platform_mcp_file(platform, project_root, home, scope)
.and_then(|file| manifest.stashed_mcp.iter().find(|s| s.file == file))
.and_then(|s| entry_db(&s.entry))
.map(|arg| store_from_arg(arg, project_root, home))
.or_else(|| match platform {
Platform::ClaudeCode => store_from_hooks(manifest, project_root, home),
_ => None,
})
.unwrap_or_else(|| {
store_ref(
project_root,
home,
scope,
None,
resolves_at_runtime(platform),
)
})
}
fn repo_store_for_enable(stores: &[(Platform, StoreRef)]) -> StoreRef {
stores
.iter()
.find(|(p, _)| resolves_at_runtime(p))
.or_else(|| stores.first())
.map(|(_, s)| s.clone())
.expect("enable always resolves at least one platform")
}
pub fn run_disable(
project_root: &Path,
home: &Path,
opts: &ToggleOpts,
) -> Result<String, CliError> {
run_disable_with(project_root, home, opts, &Externals::from_env())
}
pub fn run_disable_with(
project_root: &Path,
home: &Path,
opts: &ToggleOpts,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let (scope, manifest_path) =
locate_manifest(project_root, home, scope, auto_scope, &platforms, "disable")?;
let mut manifest = load_manifest(&manifest_path);
let dir = scope_dir(project_root, home, scope);
if manifest.disabled {
return Ok(format!(
"mushroomdb is already disabled in {}\n",
dir.display()
));
}
let mut changed = Vec::new();
let mut stashed = Vec::new();
for key in &manifest.mcp_keys {
let Some(entry) = read_mcp_entry(&key.file, &key.server)? else {
continue;
};
stashed.push(StashedMcpEntry {
file: key.file.clone(),
server: key.server.clone(),
entry,
});
if remove_mcp_key(&key.file, &key.server)? {
changed.push(format!(
"disabled mcpServers.{} in {}",
key.server,
key.file.display()
));
}
}
for h in &manifest.hooks {
if remove_hook_entry(&h.file, &h.event, &h.command)? {
changed.push(format!(
"disabled {} hook in {}",
h.event,
h.file.display()
));
}
}
for h in &manifest.git_hooks {
if remove_git_hook(h)? {
changed.push(format!("disabled git hook block in {}", h.display()));
}
}
if manifest.codex {
remove_codex(ext, &mut changed, "disabled")?;
}
manifest.disabled = true;
manifest.stashed_mcp = stashed;
write_manifest(&manifest_path, &manifest)?;
let mut out = String::new();
for line in &changed {
out.push_str(line);
out.push('\n');
}
out.push_str(&format!(
"mushroomdb is disabled in {}; enable with: mushroomdb enable\n",
dir.display()
));
Ok(out)
}
pub fn run_enable(project_root: &Path, home: &Path, opts: &ToggleOpts) -> Result<String, CliError> {
run_enable_with(
project_root,
home,
opts,
&detect_mcp_command(None),
&Externals::from_env(),
)
}
pub fn run_enable_with(
project_root: &Path,
home: &Path,
opts: &ToggleOpts,
cmd: &McpCommand,
ext: &Externals,
) -> Result<String, CliError> {
let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let (scope, manifest_path) =
locate_manifest(project_root, home, scope, auto_scope, &platforms, "enable")?;
let mut manifest = load_manifest(&manifest_path);
let dir = scope_dir(project_root, home, scope);
if !manifest.disabled {
return Ok(format!(
"mushroomdb is already enabled in {}\n",
dir.display()
));
}
let mut notes: Vec<String> = Vec::new();
let base_cmd = match manifest.requested_cmd.clone() {
Some(StoredCommand::Explicit(p)) if is_bare_program_name(&p) || p.is_file() => {
McpCommand::Explicit(p)
}
Some(StoredCommand::Explicit(p)) => {
notes.push(format!(
"warning: the pinned command {} no longer exists — re-detected the command instead",
p.display()
));
cmd.clone()
}
Some(other) => other.into_mcp(),
None => cmd.clone(),
};
let base_cmd = match &base_cmd {
McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
other => other.clone(),
};
let (cmd, launcher_note, _) = resolve_fast_command(&base_cmd, ext);
let cmd = &cmd;
notes.extend(launcher_note);
let stores: Vec<(Platform, StoreRef)> = platforms
.iter()
.map(|p| {
(
p.clone(),
recover_store_for(&manifest, p, project_root, home, scope),
)
})
.collect();
let repo_store = repo_store_for_enable(&stores);
let had_git_hooks = !manifest.git_hooks.is_empty();
let ctx = Ctx {
project_root,
home,
scope,
repo_store: &repo_store,
cmd,
ext,
git_hooks: true,
prewarm: false,
delivery: manifest.delivery,
intercept_grep: manifest.intercept_grep,
impact_before_edit: manifest.impact_before_edit,
enrich_grep: manifest.enrich_grep,
always_load: manifest.always_load,
};
let mut fresh = Manifest::default();
for (plat, store) in &stores {
match plat {
Platform::ClaudeCode => install_claude_code(&ctx, store, &mut fresh, &mut notes)?,
Platform::Cursor => install_cursor(&ctx, store, &mut fresh, &mut notes)?,
Platform::Codex => install_codex(&ctx, store, &mut fresh)?,
Platform::All => unreachable!("expand_platform never produces All"),
}
}
let repo_wiring = platforms
.iter()
.any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
if had_git_hooks && scope == Scope::Project && repo_wiring {
install_git_hooks(&ctx, &mut fresh)?;
}
let touched_mcp: Vec<&PathBuf> = fresh.mcp_keys.iter().map(|k| &k.file).collect();
manifest
.mcp_keys
.retain(|k| !touched_mcp.contains(&&k.file));
manifest.mcp_keys.extend(fresh.mcp_keys.iter().cloned());
let touched_hooks: Vec<(&PathBuf, &str)> = fresh
.hooks
.iter()
.map(|h| (&h.file, h.event.as_str()))
.collect();
manifest
.hooks
.retain(|h| !touched_hooks.contains(&(&h.file, h.event.as_str())));
manifest.hooks.extend(fresh.hooks.iter().cloned());
if !fresh.git_hooks.is_empty() {
manifest.git_hooks = fresh.git_hooks.clone();
}
manifest.codex |= fresh.codex;
for f in &fresh.files {
if !manifest.files.contains(f) {
manifest.files.push(f.clone());
}
}
manifest.disabled = false;
manifest.stashed_mcp.clear();
manifest.requested_cmd = StoredCommand::from_mcp(&base_cmd);
write_manifest(&manifest_path, &manifest)?;
let mut out = String::new();
for k in &fresh.mcp_keys {
out.push_str(&format!(
"enabled mcpServers.{} in {}\n",
k.server,
k.file.display()
));
}
for h in &fresh.hooks {
out.push_str(&format!(
"enabled {} hook in {}\n",
h.event,
h.file.display()
));
}
for g in &fresh.git_hooks {
out.push_str(&format!("enabled git hook {}\n", g.display()));
}
if fresh.codex {
out.push_str(&format!("enabled codex mcp server {SERVER_NAME}\n"));
}
for n in ¬es {
out.push_str(&format!(" {n}\n"));
}
out.push_str(&format!("mushroomdb is enabled in {}\n", dir.display()));
Ok(out)
}
pub(crate) fn resolve_platform(
project_root: &Path,
home: &Path,
requested: Option<&Platform>,
) -> Result<Platform, CliError> {
if let Some(p) = requested {
return Ok(p.clone());
}
let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
match (has_claude, has_cursor) {
(true, true) => Ok(Platform::All),
(true, false) => Ok(Platform::ClaudeCode),
(false, true) => Ok(Platform::Cursor),
(false, false) => Err(CliError(
"cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
.to_string(),
)),
}
}
pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
match p {
Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
other => vec![other.clone()],
}
}
fn preflight_check(
project_root: &Path,
home: &Path,
platform: &Platform,
scope: Scope,
store: &StoreRef,
ext: &Externals,
) -> Result<(), CliError> {
match platform {
Platform::ClaudeCode => {
check_mcp_conflict(&claude_mcp_file(project_root, home, scope), store)
}
Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), store),
Platform::Codex => codex_bin(ext).map(|_| ()),
Platform::All => unreachable!("expand_platform never produces All"),
}
}
pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
match scope {
Scope::Project => project_root.join(".mcp.json"),
Scope::User => home.join(".claude.json"),
}
}
pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
match scope {
Scope::Project => project_root.join(".cursor").join("mcp.json"),
Scope::User => home.join(".cursor").join("mcp.json"),
}
}
pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
let args = entry["args"].as_array()?;
let at = args.iter().position(|a| a == "mcp")?;
args.get(at + 1)?.as_str()
}
fn check_mcp_conflict(mcp_file: &Path, store: &StoreRef) -> Result<(), CliError> {
if !mcp_file.exists() {
return Ok(());
}
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
let v: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
let existing = &v["mcpServers"][SERVER_NAME];
if existing.is_null() {
return Ok(()); }
let existing_db = entry_db(existing).unwrap_or("");
if store.names_same_store(existing_db) {
return Ok(()); }
Err(CliError(format!(
"conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
To update it, run `mushroomdb uninstall` first, then re-install.\n\
Or manually edit {} and remove the existing mushroomdb entry.",
mcp_file.display(),
existing_db,
mcp_file.display()
)))
}
fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
if !platforms.contains(&Platform::ClaudeCode) {
return None;
}
let (other, label, flag) = match ctx.scope {
Scope::Project => (
claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
"user",
"--user",
),
Scope::User => (
claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
"project",
"--project",
),
};
if !has_our_server(&other) {
return None;
}
Some(format!(
"warning: a {label}-scope mushroomdb server also exists ({}) — \
both will load; to remove that one run: mushroomdb uninstall {flag}",
other.display()
))
}
pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
let Ok(raw) = fs::read_to_string(mcp_file) else {
return false;
};
serde_json::from_str::<serde_json::Value>(&raw)
.map(|v| !v["mcpServers"][SERVER_NAME].is_null())
.unwrap_or(false)
}
fn install_platform(
ctx: &Ctx<'_>,
platform: &Platform,
store: &StoreRef,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
if !ctx.delivery.wires_mcp() && !matches!(platform, Platform::ClaudeCode) {
notes.push(format!(
"note: --delivery {} applies to claude-code only — {} was registered as an MCP server",
ctx.delivery.label(),
platform.label()
));
}
match platform {
Platform::ClaudeCode => install_claude_code(ctx, store, manifest, notes),
Platform::Cursor => install_cursor(ctx, store, manifest, notes),
Platform::Codex => install_codex(ctx, store, manifest),
Platform::All => unreachable!("expand_platform never produces All"),
}
}
pub fn render_template(
template: &str,
db_str: &str,
bin_cmd: &str,
delivery: Delivery,
) -> Result<String, CliError> {
fn marker(line: &str) -> Option<(&'static str, bool)> {
match line {
"<!-- cli -->" => Some(("cli", true)),
"<!-- mcp -->" => Some(("mcp", true)),
"<!-- /cli -->" => Some(("cli", false)),
"<!-- /mcp -->" => Some(("mcp", false)),
_ => None,
}
}
let mut out = String::with_capacity(template.len());
let mut open: Option<(&str, usize)> = None;
let mut dropping = false;
for (i, line) in template.lines().enumerate() {
let at = i + 1;
match marker(line) {
Some((name, true)) => {
if let Some((outer, opened)) = open {
return Err(CliError(format!(
"skill template line {at}: <!-- {name} --> opens inside the \
<!-- {outer} --> region opened on line {opened} — delivery \
regions must not nest"
)));
}
open = Some((name, at));
dropping = match name {
"cli" => matches!(delivery, Delivery::Mcp),
_ => matches!(delivery, Delivery::Cli),
};
}
Some((name, false)) => {
match open {
None => {
return Err(CliError(format!(
"skill template line {at}: <!-- /{name} --> closes a region \
that was never opened"
)))
}
Some((outer, opened)) if outer != name => {
return Err(CliError(format!(
"skill template line {at}: <!-- /{name} --> closes the \
<!-- {outer} --> region opened on line {opened}"
)))
}
Some(_) => {}
}
open = None;
dropping = false;
}
None if dropping => {}
None => {
out.push_str(line);
out.push('\n');
}
}
}
if let Some((name, opened)) = open {
return Err(CliError(format!(
"skill template: the <!-- {name} --> region opened on line {opened} is \
never closed"
)));
}
Ok(out
.replace(DB_PATH_PLACEHOLDER, db_str)
.replace(BIN_PLACEHOLDER, bin_cmd))
}
fn install_claude_code(
ctx: &Ctx<'_>,
store: &StoreRef,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let shell = ctx.cmd.shell();
let db_str = store.path().to_string_lossy();
let skill_content = render_template(SKILL_TEMPLATE, &db_str, &shell, ctx.delivery)?;
let skill_dir = match ctx.scope {
Scope::Project => ctx
.project_root
.join(".claude")
.join("skills")
.join("mushroom"),
Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
};
let skill_file = skill_dir.join("SKILL.md");
if !file_matches(&skill_file, &skill_content) {
fs::create_dir_all(&skill_dir)
.map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
fs::write(&skill_file, &skill_content)
.map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
manifest.files.push(skill_file);
}
let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
if ctx.delivery.wires_mcp() {
merge_mcp_entry(&mcp_file, ctx, store, ctx.always_load, manifest, notes)?;
} else if remove_mcp_key(&mcp_file, SERVER_NAME)? {
notes.push(format!(
"removed mcpServers.{SERVER_NAME} from {} — delivery: {}",
mcp_file.display(),
ctx.delivery.label()
));
}
let settings_file = match ctx.scope {
Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
Scope::User => ctx.home.join(".claude").join("settings.json"),
};
let recall = hook_command(&shell, "recall", store);
if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", store, &recall)? {
notes.push(format!("replaced stale {HOOK_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
HOOK_EVENT,
&recall,
hook_entry(&recall),
manifest,
)?;
let touch = hook_command(&shell, "touch", store);
if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", store, &touch)? {
notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
TOUCH_EVENT,
&touch,
matched_hook_entry(TOUCH_MATCHER, &touch, TOUCH_TIMEOUT_SECS, true),
manifest,
)?;
let brief = hook_command(&shell, "brief", store);
if remove_stale_hooks(&settings_file, BRIEF_EVENT, "brief", store, &brief)? {
notes.push(format!("replaced stale {BRIEF_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
BRIEF_EVENT,
&brief,
hook_entry(&brief),
manifest,
)?;
let intercept = hook_command(&shell, "intercept", store);
if ctx.intercept_grep {
if remove_stale_hooks(
&settings_file,
INTERCEPT_EVENT,
"intercept",
store,
&intercept,
)? {
notes.push(format!("replaced stale {INTERCEPT_EVENT} hook"));
}
merge_hook_entry(
&settings_file,
INTERCEPT_EVENT,
&intercept,
matched_hook_entry(INTERCEPT_MATCHER, &intercept, HOOK_TIMEOUT_SECS, false),
manifest,
)?;
} else if drop_hooks(&settings_file, INTERCEPT_EVENT, |c| {
is_our_hook_command(c, "intercept", store)
})? {
notes.push(format!(
"removed {INTERCEPT_EVENT} hook — no --intercept-grep"
));
}
let impact = hook_command(&shell, "impact-hook", store);
if ctx.impact_before_edit {
if remove_stale_hooks(&settings_file, IMPACT_EVENT, "impact-hook", store, &impact)? {
notes.push(format!("replaced stale {IMPACT_EVENT} impact hook"));
}
merge_hook_entry(
&settings_file,
IMPACT_EVENT,
&impact,
matched_hook_entry(IMPACT_MATCHER, &impact, HOOK_TIMEOUT_SECS, false),
manifest,
)?;
} else if drop_hooks(&settings_file, IMPACT_EVENT, |c| {
is_our_hook_command(c, "impact-hook", store)
})? {
notes.push(format!(
"removed {IMPACT_EVENT} impact hook — no --impact-before-edit"
));
}
let enrich = hook_command(&shell, "enrich", store);
if ctx.enrich_grep {
if remove_stale_hooks(&settings_file, ENRICH_EVENT, "enrich", store, &enrich)? {
notes.push(format!("replaced stale {ENRICH_EVENT} enrichment hook"));
}
merge_hook_entry(
&settings_file,
ENRICH_EVENT,
&enrich,
matched_hook_entry(ENRICH_MATCHER, &enrich, HOOK_TIMEOUT_SECS, false),
manifest,
)?;
} else if drop_hooks(&settings_file, ENRICH_EVENT, |c| {
is_our_hook_command(c, "enrich", store)
})? {
notes.push(format!(
"removed {ENRICH_EVENT} enrichment hook — no --enrich-grep"
));
}
Ok(())
}
fn install_cursor(
ctx: &Ctx<'_>,
store: &StoreRef,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let db_str = store.path().to_string_lossy();
let rules_content = render_template(
CURSOR_RULES_TEMPLATE,
&db_str,
&ctx.cmd.shell(),
Delivery::Mcp,
)?;
let rules_dir = match ctx.scope {
Scope::Project => ctx.project_root.join(".cursor").join("rules"),
Scope::User => ctx.home.join(".cursor").join("rules"),
};
let rules_file = rules_dir.join("mushroom.mdc");
if !file_matches(&rules_file, &rules_content) {
fs::create_dir_all(&rules_dir)
.map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
fs::write(&rules_file, &rules_content)
.map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
manifest.files.push(rules_file);
}
let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
merge_mcp_entry(&mcp_file, ctx, store, false, manifest, notes)?;
Ok(())
}
fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
ext.which("codex").ok_or_else(|| {
CliError(
"codex was not found on PATH — install the Codex CLI, or drop \
`--platform codex`"
.to_string(),
)
})
}
fn remove_codex(ext: &Externals, out: &mut Vec<String>, verb: &str) -> Result<(), CliError> {
match ext.which("codex") {
Some(bin) => {
run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
.map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
out.push(format!("{verb} codex mcp server {SERVER_NAME}"));
}
None => out.push(
"warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
.to_string(),
),
}
Ok(())
}
fn install_codex(ctx: &Ctx<'_>, store: &StoreRef, manifest: &mut Manifest) -> Result<(), CliError> {
let bin = codex_bin(ctx.ext)?;
let mut args = vec![
"mcp".to_string(),
"add".to_string(),
SERVER_NAME.to_string(),
"--".to_string(),
];
args.extend(ctx.cmd.argv("mcp", &store.arg()));
run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
manifest.codex = true;
Ok(())
}
pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
fn gitignore_line(project_root: &Path, db: &Path) -> Option<String> {
let rel = db.strip_prefix(project_root).ok()?;
if rel.as_os_str().is_empty() {
return None;
}
Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
}
fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
let Some(line) = gitignore_line(ctx.project_root, ctx.repo_store.path()) else {
return Ok(());
};
let path = ctx.project_root.join(".gitignore");
let existed = path.exists();
let current = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
};
let bare = line.trim_end_matches('/');
if current
.lines()
.map(str::trim)
.any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
{
return Ok(());
}
let mut next = current;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(&line);
next.push('\n');
fs::write(&path, next)
.map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
manifest.gitignore.push(ManagedLine {
file: path,
line,
created: !existed,
});
Ok(())
}
fn file_is_blank(path: &Path) -> bool {
match fs::read_to_string(path) {
Ok(s) => s.trim().is_empty(),
Err(_) => true,
}
}
fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
let Ok(current) = fs::read_to_string(path) else {
return Ok(false);
};
if !current.lines().any(|l| l == line) {
return Ok(false);
}
let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
let mut next = kept.join("\n");
if !next.is_empty() {
next.push('\n');
}
fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
Ok(true)
}
pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
let dot_git = project_root.join(".git");
if dot_git.is_dir() {
return Some(dot_git.join("hooks"));
}
let text = fs::read_to_string(&dot_git).ok()?;
let target = text.strip_prefix("gitdir:")?.trim();
let target = Path::new(target);
let resolved = if target.is_absolute() {
target.to_path_buf()
} else {
project_root.join(target)
};
let base = match fs::read_to_string(resolved.join("commondir")) {
Ok(rel) => {
let rel_path = PathBuf::from(rel.trim());
if rel_path.is_absolute() {
rel_path
} else {
lexically_normalize(&resolved.join(rel_path))
}
}
Err(_) => resolved,
};
Some(base.join("hooks"))
}
fn lexically_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
let can_pop = out
.components()
.next_back()
.is_some_and(|c| matches!(c, std::path::Component::Normal(_)));
if !can_pop || !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
let Some(dir) = git_hooks_dir(ctx.project_root) else {
return Ok(());
};
let shell = ctx.cmd.shell();
for name in GIT_HOOKS {
let file = dir.join(name);
if merge_git_hook(&file, &shell, ctx.repo_store)? {
manifest.git_hooks.push(file);
}
}
Ok(())
}
fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
if !ctx.prewarm {
return None;
}
let McpCommand::Npx { version } = ctx.cmd else {
return None;
};
let args = vec![
"-y".to_string(),
format!("{NPM_PACKAGE}@{version}"),
"--version".to_string(),
];
let Some(npx) = ctx.ext.which("npx") else {
return Some(
"warning: pre-warm skipped — npx is not on PATH; the first MCP \
spawn will download the package"
.to_string(),
);
};
match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
Ok(()) => None,
Err(e) => Some(format!(
"warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
the first MCP spawn will download the package"
)),
}
}
fn merge_mcp_entry(
mcp_file: &Path,
ctx: &Ctx<'_>,
store: &StoreRef,
always_load: bool,
manifest: &mut Manifest,
notes: &mut Vec<String>,
) -> Result<(), CliError> {
let mut root: serde_json::Value = if mcp_file.exists() {
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
serde_json::from_str(&raw)
.map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
} else {
serde_json::json!({})
};
if !root["mcpServers"].is_object() {
root["mcpServers"] = serde_json::json!({});
}
let mut desired = ctx.cmd.json_entry("mcp", &store.arg());
if always_load {
desired["alwaysLoad"] = serde_json::Value::Bool(true);
}
let existing = &root["mcpServers"][SERVER_NAME];
if existing == &desired {
return Ok(()); }
let replaced = !existing.is_null();
root["mcpServers"][SERVER_NAME] = desired;
let parent = mcp_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
fs::write(mcp_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
manifest.mcp_keys.push(ManagedMcpKey {
file: mcp_file.to_path_buf(),
server: SERVER_NAME.to_string(),
});
if replaced {
notes.push(format!(
"updated mcp command in {} → {} mcp {}",
mcp_file.display(),
ctx.cmd.shell(),
store.arg()
));
}
Ok(())
}
fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
if !mcp_file.exists() {
return Ok(false);
}
let raw = fs::read_to_string(mcp_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
let mut root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
let removed = root["mcpServers"]
.as_object_mut()
.is_some_and(|servers| servers.remove(server).is_some());
if !removed {
return Ok(false);
}
let json = serde_json::to_string_pretty(&root)
.map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
fs::write(mcp_file, json)
.map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
Ok(true)
}
pub(crate) fn manifest_path(
project_root: &Path,
home: &Path,
scope: Scope,
platforms: &[Platform],
) -> PathBuf {
if platforms == [Platform::Codex] {
return home.join(".mushroomdb").join("install-manifest-codex.json");
}
if scope == Scope::User {
return home.join(".mushroomdb").join("install-manifest.json");
}
if platforms.contains(&Platform::ClaudeCode) {
project_root
.join(".claude")
.join("skills")
.join("mushroom")
.join(".install-manifest.json")
} else {
project_root.join(".cursor").join(".install-manifest.json")
}
}
fn load_manifest(path: &Path) -> Manifest {
let raw = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Manifest::default(),
};
serde_json::from_str::<Manifest>(&raw)
.unwrap_or_default()
.sanitised()
}
pub fn delivery_for_store(db_dir: &Path) -> Delivery {
let Some(parent) = db_dir.parent() else {
return Delivery::default();
};
let candidates = [
parent
.join(".claude")
.join("skills")
.join("mushroom")
.join(".install-manifest.json"),
parent.join("install-manifest.json"),
];
for candidate in candidates {
if candidate.is_file() {
return load_manifest(&candidate).delivery;
}
}
Delivery::default()
}
pub(crate) fn is_disabled(
project_root: &Path,
home: &Path,
scope: Scope,
platforms: &[Platform],
) -> bool {
load_manifest(&manifest_path(project_root, home, scope, platforms)).disabled
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct OptIns {
pub(crate) intercept_grep: bool,
pub(crate) impact_before_edit: bool,
pub(crate) enrich_grep: bool,
pub(crate) always_load: bool,
}
pub(crate) fn opt_ins(
project_root: &Path,
home: &Path,
scope: Scope,
platforms: &[Platform],
) -> OptIns {
let m = load_manifest(&manifest_path(project_root, home, scope, platforms));
OptIns {
intercept_grep: m.intercept_grep,
impact_before_edit: m.impact_before_edit,
enrich_grep: m.enrich_grep,
always_load: m.always_load,
}
}
fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
for f in &this_run.files {
if !existing.files.contains(f) {
existing.files.push(f.clone());
}
}
for k in &this_run.mcp_keys {
let already = existing
.mcp_keys
.iter()
.any(|e| e.file == k.file && e.server == k.server);
if !already {
existing.mcp_keys.push(k.clone());
}
}
for h in &this_run.hooks {
if !existing.hooks.contains(h) {
existing.hooks.push(h.clone());
}
}
for h in &this_run.git_hooks {
if !existing.git_hooks.contains(h) {
existing.git_hooks.push(h.clone());
}
}
for l in &this_run.gitignore {
if !existing.gitignore.contains(l) {
existing.gitignore.push(l.clone());
}
}
existing.codex |= this_run.codex;
if let Some(c) = &this_run.requested_cmd {
existing.requested_cmd = Some(c.clone());
}
existing.delivery = this_run.delivery;
existing.intercept_grep = this_run.intercept_grep;
existing.impact_before_edit = this_run.impact_before_edit;
existing.enrich_grep = this_run.enrich_grep;
existing.always_load = this_run.always_load;
existing
}
fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
let parent = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent).map_err(|e| {
CliError(format!(
"cannot create manifest dir {}: {e}",
parent.display()
))
})?;
let json = serde_json::to_string_pretty(manifest)
.map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
fs::write(path, json)
.map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
Ok(())
}
pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
pub const HOOK_END: &str = "# <<< mushroomdb <<<";
const HOOK_SHEBANG: &str = "#!/bin/sh";
#[must_use]
pub fn git_hook_block(shell: &str, store: &StoreRef) -> String {
format!(
"{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
store.shell_arg()
)
}
enum Stripped {
Absent,
Removed(String),
Unterminated,
}
fn strip_hook_block(text: &str) -> Stripped {
let mut kept: Vec<&str> = Vec::new();
let mut inside = false;
let mut found = false;
for line in text.lines() {
if !inside && line.trim_end() == HOOK_BEGIN {
inside = true;
found = true;
continue;
}
if inside {
if line.trim_end() == HOOK_END {
inside = false;
}
continue;
}
kept.push(line);
}
if !found {
return Stripped::Absent;
}
if inside {
return Stripped::Unterminated;
}
while kept.last().is_some_and(|l| l.trim().is_empty()) {
kept.pop();
}
let mut out = kept.join("\n");
if !out.is_empty() {
out.push('\n');
}
Stripped::Removed(out)
}
fn unterminated(hook_file: &Path) -> CliError {
CliError(format!(
"{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
— refusing to edit it; delete the block by hand and re-run",
hook_file.display()
))
}
fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
let base = match existing {
None => String::new(),
Some(text) => match strip_hook_block(text) {
Stripped::Absent => text.to_string(),
Stripped::Removed(rest) => rest,
Stripped::Unterminated => return Err(()),
},
};
let mut lines: Vec<&str> = base.lines().collect();
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
lines.push(HOOK_SHEBANG);
}
let mut out = lines.join("\n");
out.push_str("\n\n");
out.push_str(block);
Ok(out)
}
fn only_a_shebang(text: &str) -> bool {
text.lines()
.filter(|l| !l.trim().is_empty())
.all(|l| l.starts_with("#!"))
}
pub fn merge_git_hook(hook_file: &Path, shell: &str, store: &StoreRef) -> Result<bool, CliError> {
let existing = if hook_file.exists() {
Some(
fs::read_to_string(hook_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
)
} else {
None
};
let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, store))
.map_err(|()| unterminated(hook_file))?;
if existing.as_deref() == Some(next.as_str()) {
return Ok(false);
}
let parent = hook_file.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
fs::write(hook_file, &next)
.map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
CliError(format!(
"cannot make {} executable: {e}",
hook_file.display()
))
})?;
}
Ok(true)
}
pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
if !hook_file.exists() {
return Ok(false);
}
let existing = fs::read_to_string(hook_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
let next = match strip_hook_block(&existing) {
Stripped::Absent => return Ok(false),
Stripped::Removed(rest) => rest,
Stripped::Unterminated => return Err(unterminated(hook_file)),
};
if only_a_shebang(&next) {
fs::remove_file(hook_file)
.map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
return Ok(true);
}
fs::write(hook_file, next)
.map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
Ok(true)
}
fn file_matches(path: &Path, expected: &str) -> bool {
fs::read_to_string(path)
.map(|s| s == expected)
.unwrap_or(false)
}