1use crate::CliError;
21use serde::{Deserialize, Serialize};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
29const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
30
31const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
33
34const SERVER_NAME: &str = "mushroomdb";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Platform {
40 ClaudeCode,
41 Cursor,
42 All,
43}
44
45impl Platform {
46 pub fn parse(s: &str) -> Result<Self, String> {
47 match s {
48 "claude-code" => Ok(Platform::ClaudeCode),
49 "cursor" => Ok(Platform::Cursor),
50 "all" => Ok(Platform::All),
51 other => Err(format!(
52 "--platform must be claude-code | cursor | all, got: {other}"
53 )),
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct InstallOpts {
61 pub platform: Option<Platform>,
63 pub project: bool,
65 pub db: Option<PathBuf>,
67}
68
69impl InstallOpts {
70 pub fn default_db(&self, project_root: &Path, home: &Path) -> PathBuf {
71 if self.project {
72 project_root.join("mushroom-memory")
73 } else {
74 home.join(".mushroomdb").join("memory")
75 }
76 }
77}
78
79#[derive(Serialize, Deserialize, Default, Debug)]
84struct Manifest {
85 files: Vec<PathBuf>,
87 mcp_keys: Vec<ManagedMcpKey>,
89}
90
91#[derive(Serialize, Deserialize, Debug, Clone)]
92struct ManagedMcpKey {
93 file: PathBuf,
95 server: String,
97}
98
99pub fn run_install(
109 project_root: &Path,
110 home: &Path,
111 opts: &InstallOpts,
112) -> Result<String, CliError> {
113 let db = opts
114 .db
115 .clone()
116 .unwrap_or_else(|| opts.default_db(project_root, home));
117 let db_str = db.to_string_lossy();
118
119 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
120 let platforms = expand_platform(&resolved);
121
122 for plat in &platforms {
124 preflight_check(project_root, home, plat, opts.project, &db_str)?;
125 }
126
127 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
128
129 let existing = load_manifest(&manifest_path);
134
135 let mut manifest = Manifest::default();
136
137 for plat in &platforms {
138 install_platform(
139 project_root,
140 home,
141 plat,
142 opts.project,
143 &db_str,
144 &mut manifest,
145 )?;
146 }
147
148 let anything_written = !manifest.files.is_empty() || !manifest.mcp_keys.is_empty();
149
150 if anything_written {
151 let merged = union_manifests(existing, &manifest);
153 write_manifest(&manifest_path, &merged)?;
154 }
155
156 let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
157 for f in &manifest.files {
158 out.push_str(&format!(" wrote {}\n", f.display()));
159 }
160 for k in &manifest.mcp_keys {
161 out.push_str(&format!(
162 " added mcpServers.{} in {}\n",
163 k.server,
164 k.file.display()
165 ));
166 }
167 if anything_written {
168 out.push_str(&format!(" manifest {}\n", manifest_path.display()));
169 } else {
170 out.push_str(" (already installed — no changes)\n");
171 }
172 Ok(out)
173}
174
175pub fn run_uninstall(
177 project_root: &Path,
178 home: &Path,
179 opts: &InstallOpts,
180) -> Result<String, CliError> {
181 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
182 let platforms = expand_platform(&resolved);
183
184 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
185 if !manifest_path.exists() {
186 return Err(CliError(format!(
187 "no install manifest found at {} — nothing to uninstall",
188 manifest_path.display()
189 )));
190 }
191
192 let raw = fs::read_to_string(&manifest_path)
193 .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
194 let manifest: Manifest =
195 serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
196
197 let mut removed = Vec::new();
198
199 for key in &manifest.mcp_keys {
201 if key.file.exists() {
202 remove_mcp_key(&key.file, &key.server)?;
203 removed.push(format!(
204 "removed mcpServers.{} from {}",
205 key.server,
206 key.file.display()
207 ));
208 }
209 }
210
211 for f in &manifest.files {
213 if f.exists() {
214 fs::remove_file(f)
215 .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
216 removed.push(format!("removed {}", f.display()));
217 }
218 }
219
220 if manifest_path.exists() {
222 fs::remove_file(&manifest_path)
223 .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
224 }
225
226 let mut out = "mushroomdb uninstalled\n".to_string();
227 for line in &removed {
228 out.push_str(&format!(" {line}\n"));
229 }
230 Ok(out)
231}
232
233fn resolve_platform(
238 project_root: &Path,
239 home: &Path,
240 requested: Option<&Platform>,
241) -> Result<Platform, CliError> {
242 if let Some(p) = requested {
243 return Ok(p.clone());
244 }
245
246 let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
248 let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
249
250 match (has_claude, has_cursor) {
251 (true, true) => Ok(Platform::All),
252 (true, false) => Ok(Platform::ClaudeCode),
253 (false, true) => Ok(Platform::Cursor),
254 (false, false) => Err(CliError(
255 "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
256 Pass --platform claude-code, --platform cursor, or --platform all."
257 .to_string(),
258 )),
259 }
260}
261
262fn expand_platform(p: &Platform) -> Vec<Platform> {
263 match p {
264 Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
265 Platform::ClaudeCode => vec![Platform::ClaudeCode],
266 Platform::Cursor => vec![Platform::Cursor],
267 }
268}
269
270fn preflight_check(
275 project_root: &Path,
276 home: &Path,
277 platform: &Platform,
278 project_scope: bool,
279 db_str: &str,
280) -> Result<(), CliError> {
281 match platform {
282 Platform::ClaudeCode => {
283 let mcp_file = if project_scope {
284 project_root.join(".mcp.json")
285 } else {
286 home.join(".claude.json")
290 };
291 check_mcp_conflict(&mcp_file, db_str)?;
292 }
293 Platform::Cursor => {
294 let mcp_file = if project_scope {
295 project_root.join(".cursor").join("mcp.json")
296 } else {
297 home.join(".cursor").join("mcp.json")
298 };
299 check_mcp_conflict(&mcp_file, db_str)?;
300 }
301 Platform::All => unreachable!("expand_platform never produces All"),
302 }
303 Ok(())
304}
305
306fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
311 if !mcp_file.exists() {
312 return Ok(());
313 }
314 let raw = fs::read_to_string(mcp_file)
315 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
316 let v: serde_json::Value = serde_json::from_str(&raw)
317 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
318
319 let existing = &v["mcpServers"][SERVER_NAME];
320 if existing.is_null() {
321 return Ok(()); }
323
324 let existing_cmd = existing["command"].as_str().unwrap_or("");
326 let existing_db = existing["args"]
327 .get(1)
328 .and_then(|v| v.as_str())
329 .unwrap_or("");
330
331 if existing_cmd == "mushroomdb" && existing_db == db_str {
332 return Ok(()); }
334
335 Err(CliError(format!(
336 "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
337 To update it, run `mushroomdb uninstall` first, then re-install.\n\
338 Or manually edit {} and remove the existing mushroomdb entry.",
339 mcp_file.display(),
340 existing_db,
341 mcp_file.display()
342 )))
343}
344
345fn install_platform(
350 project_root: &Path,
351 home: &Path,
352 platform: &Platform,
353 project_scope: bool,
354 db_str: &str,
355 manifest: &mut Manifest,
356) -> Result<(), CliError> {
357 match platform {
358 Platform::ClaudeCode => {
359 install_claude_code(project_root, home, project_scope, db_str, manifest)
360 }
361 Platform::Cursor => install_cursor(project_root, home, project_scope, db_str, manifest),
362 Platform::All => unreachable!("expand_platform never produces All"),
363 }
364}
365
366fn install_claude_code(
367 project_root: &Path,
368 home: &Path,
369 project_scope: bool,
370 db_str: &str,
371 manifest: &mut Manifest,
372) -> Result<(), CliError> {
373 let skill_content = SKILL_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
374
375 let skill_dir = if project_scope {
376 project_root.join(".claude").join("skills").join("mushroom")
377 } else {
378 home.join(".claude").join("skills").join("mushroom")
379 };
380 let skill_file = skill_dir.join("SKILL.md");
381
382 if !file_matches(&skill_file, &skill_content) {
384 fs::create_dir_all(&skill_dir)
385 .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
386 fs::write(&skill_file, &skill_content)
387 .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
388 manifest.files.push(skill_file);
389 }
390
391 let mcp_file = if project_scope {
394 project_root.join(".mcp.json")
395 } else {
396 home.join(".claude.json")
397 };
398 merge_mcp_entry(&mcp_file, db_str, manifest)?;
399
400 Ok(())
401}
402
403fn install_cursor(
404 project_root: &Path,
405 home: &Path,
406 project_scope: bool,
407 db_str: &str,
408 manifest: &mut Manifest,
409) -> Result<(), CliError> {
410 let rules_content = CURSOR_RULES_TEMPLATE.replace(DB_PATH_PLACEHOLDER, db_str);
411
412 let rules_dir = if project_scope {
413 project_root.join(".cursor").join("rules")
414 } else {
415 home.join(".cursor").join("rules")
416 };
417 let rules_file = rules_dir.join("mushroom.mdc");
418
419 if !file_matches(&rules_file, &rules_content) {
420 fs::create_dir_all(&rules_dir)
421 .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
422 fs::write(&rules_file, &rules_content)
423 .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
424 manifest.files.push(rules_file);
425 }
426
427 let mcp_file = if project_scope {
429 project_root.join(".cursor").join("mcp.json")
430 } else {
431 home.join(".cursor").join("mcp.json")
432 };
433 merge_mcp_entry(&mcp_file, db_str, manifest)?;
434
435 Ok(())
436}
437
438fn merge_mcp_entry(mcp_file: &Path, db_str: &str, manifest: &mut Manifest) -> Result<(), CliError> {
445 let mut root: serde_json::Value = if mcp_file.exists() {
446 let raw = fs::read_to_string(mcp_file)
447 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
448 serde_json::from_str(&raw)
449 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
450 } else {
451 serde_json::json!({})
452 };
453
454 if !root["mcpServers"].is_object() {
456 root["mcpServers"] = serde_json::json!({});
457 }
458
459 let desired = mcp_server_entry(db_str);
460 let existing = &root["mcpServers"][SERVER_NAME];
461
462 if existing == &desired {
463 return Ok(()); }
465
466 root["mcpServers"][SERVER_NAME] = desired;
468
469 let parent = mcp_file.parent().unwrap_or(Path::new("."));
470 fs::create_dir_all(parent)
471 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
472
473 let json = serde_json::to_string_pretty(&root)
474 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
475 fs::write(mcp_file, json)
476 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
477
478 manifest.mcp_keys.push(ManagedMcpKey {
479 file: mcp_file.to_path_buf(),
480 server: SERVER_NAME.to_string(),
481 });
482
483 Ok(())
484}
485
486fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
490 if !mcp_file.exists() {
491 return Ok(());
492 }
493 let raw = fs::read_to_string(mcp_file)
494 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
495 let mut root: serde_json::Value = serde_json::from_str(&raw)
496 .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
497
498 if let Some(servers) = root["mcpServers"].as_object_mut() {
499 servers.remove(server);
500 }
501
502 let json = serde_json::to_string_pretty(&root)
503 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
504 fs::write(mcp_file, json)
505 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
506 Ok(())
507}
508
509fn mcp_server_entry(db_str: &str) -> serde_json::Value {
510 serde_json::json!({
511 "command": "mushroomdb",
512 "args": ["mcp", db_str]
513 })
514}
515
516fn manifest_path(
521 project_root: &Path,
522 home: &Path,
523 project_scope: bool,
524 platforms: &[Platform],
525) -> PathBuf {
526 if !project_scope {
527 return home.join(".mushroomdb").join("install-manifest.json");
528 }
529 if platforms.contains(&Platform::ClaudeCode) {
531 project_root
532 .join(".claude")
533 .join("skills")
534 .join("mushroom")
535 .join(".install-manifest.json")
536 } else {
537 project_root.join(".cursor").join(".install-manifest.json")
538 }
539}
540
541fn load_manifest(path: &Path) -> Manifest {
543 let raw = match fs::read_to_string(path) {
544 Ok(s) => s,
545 Err(_) => return Manifest::default(),
546 };
547 serde_json::from_str(&raw).unwrap_or_default()
548}
549
550fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
554 for f in &this_run.files {
555 if !existing.files.contains(f) {
556 existing.files.push(f.clone());
557 }
558 }
559 for k in &this_run.mcp_keys {
560 let already = existing
561 .mcp_keys
562 .iter()
563 .any(|e| e.file == k.file && e.server == k.server);
564 if !already {
565 existing.mcp_keys.push(k.clone());
566 }
567 }
568 existing
569}
570
571fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
572 let parent = path.parent().unwrap_or(Path::new("."));
573 fs::create_dir_all(parent).map_err(|e| {
574 CliError(format!(
575 "cannot create manifest dir {}: {e}",
576 parent.display()
577 ))
578 })?;
579 let json = serde_json::to_string_pretty(manifest)
580 .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
581 fs::write(path, json)
582 .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
583 Ok(())
584}
585
586fn file_matches(path: &Path, expected: &str) -> bool {
592 fs::read_to_string(path)
593 .map(|s| s == expected)
594 .unwrap_or(false)
595}