use crate::CliError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
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 SERVER_NAME: &str = "mushroomdb";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Platform {
ClaudeCode,
Cursor,
All,
}
impl Platform {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"claude-code" => Ok(Platform::ClaudeCode),
"cursor" => Ok(Platform::Cursor),
"all" => Ok(Platform::All),
other => Err(format!(
"--platform must be claude-code | cursor | all, got: {other}"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOpts {
pub platform: Option<Platform>,
pub project: bool,
pub db: Option<PathBuf>,
}
impl InstallOpts {
pub fn default_db(&self, project_root: &Path, home: &Path) -> PathBuf {
if self.project {
project_root.join("mushroom-memory")
} else {
home.join(".mushroomdb").join("memory")
}
}
}
#[derive(Serialize, Deserialize, Default, Debug)]
struct Manifest {
files: Vec<PathBuf>,
mcp_keys: Vec<ManagedMcpKey>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct ManagedMcpKey {
file: PathBuf,
server: String,
}
pub fn run_install(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
let db = opts
.db
.clone()
.unwrap_or_else(|| opts.default_db(project_root, home));
let db_str = db.to_string_lossy();
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
for plat in &platforms {
preflight_check(project_root, home, plat, opts.project, &db_str)?;
}
let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
let existing = load_manifest(&manifest_path);
let mut manifest = Manifest::default();
for plat in &platforms {
install_platform(
project_root,
home,
plat,
opts.project,
&db_str,
&mut manifest,
)?;
}
let anything_written = !manifest.files.is_empty() || !manifest.mcp_keys.is_empty();
if anything_written {
let merged = union_manifests(existing, &manifest);
write_manifest(&manifest_path, &merged)?;
}
let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
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()
));
}
if anything_written {
out.push_str(&format!(" manifest {}\n", manifest_path.display()));
} else {
out.push_str(" (already installed — no changes)\n");
}
Ok(out)
}
pub fn run_uninstall(
project_root: &Path,
home: &Path,
opts: &InstallOpts,
) -> Result<String, CliError> {
let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
let platforms = expand_platform(&resolved);
let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
if !manifest_path.exists() {
return Err(CliError(format!(
"no install manifest found at {} — nothing to uninstall",
manifest_path.display()
)));
}
let raw = fs::read_to_string(&manifest_path)
.map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
let manifest: Manifest =
serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
let mut removed = Vec::new();
for key in &manifest.mcp_keys {
if key.file.exists() {
remove_mcp_key(&key.file, &key.server)?;
removed.push(format!(
"removed mcpServers.{} from {}",
key.server,
key.file.display()
));
}
}
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();
for line in &removed {
out.push_str(&format!(" {line}\n"));
}
Ok(out)
}
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, or --platform all."
.to_string(),
)),
}
}
fn expand_platform(p: &Platform) -> Vec<Platform> {
match p {
Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
Platform::ClaudeCode => vec![Platform::ClaudeCode],
Platform::Cursor => vec![Platform::Cursor],
}
}
fn preflight_check(
project_root: &Path,
home: &Path,
platform: &Platform,
project_scope: bool,
db_str: &str,
) -> Result<(), CliError> {
match platform {
Platform::ClaudeCode => {
let mcp_file = if project_scope {
project_root.join(".mcp.json")
} else {
home.join(".claude.json")
};
check_mcp_conflict(&mcp_file, db_str)?;
}
Platform::Cursor => {
let mcp_file = if project_scope {
project_root.join(".cursor").join("mcp.json")
} else {
home.join(".cursor").join("mcp.json")
};
check_mcp_conflict(&mcp_file, db_str)?;
}
Platform::All => unreachable!("expand_platform never produces All"),
}
Ok(())
}
fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> 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_cmd = existing["command"].as_str().unwrap_or("");
let existing_db = existing["args"]
.get(1)
.and_then(|v| v.as_str())
.unwrap_or("");
if existing_cmd == "mushroomdb" && existing_db == db_str {
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 install_platform(
project_root: &Path,
home: &Path,
platform: &Platform,
project_scope: bool,
db_str: &str,
manifest: &mut Manifest,
) -> Result<(), CliError> {
match platform {
Platform::ClaudeCode => {
install_claude_code(project_root, home, project_scope, db_str, manifest)
}
Platform::Cursor => install_cursor(project_root, home, project_scope, db_str, manifest),
Platform::All => unreachable!("expand_platform never produces All"),
}
}
fn install_claude_code(
project_root: &Path,
home: &Path,
project_scope: bool,
db_str: &str,
manifest: &mut Manifest,
) -> Result<(), CliError> {
let skill_content = SKILL_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
let skill_dir = if project_scope {
project_root.join(".claude").join("skills").join("mushroom")
} else {
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 = if project_scope {
project_root.join(".mcp.json")
} else {
home.join(".claude.json")
};
merge_mcp_entry(&mcp_file, db_str, manifest)?;
Ok(())
}
fn install_cursor(
project_root: &Path,
home: &Path,
project_scope: bool,
db_str: &str,
manifest: &mut Manifest,
) -> Result<(), CliError> {
let rules_content = CURSOR_RULES_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
let rules_dir = if project_scope {
project_root.join(".cursor").join("rules")
} else {
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 = if project_scope {
project_root.join(".cursor").join("mcp.json")
} else {
home.join(".cursor").join("mcp.json")
};
merge_mcp_entry(&mcp_file, db_str, manifest)?;
Ok(())
}
fn merge_mcp_entry(mcp_file: &Path, db_str: &str, manifest: &mut Manifest) -> 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 desired = mcp_server_entry(db_str);
let existing = &root["mcpServers"][SERVER_NAME];
if existing == &desired {
return Ok(()); }
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(),
});
Ok(())
}
fn remove_mcp_key(mcp_file: &Path, server: &str) -> 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 mut root: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
if let Some(servers) = root["mcpServers"].as_object_mut() {
servers.remove(server);
}
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(())
}
fn mcp_server_entry(db_str: &str) -> serde_json::Value {
serde_json::json!({
"command": "mushroomdb",
"args": ["mcp", db_str]
})
}
fn manifest_path(
project_root: &Path,
home: &Path,
project_scope: bool,
platforms: &[Platform],
) -> PathBuf {
if !project_scope {
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(&raw).unwrap_or_default()
}
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());
}
}
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(())
}
fn file_matches(path: &Path, expected: &str) -> bool {
fs::read_to_string(path)
.map(|s| s == expected)
.unwrap_or(false)
}