1use crate::CliError;
21use serde::{Deserialize, Serialize};
22use std::ffi::OsStr;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
30const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
31
32const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
34
35const BIN_PLACEHOLDER: &str = "{{BIN}}";
38
39const SERVER_NAME: &str = "mushroomdb";
41
42const BIN_NAME: &str = "mushroomdb";
44
45#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum BinaryLocation {
56 OnPath,
58 CopyFrom(PathBuf),
61}
62
63pub fn detect_binary_location() -> BinaryLocation {
66 match std::env::current_exe() {
67 Ok(exe) => classify_binary_location(std::env::var_os("PATH").as_deref(), &exe),
68 Err(_) => BinaryLocation::OnPath,
70 }
71}
72
73pub fn classify_binary_location(path_var: Option<&OsStr>, current_exe: &Path) -> BinaryLocation {
91 let copy = || BinaryLocation::CopyFrom(current_exe.to_path_buf());
92
93 let Some(hit) = path_var.and_then(|p| {
96 std::env::split_paths(p)
97 .map(|dir| dir.join(BIN_NAME))
98 .find(|candidate| candidate.is_file())
99 }) else {
100 return copy();
101 };
102
103 match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
107 (Ok(on_path), Ok(running)) if on_path == running => BinaryLocation::OnPath,
108 _ => copy(),
109 }
110}
111
112fn stable_bin_path(home: &Path) -> PathBuf {
115 home.join(".mushroomdb").join("bin").join(BIN_NAME)
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Platform {
121 ClaudeCode,
122 Cursor,
123 All,
124}
125
126impl Platform {
127 pub fn parse(s: &str) -> Result<Self, String> {
128 match s {
129 "claude-code" => Ok(Platform::ClaudeCode),
130 "cursor" => Ok(Platform::Cursor),
131 "all" => Ok(Platform::All),
132 other => Err(format!(
133 "--platform must be claude-code | cursor | all, got: {other}"
134 )),
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct InstallOpts {
142 pub platform: Option<Platform>,
144 pub project: bool,
146 pub db: Option<PathBuf>,
148}
149
150impl InstallOpts {
151 pub fn default_db(&self, project_root: &Path, home: &Path) -> PathBuf {
152 if self.project {
153 project_root.join("mushroom-memory")
154 } else {
155 home.join(".mushroomdb").join("memory")
156 }
157 }
158}
159
160#[derive(Serialize, Deserialize, Default, Debug)]
165struct Manifest {
166 files: Vec<PathBuf>,
168 mcp_keys: Vec<ManagedMcpKey>,
170 #[serde(default)]
172 hooks: Vec<ManagedHook>,
173}
174
175#[derive(Serialize, Deserialize, Debug, Clone)]
176struct ManagedMcpKey {
177 file: PathBuf,
179 server: String,
181}
182
183#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
184struct ManagedHook {
185 file: PathBuf,
187 event: String,
189 command: String,
191}
192
193const HOOK_EVENT: &str = "UserPromptSubmit";
196const HOOK_TIMEOUT_SECS: u64 = 5;
198
199fn sh_quote(s: &str) -> String {
205 format!("'{}'", s.replace('\'', r"'\''"))
206}
207
208fn recall_hook_command(bin_cmd: &str, db_str: &str) -> String {
210 format!("{} recall {}", sh_quote(bin_cmd), sh_quote(db_str))
211}
212
213fn hook_entry(command: &str) -> serde_json::Value {
215 serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
216}
217
218fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
220 root["hooks"][event]
221 .as_array()
222 .map(|groups| {
223 groups.iter().any(|g| {
224 g["hooks"]
225 .as_array()
226 .map(|hs| hs.iter().any(|h| h["command"] == command))
227 .unwrap_or(false)
228 })
229 })
230 .unwrap_or(false)
231}
232
233fn merge_hook_entry(
240 settings_file: &Path,
241 command: &str,
242 manifest: &mut Manifest,
243) -> Result<(), CliError> {
244 let mut root: serde_json::Value = if settings_file.exists() {
245 let raw = fs::read_to_string(settings_file)
246 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
247 serde_json::from_str(&raw)
248 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
249 } else {
250 serde_json::json!({})
251 };
252
253 if !root.is_object() {
254 return Err(CliError(format!(
255 "{} is not a JSON object at its top level — refusing to add a hook",
256 settings_file.display()
257 )));
258 }
259
260 if settings_has_hook(&root, HOOK_EVENT, command) {
261 return Ok(());
262 }
263
264 match root.get("hooks") {
268 None => root["hooks"] = serde_json::json!({}),
269 Some(v) if v.is_object() => {}
270 Some(_) => {
271 return Err(CliError(format!(
272 "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
273 settings_file.display()
274 )));
275 }
276 }
277 match root["hooks"].get(HOOK_EVENT) {
278 None => root["hooks"][HOOK_EVENT] = serde_json::json!([]),
279 Some(v) if v.is_array() => {}
280 Some(_) => {
281 return Err(CliError(format!(
282 "{}: \"hooks.{HOOK_EVENT}\" is not a JSON array — refusing to overwrite it",
283 settings_file.display()
284 )));
285 }
286 }
287 root["hooks"][HOOK_EVENT]
288 .as_array_mut()
289 .unwrap()
290 .push(hook_entry(command));
291
292 let parent = settings_file.parent().unwrap_or(Path::new("."));
293 fs::create_dir_all(parent)
294 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
295 let json = serde_json::to_string_pretty(&root)
296 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
297 fs::write(settings_file, json)
298 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
299
300 manifest.hooks.push(ManagedHook {
301 file: settings_file.to_path_buf(),
302 event: HOOK_EVENT.into(),
303 command: command.into(),
304 });
305 Ok(())
306}
307
308fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<(), CliError> {
318 if !settings_file.exists() {
319 return Ok(());
320 }
321 let raw = fs::read_to_string(settings_file)
322 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
323 let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
324 CliError(format!(
325 "corrupt settings json at {}: {e}",
326 settings_file.display()
327 ))
328 })?;
329
330 let Some(mut groups) = root
331 .get("hooks")
332 .and_then(|h| h.get(event))
333 .and_then(|g| g.as_array())
334 .cloned()
335 else {
336 return Ok(());
339 };
340
341 for g in groups.iter_mut() {
342 if let Some(hs) = g["hooks"].as_array_mut() {
343 hs.retain(|h| h["command"] != command);
344 }
345 }
346 groups.retain(|g| {
347 g["hooks"]
348 .as_array()
349 .map(|hs| !hs.is_empty())
350 .unwrap_or(true)
351 });
352
353 let before = root.clone();
354 if groups.is_empty() {
355 root["hooks"].as_object_mut().unwrap().remove(event);
356 } else {
357 root["hooks"][event] = serde_json::Value::Array(groups);
358 }
359 if root == before {
360 return Ok(());
365 }
366
367 let json = serde_json::to_string_pretty(&root)
368 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
369 fs::write(settings_file, json)
370 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
371 Ok(())
372}
373
374pub fn run_install(
384 project_root: &Path,
385 home: &Path,
386 opts: &InstallOpts,
387) -> Result<String, CliError> {
388 run_install_with(project_root, home, opts, &detect_binary_location())
389}
390
391pub fn run_install_with(
395 project_root: &Path,
396 home: &Path,
397 opts: &InstallOpts,
398 bin: &BinaryLocation,
399) -> Result<String, CliError> {
400 let db = opts
401 .db
402 .clone()
403 .unwrap_or_else(|| opts.default_db(project_root, home));
404 let db_str = db.to_string_lossy();
405
406 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
407 let platforms = expand_platform(&resolved);
408
409 for plat in &platforms {
411 preflight_check(project_root, home, plat, opts.project, &db_str)?;
412 }
413
414 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
415
416 let existing = load_manifest(&manifest_path);
421
422 let mut manifest = Manifest::default();
423
424 let bin_cmd = match bin {
427 BinaryLocation::OnPath => BIN_NAME.to_string(),
428 BinaryLocation::CopyFrom(src) => {
429 let dest = stable_bin_path(home);
430 copy_binary(src, &dest, &mut manifest)?;
431 dest.to_string_lossy().into_owned()
432 }
433 };
434
435 for plat in &platforms {
436 let step = install_platform(
437 project_root,
438 home,
439 plat,
440 opts.project,
441 &db_str,
442 &bin_cmd,
443 &mut manifest,
444 );
445 if let Err(e) = step {
446 let anything_written = !manifest.files.is_empty()
450 || !manifest.mcp_keys.is_empty()
451 || !manifest.hooks.is_empty();
452 if anything_written {
453 let merged = union_manifests(load_manifest(&manifest_path), &manifest);
454 let _ = write_manifest(&manifest_path, &merged);
455 }
456 return Err(e);
457 }
458 }
459
460 let anything_written =
461 !manifest.files.is_empty() || !manifest.mcp_keys.is_empty() || !manifest.hooks.is_empty();
462
463 if anything_written {
464 let merged = union_manifests(existing, &manifest);
466 write_manifest(&manifest_path, &merged)?;
467 }
468
469 let mut out = format!("mushroomdb installed ({} platform(s))\n", platforms.len());
470 for f in &manifest.files {
471 out.push_str(&format!(" wrote {}\n", f.display()));
472 }
473 for k in &manifest.mcp_keys {
474 out.push_str(&format!(
475 " added mcpServers.{} in {}\n",
476 k.server,
477 k.file.display()
478 ));
479 }
480 for h in &manifest.hooks {
481 out.push_str(&format!(
482 " added {} hook in {}\n",
483 h.event,
484 h.file.display()
485 ));
486 }
487 if anything_written {
488 out.push_str(&format!(" manifest {}\n", manifest_path.display()));
489 out.push_str(&format!(
490 " mcp command {bin_cmd}\n restart your assistant to connect the MCP server\n"
491 ));
492 } else {
493 out.push_str(" (already installed — no changes)\n");
494 }
495 Ok(out)
496}
497
498fn copy_binary(src: &Path, dest: &Path, manifest: &mut Manifest) -> Result<(), CliError> {
502 let bytes = fs::read(src)
503 .map_err(|e| CliError(format!("cannot read binary {}: {e}", src.display())))?;
504 if fs::read(dest).map(|cur| cur == bytes).unwrap_or(false) {
505 return Ok(());
506 }
507 let parent = dest.parent().unwrap_or(Path::new("."));
508 fs::create_dir_all(parent)
509 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
510 let tmp = parent.join(format!(".{BIN_NAME}.tmp-{}", std::process::id()));
513 fs::write(&tmp, &bytes)
514 .map_err(|e| CliError(format!("cannot write {}: {e}", tmp.display())))?;
515 let finish = || -> Result<(), CliError> {
516 #[cfg(unix)]
517 {
518 use std::os::unix::fs::PermissionsExt;
519 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
520 .map_err(|e| CliError(format!("cannot chmod {}: {e}", tmp.display())))?;
521 }
522 fs::rename(&tmp, dest)
523 .map_err(|e| CliError(format!("cannot move binary into {}: {e}", dest.display())))
524 };
525 if let Err(e) = finish() {
526 let _ = fs::remove_file(&tmp); return Err(e);
528 }
529 manifest.files.push(dest.to_path_buf());
530 Ok(())
531}
532
533pub fn run_uninstall(
535 project_root: &Path,
536 home: &Path,
537 opts: &InstallOpts,
538) -> Result<String, CliError> {
539 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
540 let platforms = expand_platform(&resolved);
541
542 let manifest_path = manifest_path(project_root, home, opts.project, &platforms);
543 if !manifest_path.exists() {
544 return Err(CliError(format!(
545 "no install manifest found at {} — nothing to uninstall",
546 manifest_path.display()
547 )));
548 }
549
550 let raw = fs::read_to_string(&manifest_path)
551 .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
552 let manifest: Manifest =
553 serde_json::from_str(&raw).map_err(|e| CliError(format!("corrupt manifest: {e}")))?;
554
555 let mut removed = Vec::new();
556
557 for key in &manifest.mcp_keys {
559 if key.file.exists() {
560 remove_mcp_key(&key.file, &key.server)?;
561 removed.push(format!(
562 "removed mcpServers.{} from {}",
563 key.server,
564 key.file.display()
565 ));
566 }
567 }
568
569 for h in &manifest.hooks {
571 if h.file.exists() {
572 remove_hook_entry(&h.file, &h.event, &h.command)?;
573 removed.push(format!(
574 "removed {} hook from {}",
575 h.event,
576 h.file.display()
577 ));
578 }
579 }
580
581 for f in &manifest.files {
583 if f.exists() {
584 fs::remove_file(f)
585 .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
586 removed.push(format!("removed {}", f.display()));
587 }
588 }
589
590 if manifest_path.exists() {
592 fs::remove_file(&manifest_path)
593 .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
594 }
595
596 let mut out = "mushroomdb uninstalled\n".to_string();
597 for line in &removed {
598 out.push_str(&format!(" {line}\n"));
599 }
600 Ok(out)
601}
602
603fn resolve_platform(
608 project_root: &Path,
609 home: &Path,
610 requested: Option<&Platform>,
611) -> Result<Platform, CliError> {
612 if let Some(p) = requested {
613 return Ok(p.clone());
614 }
615
616 let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
618 let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
619
620 match (has_claude, has_cursor) {
621 (true, true) => Ok(Platform::All),
622 (true, false) => Ok(Platform::ClaudeCode),
623 (false, true) => Ok(Platform::Cursor),
624 (false, false) => Err(CliError(
625 "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
626 Pass --platform claude-code, --platform cursor, or --platform all."
627 .to_string(),
628 )),
629 }
630}
631
632fn expand_platform(p: &Platform) -> Vec<Platform> {
633 match p {
634 Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
635 Platform::ClaudeCode => vec![Platform::ClaudeCode],
636 Platform::Cursor => vec![Platform::Cursor],
637 }
638}
639
640fn preflight_check(
645 project_root: &Path,
646 home: &Path,
647 platform: &Platform,
648 project_scope: bool,
649 db_str: &str,
650) -> Result<(), CliError> {
651 match platform {
652 Platform::ClaudeCode => {
653 let mcp_file = if project_scope {
654 project_root.join(".mcp.json")
655 } else {
656 home.join(".claude.json")
660 };
661 check_mcp_conflict(&mcp_file, db_str)?;
662 }
663 Platform::Cursor => {
664 let mcp_file = if project_scope {
665 project_root.join(".cursor").join("mcp.json")
666 } else {
667 home.join(".cursor").join("mcp.json")
668 };
669 check_mcp_conflict(&mcp_file, db_str)?;
670 }
671 Platform::All => unreachable!("expand_platform never produces All"),
672 }
673 Ok(())
674}
675
676fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
684 if !mcp_file.exists() {
685 return Ok(());
686 }
687 let raw = fs::read_to_string(mcp_file)
688 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
689 let v: serde_json::Value = serde_json::from_str(&raw)
690 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
691
692 let existing = &v["mcpServers"][SERVER_NAME];
693 if existing.is_null() {
694 return Ok(()); }
696
697 let existing_db = existing["args"]
698 .get(1)
699 .and_then(|v| v.as_str())
700 .unwrap_or("");
701
702 if existing_db == db_str {
703 return Ok(()); }
705
706 Err(CliError(format!(
707 "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
708 To update it, run `mushroomdb uninstall` first, then re-install.\n\
709 Or manually edit {} and remove the existing mushroomdb entry.",
710 mcp_file.display(),
711 existing_db,
712 mcp_file.display()
713 )))
714}
715
716fn install_platform(
721 project_root: &Path,
722 home: &Path,
723 platform: &Platform,
724 project_scope: bool,
725 db_str: &str,
726 bin_cmd: &str,
727 manifest: &mut Manifest,
728) -> Result<(), CliError> {
729 match platform {
730 Platform::ClaudeCode => {
731 install_claude_code(project_root, home, project_scope, db_str, bin_cmd, manifest)
732 }
733 Platform::Cursor => {
734 install_cursor(project_root, home, project_scope, db_str, bin_cmd, manifest)
735 }
736 Platform::All => unreachable!("expand_platform never produces All"),
737 }
738}
739
740fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
742 template
743 .replace(DB_PATH_PLACEHOLDER, db_str)
744 .replace(BIN_PLACEHOLDER, bin_cmd)
745}
746
747fn install_claude_code(
748 project_root: &Path,
749 home: &Path,
750 project_scope: bool,
751 db_str: &str,
752 bin_cmd: &str,
753 manifest: &mut Manifest,
754) -> Result<(), CliError> {
755 let skill_content = render_template(SKILL_TEMPLATE, db_str, bin_cmd);
756
757 let skill_dir = if project_scope {
758 project_root.join(".claude").join("skills").join("mushroom")
759 } else {
760 home.join(".claude").join("skills").join("mushroom")
761 };
762 let skill_file = skill_dir.join("SKILL.md");
763
764 if !file_matches(&skill_file, &skill_content) {
766 fs::create_dir_all(&skill_dir)
767 .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
768 fs::write(&skill_file, &skill_content)
769 .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
770 manifest.files.push(skill_file);
771 }
772
773 let mcp_file = if project_scope {
776 project_root.join(".mcp.json")
777 } else {
778 home.join(".claude.json")
779 };
780 merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
781
782 let settings_file = if project_scope {
784 project_root.join(".claude").join("settings.json")
785 } else {
786 home.join(".claude").join("settings.json")
787 };
788 merge_hook_entry(
789 &settings_file,
790 &recall_hook_command(bin_cmd, db_str),
791 manifest,
792 )?;
793
794 Ok(())
795}
796
797fn install_cursor(
798 project_root: &Path,
799 home: &Path,
800 project_scope: bool,
801 db_str: &str,
802 bin_cmd: &str,
803 manifest: &mut Manifest,
804) -> Result<(), CliError> {
805 let rules_content = render_template(CURSOR_RULES_TEMPLATE, db_str, bin_cmd);
806
807 let rules_dir = if project_scope {
808 project_root.join(".cursor").join("rules")
809 } else {
810 home.join(".cursor").join("rules")
811 };
812 let rules_file = rules_dir.join("mushroom.mdc");
813
814 if !file_matches(&rules_file, &rules_content) {
815 fs::create_dir_all(&rules_dir)
816 .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
817 fs::write(&rules_file, &rules_content)
818 .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
819 manifest.files.push(rules_file);
820 }
821
822 let mcp_file = if project_scope {
824 project_root.join(".cursor").join("mcp.json")
825 } else {
826 home.join(".cursor").join("mcp.json")
827 };
828 merge_mcp_entry(&mcp_file, db_str, bin_cmd, manifest)?;
829
830 Ok(())
831}
832
833fn merge_mcp_entry(
840 mcp_file: &Path,
841 db_str: &str,
842 bin_cmd: &str,
843 manifest: &mut Manifest,
844) -> Result<(), CliError> {
845 let mut root: serde_json::Value = if mcp_file.exists() {
846 let raw = fs::read_to_string(mcp_file)
847 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
848 serde_json::from_str(&raw)
849 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
850 } else {
851 serde_json::json!({})
852 };
853
854 if !root["mcpServers"].is_object() {
856 root["mcpServers"] = serde_json::json!({});
857 }
858
859 let desired = mcp_server_entry(db_str, bin_cmd);
860 let existing = &root["mcpServers"][SERVER_NAME];
861
862 if existing == &desired {
863 return Ok(()); }
865
866 root["mcpServers"][SERVER_NAME] = desired;
868
869 let parent = mcp_file.parent().unwrap_or(Path::new("."));
870 fs::create_dir_all(parent)
871 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
872
873 let json = serde_json::to_string_pretty(&root)
874 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
875 fs::write(mcp_file, json)
876 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
877
878 manifest.mcp_keys.push(ManagedMcpKey {
879 file: mcp_file.to_path_buf(),
880 server: SERVER_NAME.to_string(),
881 });
882
883 Ok(())
884}
885
886fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<(), CliError> {
890 if !mcp_file.exists() {
891 return Ok(());
892 }
893 let raw = fs::read_to_string(mcp_file)
894 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
895 let mut root: serde_json::Value = serde_json::from_str(&raw)
896 .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
897
898 if let Some(servers) = root["mcpServers"].as_object_mut() {
899 servers.remove(server);
900 }
901
902 let json = serde_json::to_string_pretty(&root)
903 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
904 fs::write(mcp_file, json)
905 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
906 Ok(())
907}
908
909fn mcp_server_entry(db_str: &str, bin_cmd: &str) -> serde_json::Value {
910 serde_json::json!({
911 "command": bin_cmd,
912 "args": ["mcp", db_str]
913 })
914}
915
916fn manifest_path(
921 project_root: &Path,
922 home: &Path,
923 project_scope: bool,
924 platforms: &[Platform],
925) -> PathBuf {
926 if !project_scope {
927 return home.join(".mushroomdb").join("install-manifest.json");
928 }
929 if platforms.contains(&Platform::ClaudeCode) {
931 project_root
932 .join(".claude")
933 .join("skills")
934 .join("mushroom")
935 .join(".install-manifest.json")
936 } else {
937 project_root.join(".cursor").join(".install-manifest.json")
938 }
939}
940
941fn load_manifest(path: &Path) -> Manifest {
943 let raw = match fs::read_to_string(path) {
944 Ok(s) => s,
945 Err(_) => return Manifest::default(),
946 };
947 serde_json::from_str(&raw).unwrap_or_default()
948}
949
950fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
955 for f in &this_run.files {
956 if !existing.files.contains(f) {
957 existing.files.push(f.clone());
958 }
959 }
960 for k in &this_run.mcp_keys {
961 let already = existing
962 .mcp_keys
963 .iter()
964 .any(|e| e.file == k.file && e.server == k.server);
965 if !already {
966 existing.mcp_keys.push(k.clone());
967 }
968 }
969 for h in &this_run.hooks {
970 if !existing.hooks.contains(h) {
971 existing.hooks.push(h.clone());
972 }
973 }
974 existing
975}
976
977fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
978 let parent = path.parent().unwrap_or(Path::new("."));
979 fs::create_dir_all(parent).map_err(|e| {
980 CliError(format!(
981 "cannot create manifest dir {}: {e}",
982 parent.display()
983 ))
984 })?;
985 let json = serde_json::to_string_pretty(manifest)
986 .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
987 fs::write(path, json)
988 .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
989 Ok(())
990}
991
992fn file_matches(path: &Path, expected: &str) -> bool {
998 fs::read_to_string(path)
999 .map(|s| s == expected)
1000 .unwrap_or(false)
1001}