1use crate::CliError;
23use serde::{Deserialize, Serialize};
24use std::ffi::{OsStr, OsString};
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::time::{Duration, Instant};
28
29const SKILL_TEMPLATE: &str = include_str!("../skills/mushroom/SKILL.md");
33const CURSOR_RULES_TEMPLATE: &str = include_str!("../skills/mushroom/cursor-rules.mdc");
34
35const DB_PATH_PLACEHOLDER: &str = "{{DB_PATH}}";
37
38const BIN_PLACEHOLDER: &str = "{{BIN}}";
42
43const SERVER_NAME: &str = "mushroomdb";
45
46const BIN_NAME: &str = "mushroomdb";
48
49const NPM_PACKAGE: &str = "mushroomdb";
51
52const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
54
55const PREWARM_TIMEOUT_SECS: u64 = 180;
59
60#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum McpCommand {
76 Npx { version: String },
79 Explicit(PathBuf),
81 OnPath,
84}
85
86impl McpCommand {
87 #[must_use]
89 pub fn npx() -> Self {
90 McpCommand::Npx {
91 version: CRATE_VERSION.to_string(),
92 }
93 }
94
95 fn program(&self) -> (String, Vec<String>) {
97 match self {
98 McpCommand::Npx { version } => (
99 "npx".to_string(),
100 vec!["-y".to_string(), format!("{NPM_PACKAGE}@{version}")],
101 ),
102 McpCommand::Explicit(p) => (p.to_string_lossy().into_owned(), Vec::new()),
103 McpCommand::OnPath => (BIN_NAME.to_string(), Vec::new()),
104 }
105 }
106
107 #[must_use]
113 pub fn json_entry(&self, sub: &str, db: &str) -> serde_json::Value {
114 let (command, mut args) = self.program();
115 args.push(sub.to_string());
116 args.push(db.to_string());
117 serde_json::json!({ "command": command, "args": args })
118 }
119
120 #[must_use]
127 pub fn shell(&self) -> String {
128 let (command, args) = self.program();
129 let mut out = match self {
130 McpCommand::Explicit(_) => sh_quote(&command),
131 _ => command,
132 };
133 for a in args {
134 out.push(' ');
135 out.push_str(&a);
136 }
137 out
138 }
139
140 fn argv(&self, sub: &str, db: &str) -> Vec<String> {
142 let (command, mut args) = self.program();
143 args.push(sub.to_string());
144 args.push(db.to_string());
145 let mut out = vec![command];
146 out.extend(args);
147 out
148 }
149}
150
151#[must_use]
154pub fn detect_mcp_command(opts: &InstallOpts) -> McpCommand {
155 if let Some(path) = &opts.command {
156 return McpCommand::Explicit(path.clone());
157 }
158 match std::env::current_exe() {
159 Ok(exe) => classify_mcp_command(std::env::var_os("PATH").as_deref(), &exe),
160 Err(_) => McpCommand::npx(),
162 }
163}
164
165#[must_use]
182pub fn classify_mcp_command(path_var: Option<&OsStr>, current_exe: &Path) -> McpCommand {
183 let Some(hit) = path_var.and_then(|p| {
186 std::env::split_paths(p)
187 .map(|dir| dir.join(BIN_NAME))
188 .find(|candidate| candidate.is_file())
189 }) else {
190 return McpCommand::npx();
191 };
192
193 match (fs::canonicalize(&hit), fs::canonicalize(current_exe)) {
197 (Ok(on_path), Ok(running)) if on_path == running => McpCommand::OnPath,
198 _ => McpCommand::npx(),
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Platform {
209 ClaudeCode,
210 Cursor,
211 Codex,
212 All,
213}
214
215impl Platform {
216 pub fn parse(s: &str) -> Result<Self, String> {
217 match s {
218 "claude-code" => Ok(Platform::ClaudeCode),
219 "cursor" => Ok(Platform::Cursor),
220 "codex" => Ok(Platform::Codex),
221 "all" => Ok(Platform::All),
222 other => Err(format!(
223 "--platform must be claude-code | cursor | codex | all, got: {other}"
224 )),
225 }
226 }
227
228 pub(crate) fn label(&self) -> &'static str {
229 match self {
230 Platform::ClaudeCode => "claude-code",
231 Platform::Cursor => "cursor",
232 Platform::Codex => "codex",
233 Platform::All => "all",
234 }
235 }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum Scope {
241 Project,
242 User,
243}
244
245impl Scope {
246 pub(crate) fn label(self) -> &'static str {
247 match self {
248 Scope::Project => "project",
249 Scope::User => "user",
250 }
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct InstallOpts {
257 pub platform: Option<Platform>,
259 pub scope: Option<Scope>,
261 pub db: Option<PathBuf>,
263 pub command: Option<PathBuf>,
265 pub git_hooks: bool,
267 pub prewarm: bool,
270}
271
272#[must_use]
274pub fn default_db(scope: Scope, project_root: &Path, home: &Path) -> PathBuf {
275 match scope {
276 Scope::Project => project_root.join("mushroom-memory"),
277 Scope::User => home.join(".mushroomdb").join("memory"),
278 }
279}
280
281pub(crate) fn resolve_scope(project_root: &Path, requested: Option<Scope>) -> (Scope, bool) {
287 match requested {
288 Some(s) => (s, false),
289 None if project_root.join(".git").exists() => (Scope::Project, true),
290 None => (Scope::User, true),
291 }
292}
293
294#[derive(Debug, Clone)]
305pub struct Externals {
306 pub path: Option<OsString>,
308 pub prewarm_timeout: Duration,
310}
311
312impl Externals {
313 #[must_use]
315 pub fn from_env() -> Self {
316 Self::with_path(std::env::var_os("PATH"))
317 }
318
319 #[must_use]
321 pub fn with_path(path: Option<OsString>) -> Self {
322 Self {
323 path,
324 prewarm_timeout: Duration::from_secs(PREWARM_TIMEOUT_SECS),
325 }
326 }
327
328 pub(crate) fn which(&self, program: &str) -> Option<PathBuf> {
330 let path = self.path.as_ref()?;
331 std::env::split_paths(path)
332 .map(|dir| dir.join(program))
333 .find(|c| is_executable(c))
334 }
335}
336
337fn is_executable(path: &Path) -> bool {
338 let Ok(meta) = fs::metadata(path) else {
339 return false;
340 };
341 if !meta.is_file() {
342 return false;
343 }
344 #[cfg(unix)]
345 {
346 use std::os::unix::fs::PermissionsExt;
347 meta.permissions().mode() & 0o111 != 0
348 }
349 #[cfg(not(unix))]
350 {
351 true
352 }
353}
354
355fn run_and_capture(bin: &Path, args: &[String]) -> Result<(), String> {
357 let out = std::process::Command::new(bin)
358 .args(args)
359 .output()
360 .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
361 if out.status.success() {
362 return Ok(());
363 }
364 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
365 let detail = if stderr.is_empty() {
366 String::new()
367 } else {
368 format!(": {stderr}")
369 };
370 Err(format!(
371 "{} {} exited with {}{detail}",
372 bin.display(),
373 args.join(" "),
374 out.status
375 ))
376}
377
378fn run_with_timeout(bin: &Path, args: &[String], timeout: Duration) -> Result<(), String> {
381 let mut child = std::process::Command::new(bin)
382 .args(args)
383 .stdin(std::process::Stdio::null())
384 .stdout(std::process::Stdio::null())
385 .stderr(std::process::Stdio::null())
386 .spawn()
387 .map_err(|e| format!("cannot run {}: {e}", bin.display()))?;
388 let deadline = Instant::now() + timeout;
389 loop {
390 match child.try_wait() {
391 Ok(Some(status)) if status.success() => return Ok(()),
392 Ok(Some(status)) => return Err(format!("exited with {status}")),
393 Ok(None) => {}
394 Err(e) => return Err(format!("cannot wait for {}: {e}", bin.display())),
395 }
396 if Instant::now() >= deadline {
397 let _ = child.kill();
398 let _ = child.wait();
399 return Err(format!("timed out after {}s", timeout.as_secs()));
400 }
401 std::thread::sleep(Duration::from_millis(25));
402 }
403}
404
405#[derive(Serialize, Deserialize, Default, Debug)]
410struct Manifest {
411 files: Vec<PathBuf>,
413 mcp_keys: Vec<ManagedMcpKey>,
415 #[serde(default)]
417 hooks: Vec<ManagedHook>,
418 #[serde(default)]
420 git_hooks: Vec<PathBuf>,
421 #[serde(default)]
423 gitignore: Vec<ManagedLine>,
424 #[serde(default)]
426 codex: bool,
427}
428
429impl Manifest {
430 fn sanitised(mut self) -> Self {
433 self.files
434 .retain(|f| f.file_name() != Some(OsStr::new(".gitignore")));
435 self
436 }
437
438 fn is_empty(&self) -> bool {
439 self.files.is_empty()
440 && self.mcp_keys.is_empty()
441 && self.hooks.is_empty()
442 && self.git_hooks.is_empty()
443 && self.gitignore.is_empty()
444 && !self.codex
445 }
446}
447
448#[derive(Serialize, Deserialize, Debug, Clone)]
449struct ManagedMcpKey {
450 file: PathBuf,
452 server: String,
454}
455
456#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
457struct ManagedHook {
458 file: PathBuf,
460 event: String,
462 command: String,
464}
465
466#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
468struct ManagedLine {
469 file: PathBuf,
471 line: String,
473 #[serde(default)]
476 created: bool,
477}
478
479pub(crate) const HOOK_EVENT: &str = "UserPromptSubmit";
482const HOOK_TIMEOUT_SECS: u64 = 5;
484
485pub(crate) const TOUCH_EVENT: &str = "PostToolUse";
488const TOUCH_MATCHER: &str = "Edit|Write|MultiEdit";
491const TOUCH_TIMEOUT_SECS: u64 = 30;
495
496fn sh_quote(s: &str) -> String {
502 format!("'{}'", s.replace('\'', r"'\''"))
503}
504
505fn recall_hook_command(shell: &str, db_str: &str) -> String {
508 format!("{shell} recall {}", sh_quote(db_str))
509}
510
511fn touch_hook_command(shell: &str, db_str: &str) -> String {
514 format!("{shell} touch {}", sh_quote(db_str))
515}
516
517fn hook_entry(command: &str) -> serde_json::Value {
519 serde_json::json!({ "hooks": [ { "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS } ] })
520}
521
522fn touch_hook_entry(command: &str) -> serde_json::Value {
525 serde_json::json!({
526 "matcher": TOUCH_MATCHER,
527 "hooks": [ {
528 "type": "command",
529 "command": command,
530 "timeout": TOUCH_TIMEOUT_SECS,
531 "async": true
532 } ]
533 })
534}
535
536pub(crate) fn settings_has_hook(root: &serde_json::Value, event: &str, command: &str) -> bool {
538 root["hooks"][event]
539 .as_array()
540 .map(|groups| {
541 groups.iter().any(|g| {
542 g["hooks"]
543 .as_array()
544 .map(|hs| hs.iter().any(|h| h["command"] == command))
545 .unwrap_or(false)
546 })
547 })
548 .unwrap_or(false)
549}
550
551fn merge_hook_entry(
561 settings_file: &Path,
562 event: &str,
563 command: &str,
564 entry: serde_json::Value,
565 manifest: &mut Manifest,
566) -> Result<(), CliError> {
567 let mut root: serde_json::Value = if settings_file.exists() {
568 let raw = fs::read_to_string(settings_file)
569 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
570 serde_json::from_str(&raw)
571 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", settings_file.display())))?
572 } else {
573 serde_json::json!({})
574 };
575
576 if !root.is_object() {
577 return Err(CliError(format!(
578 "{} is not a JSON object at its top level — refusing to add a hook",
579 settings_file.display()
580 )));
581 }
582
583 if settings_has_hook(&root, event, command) {
584 return Ok(());
585 }
586
587 match root.get("hooks") {
591 None => root["hooks"] = serde_json::json!({}),
592 Some(v) if v.is_object() => {}
593 Some(_) => {
594 return Err(CliError(format!(
595 "{}: \"hooks\" is not a JSON object — refusing to overwrite it",
596 settings_file.display()
597 )));
598 }
599 }
600 match root["hooks"].get(event) {
601 None => root["hooks"][event] = serde_json::json!([]),
602 Some(v) if v.is_array() => {}
603 Some(_) => {
604 return Err(CliError(format!(
605 "{}: \"hooks.{event}\" is not a JSON array — refusing to overwrite it",
606 settings_file.display()
607 )));
608 }
609 }
610 root["hooks"][event].as_array_mut().unwrap().push(entry);
611
612 let parent = settings_file.parent().unwrap_or(Path::new("."));
613 fs::create_dir_all(parent)
614 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
615 let json = serde_json::to_string_pretty(&root)
616 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
617 fs::write(settings_file, json)
618 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
619
620 manifest.hooks.push(ManagedHook {
621 file: settings_file.to_path_buf(),
622 event: event.into(),
623 command: command.into(),
624 });
625 Ok(())
626}
627
628fn remove_hook_entry(settings_file: &Path, event: &str, command: &str) -> Result<bool, CliError> {
633 drop_hooks(settings_file, event, |c| c == command)
634}
635
636pub(crate) fn is_our_hook_command(command: &str, sub: &str, db_str: &str) -> bool {
646 command.ends_with(&format!(" {sub} {}", sh_quote(db_str)))
647}
648
649fn remove_stale_hooks(
657 settings_file: &Path,
658 event: &str,
659 sub: &str,
660 db_str: &str,
661 desired: &str,
662) -> Result<bool, CliError> {
663 drop_hooks(settings_file, event, |c| {
664 c != desired && is_our_hook_command(c, sub, db_str)
665 })
666}
667
668fn drop_hooks(
678 settings_file: &Path,
679 event: &str,
680 drop_it: impl Fn(&str) -> bool,
681) -> Result<bool, CliError> {
682 if !settings_file.exists() {
683 return Ok(false);
684 }
685 let raw = fs::read_to_string(settings_file)
686 .map_err(|e| CliError(format!("cannot read {}: {e}", settings_file.display())))?;
687 let mut root: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
688 CliError(format!(
689 "corrupt settings json at {}: {e}",
690 settings_file.display()
691 ))
692 })?;
693
694 let Some(mut groups) = root
695 .get("hooks")
696 .and_then(|h| h.get(event))
697 .and_then(|g| g.as_array())
698 .cloned()
699 else {
700 return Ok(false);
703 };
704
705 for g in groups.iter_mut() {
706 if let Some(hs) = g["hooks"].as_array_mut() {
707 hs.retain(|h| !h["command"].as_str().is_some_and(&drop_it));
708 }
709 }
710 groups.retain(|g| {
711 g["hooks"]
712 .as_array()
713 .map(|hs| !hs.is_empty())
714 .unwrap_or(true)
715 });
716
717 let before = root.clone();
718 if groups.is_empty() {
719 root["hooks"].as_object_mut().unwrap().remove(event);
720 } else {
721 root["hooks"][event] = serde_json::Value::Array(groups);
722 }
723 if root == before {
724 return Ok(false);
729 }
730
731 let json = serde_json::to_string_pretty(&root)
732 .map_err(|e| CliError(format!("cannot serialize settings: {e}")))?;
733 fs::write(settings_file, json)
734 .map_err(|e| CliError(format!("cannot write {}: {e}", settings_file.display())))?;
735 Ok(true)
736}
737
738struct Ctx<'a> {
745 project_root: &'a Path,
746 home: &'a Path,
747 scope: Scope,
748 db: &'a str,
749 cmd: &'a McpCommand,
750 ext: &'a Externals,
751 git_hooks: bool,
752 prewarm: bool,
753}
754
755fn absolutise(path: &Path, base: &Path) -> PathBuf {
764 let joined = if path.is_absolute() {
765 path.to_path_buf()
766 } else {
767 base.join(path)
768 };
769 let mut out = PathBuf::new();
770 for c in joined.components() {
771 match c {
772 std::path::Component::CurDir => {}
773 other => out.push(other),
774 }
775 }
776 out
777}
778
779fn is_bare_program_name(p: &Path) -> bool {
789 let mut components = p.components();
790 matches!(
791 (components.next(), components.next()),
792 (Some(std::path::Component::Normal(_)), None)
793 )
794}
795
796fn absolutise_command(path: &Path, base: &Path) -> PathBuf {
798 if is_bare_program_name(path) {
799 path.to_path_buf()
800 } else {
801 absolutise(path, base)
802 }
803}
804
805pub fn run_install(
811 project_root: &Path,
812 home: &Path,
813 opts: &InstallOpts,
814) -> Result<String, CliError> {
815 run_install_with(
816 project_root,
817 home,
818 opts,
819 &detect_mcp_command(opts),
820 &Externals::from_env(),
821 )
822}
823
824pub fn run_install_with(
829 project_root: &Path,
830 home: &Path,
831 opts: &InstallOpts,
832 cmd: &McpCommand,
833 ext: &Externals,
834) -> Result<String, CliError> {
835 let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
836 let db = opts
837 .db
838 .as_ref()
839 .map(|d| absolutise(d, project_root))
840 .unwrap_or_else(|| default_db(scope, project_root, home));
841 let db_str = db.to_string_lossy();
842 let cmd = &match cmd {
845 McpCommand::Explicit(p) => McpCommand::Explicit(absolutise_command(p, project_root)),
846 other => other.clone(),
847 };
848
849 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
850 let platforms = expand_platform(&resolved);
851
852 for plat in &platforms {
855 preflight_check(project_root, home, plat, scope, &db_str, ext)?;
856 }
857
858 let ctx = Ctx {
859 project_root,
860 home,
861 scope,
862 db: &db_str,
863 cmd,
864 ext,
865 git_hooks: opts.git_hooks,
866 prewarm: opts.prewarm,
867 };
868
869 let manifest_path = manifest_path(project_root, home, scope, &platforms);
870
871 let existing = load_manifest(&manifest_path);
876
877 let mut manifest = Manifest::default();
878 let mut notes: Vec<String> = Vec::new();
879
880 let outcome = write_everything(&ctx, &platforms, &mut manifest, &mut notes);
881 if let Err(e) = outcome {
882 if !manifest.is_empty() {
886 let merged = union_manifests(load_manifest(&manifest_path), &manifest);
887 let _ = write_manifest(&manifest_path, &merged);
888 }
889 return Err(e);
890 }
891
892 let anything_written = !manifest.is_empty();
893 if anything_written {
894 let merged = union_manifests(existing, &manifest);
896 write_manifest(&manifest_path, &merged)?;
897 }
898
899 let labels: Vec<&str> = platforms.iter().map(Platform::label).collect();
900 let mut out = format!("mushroomdb installed ({})\n", labels.join(", "));
901 out.push_str(&format!(
902 " scope {}{}\n",
903 scope.label(),
904 if auto_scope { " (auto-detected)" } else { "" }
905 ));
906 for f in &manifest.files {
907 out.push_str(&format!(" wrote {}\n", f.display()));
908 }
909 for k in &manifest.mcp_keys {
910 out.push_str(&format!(
911 " added mcpServers.{} in {}\n",
912 k.server,
913 k.file.display()
914 ));
915 }
916 for h in &manifest.hooks {
917 out.push_str(&format!(
918 " added {} hook in {}\n",
919 h.event,
920 h.file.display()
921 ));
922 }
923 for g in &manifest.gitignore {
924 out.push_str(&format!(" added {} to {}\n", g.line, g.file.display()));
925 }
926 for h in &manifest.git_hooks {
927 out.push_str(&format!(" added git hook {}\n", h.display()));
928 }
929 if manifest.codex {
930 out.push_str(&format!(" added codex mcp server {SERVER_NAME}\n"));
931 }
932 if anything_written {
933 out.push_str(&format!(" manifest {}\n", manifest_path.display()));
934 out.push_str(&format!(" mcp command {}\n", cmd.shell()));
935 } else {
936 out.push_str(" (already installed — no changes)\n");
937 }
938 for n in ¬es {
939 out.push_str(&format!(" {n}\n"));
940 }
941 out.push_str(&format!(
942 "next: restart Claude Code in {}, then type /mushroom\n",
943 project_root.display()
944 ));
945 Ok(out)
946}
947
948fn write_everything(
951 ctx: &Ctx<'_>,
952 platforms: &[Platform],
953 manifest: &mut Manifest,
954 notes: &mut Vec<String>,
955) -> Result<(), CliError> {
956 if let Some(w) = scope_conflict_note(ctx, platforms) {
957 notes.push(w);
958 }
959
960 for plat in platforms {
961 install_platform(ctx, plat, manifest, notes)?;
962 }
963
964 let repo_wiring = platforms
974 .iter()
975 .any(|p| matches!(p, Platform::ClaudeCode | Platform::Cursor));
976 if ctx.scope == Scope::Project && repo_wiring {
977 ensure_gitignore_line(ctx, manifest)?;
978 if ctx.git_hooks {
979 install_git_hooks(ctx, manifest)?;
980 }
981 }
982
983 if let Some(w) = prewarm(ctx) {
984 notes.push(w);
985 }
986 Ok(())
987}
988
989pub fn run_uninstall(
991 project_root: &Path,
992 home: &Path,
993 opts: &InstallOpts,
994) -> Result<String, CliError> {
995 run_uninstall_with(project_root, home, opts, &Externals::from_env())
996}
997
998pub fn run_uninstall_with(
1001 project_root: &Path,
1002 home: &Path,
1003 opts: &InstallOpts,
1004 ext: &Externals,
1005) -> Result<String, CliError> {
1006 let (scope, auto_scope) = resolve_scope(project_root, opts.scope);
1007 let resolved = resolve_platform(project_root, home, opts.platform.as_ref())?;
1008 let platforms = expand_platform(&resolved);
1009
1010 let mut scope = scope;
1011 let mut manifest_path = manifest_path(project_root, home, scope, &platforms);
1012 if auto_scope && !manifest_path.exists() {
1018 let other = match scope {
1019 Scope::Project => Scope::User,
1020 Scope::User => Scope::Project,
1021 };
1022 let alt = self::manifest_path(project_root, home, other, &platforms);
1023 if alt.exists() {
1024 scope = other;
1025 manifest_path = alt;
1026 }
1027 }
1028 if !manifest_path.exists() {
1029 return Err(CliError(format!(
1030 "no install manifest found at {} — nothing to uninstall",
1031 manifest_path.display()
1032 )));
1033 }
1034
1035 let raw = fs::read_to_string(&manifest_path)
1036 .map_err(|e| CliError(format!("cannot read manifest: {e}")))?;
1037 let manifest: Manifest = serde_json::from_str::<Manifest>(&raw)
1038 .map_err(|e| CliError(format!("corrupt manifest: {e}")))?
1039 .sanitised();
1040
1041 let mut removed = Vec::new();
1042
1043 for key in &manifest.mcp_keys {
1048 if remove_mcp_key(&key.file, &key.server)? {
1049 removed.push(format!(
1050 "removed mcpServers.{} from {}",
1051 key.server,
1052 key.file.display()
1053 ));
1054 }
1055 }
1056
1057 for h in &manifest.hooks {
1059 if remove_hook_entry(&h.file, &h.event, &h.command)? {
1060 removed.push(format!(
1061 "removed {} hook from {}",
1062 h.event,
1063 h.file.display()
1064 ));
1065 }
1066 }
1067
1068 for h in &manifest.git_hooks {
1070 if remove_git_hook(h)? {
1071 removed.push(format!("removed git hook block from {}", h.display()));
1072 }
1073 }
1074
1075 for g in &manifest.gitignore {
1079 if remove_line(&g.file, &g.line)? {
1080 removed.push(format!("removed {} from {}", g.line, g.file.display()));
1081 }
1082 if g.created && g.file.exists() && file_is_blank(&g.file) {
1083 fs::remove_file(&g.file)
1084 .map_err(|e| CliError(format!("cannot remove {}: {e}", g.file.display())))?;
1085 removed.push(format!("removed {}", g.file.display()));
1086 }
1087 }
1088
1089 if manifest.codex {
1091 match ext.which("codex") {
1092 Some(bin) => {
1093 run_and_capture(&bin, &["mcp".into(), "remove".into(), SERVER_NAME.into()])
1094 .map_err(|e| CliError(format!("codex mcp remove failed: {e}")))?;
1095 removed.push(format!("removed codex mcp server {SERVER_NAME}"));
1096 }
1097 None => removed.push(
1100 "warning: codex is not on PATH — run `codex mcp remove mushroomdb` yourself"
1101 .to_string(),
1102 ),
1103 }
1104 }
1105
1106 for f in &manifest.files {
1108 if f.exists() {
1109 fs::remove_file(f)
1110 .map_err(|e| CliError(format!("cannot remove {}: {e}", f.display())))?;
1111 removed.push(format!("removed {}", f.display()));
1112 }
1113 }
1114
1115 if manifest_path.exists() {
1117 fs::remove_file(&manifest_path)
1118 .map_err(|e| CliError(format!("cannot remove manifest: {e}")))?;
1119 }
1120
1121 let mut out = "mushroomdb uninstalled\n".to_string();
1122 out.push_str(&format!(
1123 " scope {}{}\n",
1124 scope.label(),
1125 if auto_scope { " (auto-detected)" } else { "" }
1126 ));
1127 for line in &removed {
1128 out.push_str(&format!(" {line}\n"));
1129 }
1130 Ok(out)
1131}
1132
1133pub(crate) fn resolve_platform(
1138 project_root: &Path,
1139 home: &Path,
1140 requested: Option<&Platform>,
1141) -> Result<Platform, CliError> {
1142 if let Some(p) = requested {
1143 return Ok(p.clone());
1144 }
1145
1146 let has_claude = home.join(".claude").exists() || project_root.join(".claude").exists();
1149 let has_cursor = project_root.join(".cursor").exists() || home.join(".cursor").exists();
1150
1151 match (has_claude, has_cursor) {
1152 (true, true) => Ok(Platform::All),
1153 (true, false) => Ok(Platform::ClaudeCode),
1154 (false, true) => Ok(Platform::Cursor),
1155 (false, false) => Err(CliError(
1156 "cannot auto-detect platform: neither ~/.claude nor .cursor/ found.\n\
1157 Pass --platform claude-code, --platform cursor, --platform codex, or --platform all."
1158 .to_string(),
1159 )),
1160 }
1161}
1162
1163pub(crate) fn expand_platform(p: &Platform) -> Vec<Platform> {
1168 match p {
1169 Platform::All => vec![Platform::ClaudeCode, Platform::Cursor],
1170 other => vec![other.clone()],
1171 }
1172}
1173
1174fn preflight_check(
1179 project_root: &Path,
1180 home: &Path,
1181 platform: &Platform,
1182 scope: Scope,
1183 db_str: &str,
1184 ext: &Externals,
1185) -> Result<(), CliError> {
1186 match platform {
1187 Platform::ClaudeCode => {
1188 check_mcp_conflict(&claude_mcp_file(project_root, home, scope), db_str)
1189 }
1190 Platform::Cursor => check_mcp_conflict(&cursor_mcp_file(project_root, home, scope), db_str),
1191 Platform::Codex => codex_bin(ext).map(|_| ()),
1194 Platform::All => unreachable!("expand_platform never produces All"),
1195 }
1196}
1197
1198pub(crate) fn claude_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1199 match scope {
1200 Scope::Project => project_root.join(".mcp.json"),
1201 Scope::User => home.join(".claude.json"),
1205 }
1206}
1207
1208pub(crate) fn cursor_mcp_file(project_root: &Path, home: &Path, scope: Scope) -> PathBuf {
1209 match scope {
1210 Scope::Project => project_root.join(".cursor").join("mcp.json"),
1211 Scope::User => home.join(".cursor").join("mcp.json"),
1212 }
1213}
1214
1215pub(crate) fn entry_db(entry: &serde_json::Value) -> Option<&str> {
1221 let args = entry["args"].as_array()?;
1222 let at = args.iter().position(|a| a == "mcp")?;
1223 args.get(at + 1)?.as_str()
1224}
1225
1226fn check_mcp_conflict(mcp_file: &Path, db_str: &str) -> Result<(), CliError> {
1234 if !mcp_file.exists() {
1235 return Ok(());
1236 }
1237 let raw = fs::read_to_string(mcp_file)
1238 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1239 let v: serde_json::Value = serde_json::from_str(&raw)
1240 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?;
1241
1242 let existing = &v["mcpServers"][SERVER_NAME];
1243 if existing.is_null() {
1244 return Ok(()); }
1246
1247 let existing_db = entry_db(existing).unwrap_or("");
1248 if existing_db == db_str {
1249 return Ok(()); }
1251
1252 Err(CliError(format!(
1253 "conflict: {} already has mcpServers.mushroomdb pointing to {:?}\n\
1254 To update it, run `mushroomdb uninstall` first, then re-install.\n\
1255 Or manually edit {} and remove the existing mushroomdb entry.",
1256 mcp_file.display(),
1257 existing_db,
1258 mcp_file.display()
1259 )))
1260}
1261
1262fn scope_conflict_note(ctx: &Ctx<'_>, platforms: &[Platform]) -> Option<String> {
1266 if !platforms.contains(&Platform::ClaudeCode) {
1267 return None;
1268 }
1269 let (other, label, flag) = match ctx.scope {
1270 Scope::Project => (
1271 claude_mcp_file(ctx.project_root, ctx.home, Scope::User),
1272 "user",
1273 "--user",
1274 ),
1275 Scope::User => (
1276 claude_mcp_file(ctx.project_root, ctx.home, Scope::Project),
1277 "project",
1278 "--project",
1279 ),
1280 };
1281 if !has_our_server(&other) {
1282 return None;
1283 }
1284 Some(format!(
1285 "warning: a {label}-scope mushroomdb server also exists ({}) — \
1286 both will load; to remove that one run: mushroomdb uninstall {flag}",
1287 other.display()
1288 ))
1289}
1290
1291pub(crate) fn has_our_server(mcp_file: &Path) -> bool {
1292 let Ok(raw) = fs::read_to_string(mcp_file) else {
1293 return false;
1294 };
1295 serde_json::from_str::<serde_json::Value>(&raw)
1296 .map(|v| !v["mcpServers"][SERVER_NAME].is_null())
1297 .unwrap_or(false)
1298}
1299
1300fn install_platform(
1305 ctx: &Ctx<'_>,
1306 platform: &Platform,
1307 manifest: &mut Manifest,
1308 notes: &mut Vec<String>,
1309) -> Result<(), CliError> {
1310 match platform {
1311 Platform::ClaudeCode => install_claude_code(ctx, manifest, notes),
1312 Platform::Cursor => install_cursor(ctx, manifest, notes),
1313 Platform::Codex => install_codex(ctx, manifest),
1314 Platform::All => unreachable!("expand_platform never produces All"),
1315 }
1316}
1317
1318fn render_template(template: &str, db_str: &str, bin_cmd: &str) -> String {
1321 template
1322 .replace(DB_PATH_PLACEHOLDER, db_str)
1323 .replace(BIN_PLACEHOLDER, bin_cmd)
1324}
1325
1326fn install_claude_code(
1327 ctx: &Ctx<'_>,
1328 manifest: &mut Manifest,
1329 notes: &mut Vec<String>,
1330) -> Result<(), CliError> {
1331 let shell = ctx.cmd.shell();
1332 let skill_content = render_template(SKILL_TEMPLATE, ctx.db, &shell);
1333
1334 let skill_dir = match ctx.scope {
1335 Scope::Project => ctx
1336 .project_root
1337 .join(".claude")
1338 .join("skills")
1339 .join("mushroom"),
1340 Scope::User => ctx.home.join(".claude").join("skills").join("mushroom"),
1341 };
1342 let skill_file = skill_dir.join("SKILL.md");
1343
1344 if !file_matches(&skill_file, &skill_content) {
1346 fs::create_dir_all(&skill_dir)
1347 .map_err(|e| CliError(format!("cannot create {}: {e}", skill_dir.display())))?;
1348 fs::write(&skill_file, &skill_content)
1349 .map_err(|e| CliError(format!("cannot write {}: {e}", skill_file.display())))?;
1350 manifest.files.push(skill_file);
1351 }
1352
1353 let mcp_file = claude_mcp_file(ctx.project_root, ctx.home, ctx.scope);
1354 merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
1355
1356 let settings_file = match ctx.scope {
1359 Scope::Project => ctx.project_root.join(".claude").join("settings.json"),
1360 Scope::User => ctx.home.join(".claude").join("settings.json"),
1361 };
1362 let recall = recall_hook_command(&shell, ctx.db);
1366 if remove_stale_hooks(&settings_file, HOOK_EVENT, "recall", ctx.db, &recall)? {
1367 notes.push(format!("replaced stale {HOOK_EVENT} hook"));
1368 }
1369 merge_hook_entry(
1370 &settings_file,
1371 HOOK_EVENT,
1372 &recall,
1373 hook_entry(&recall),
1374 manifest,
1375 )?;
1376 let touch = touch_hook_command(&shell, ctx.db);
1377 if remove_stale_hooks(&settings_file, TOUCH_EVENT, "touch", ctx.db, &touch)? {
1378 notes.push(format!("replaced stale {TOUCH_EVENT} hook"));
1379 }
1380 merge_hook_entry(
1381 &settings_file,
1382 TOUCH_EVENT,
1383 &touch,
1384 touch_hook_entry(&touch),
1385 manifest,
1386 )?;
1387
1388 Ok(())
1389}
1390
1391fn install_cursor(
1392 ctx: &Ctx<'_>,
1393 manifest: &mut Manifest,
1394 notes: &mut Vec<String>,
1395) -> Result<(), CliError> {
1396 let rules_content = render_template(CURSOR_RULES_TEMPLATE, ctx.db, &ctx.cmd.shell());
1397
1398 let rules_dir = match ctx.scope {
1399 Scope::Project => ctx.project_root.join(".cursor").join("rules"),
1400 Scope::User => ctx.home.join(".cursor").join("rules"),
1401 };
1402 let rules_file = rules_dir.join("mushroom.mdc");
1403
1404 if !file_matches(&rules_file, &rules_content) {
1405 fs::create_dir_all(&rules_dir)
1406 .map_err(|e| CliError(format!("cannot create {}: {e}", rules_dir.display())))?;
1407 fs::write(&rules_file, &rules_content)
1408 .map_err(|e| CliError(format!("cannot write {}: {e}", rules_file.display())))?;
1409 manifest.files.push(rules_file);
1410 }
1411
1412 let mcp_file = cursor_mcp_file(ctx.project_root, ctx.home, ctx.scope);
1413 merge_mcp_entry(&mcp_file, ctx, manifest, notes)?;
1414
1415 Ok(())
1416}
1417
1418fn codex_bin(ext: &Externals) -> Result<PathBuf, CliError> {
1420 ext.which("codex").ok_or_else(|| {
1421 CliError(
1422 "codex was not found on PATH — install the Codex CLI, or drop \
1423 `--platform codex`"
1424 .to_string(),
1425 )
1426 })
1427}
1428
1429fn install_codex(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1436 let bin = codex_bin(ctx.ext)?;
1437 let mut args = vec![
1438 "mcp".to_string(),
1439 "add".to_string(),
1440 SERVER_NAME.to_string(),
1441 "--".to_string(),
1442 ];
1443 args.extend(ctx.cmd.argv("mcp", ctx.db));
1444 run_and_capture(&bin, &args).map_err(|e| CliError(format!("codex mcp add failed: {e}")))?;
1445 manifest.codex = true;
1446 Ok(())
1447}
1448
1449pub(crate) const GIT_HOOKS: &[&str] = &["post-commit", "post-checkout", "post-merge"];
1457
1458fn gitignore_line(project_root: &Path, db: &str) -> Option<String> {
1462 let rel = Path::new(db).strip_prefix(project_root).ok()?;
1463 if rel.as_os_str().is_empty() {
1464 return None;
1465 }
1466 Some(format!("{}/", rel.to_string_lossy().replace('\\', "/")))
1467}
1468
1469fn ensure_gitignore_line(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1472 let Some(line) = gitignore_line(ctx.project_root, ctx.db) else {
1473 return Ok(());
1474 };
1475 let path = ctx.project_root.join(".gitignore");
1476 let existed = path.exists();
1477 let current = match fs::read_to_string(&path) {
1478 Ok(s) => s,
1479 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
1480 Err(e) => return Err(CliError(format!("cannot read {}: {e}", path.display()))),
1481 };
1482 let bare = line.trim_end_matches('/');
1483 if current
1484 .lines()
1485 .map(str::trim)
1486 .any(|l| l == line || l == bare || l == format!("/{line}") || l == format!("/{bare}"))
1487 {
1488 return Ok(());
1489 }
1490 let mut next = current;
1491 if !next.is_empty() && !next.ends_with('\n') {
1492 next.push('\n');
1493 }
1494 next.push_str(&line);
1495 next.push('\n');
1496 fs::write(&path, next)
1497 .map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
1498 manifest.gitignore.push(ManagedLine {
1502 file: path,
1503 line,
1504 created: !existed,
1505 });
1506 Ok(())
1507}
1508
1509fn file_is_blank(path: &Path) -> bool {
1511 match fs::read_to_string(path) {
1512 Ok(s) => s.trim().is_empty(),
1513 Err(_) => true,
1514 }
1515}
1516
1517fn remove_line(path: &Path, line: &str) -> Result<bool, CliError> {
1520 let Ok(current) = fs::read_to_string(path) else {
1521 return Ok(false);
1522 };
1523 if !current.lines().any(|l| l == line) {
1524 return Ok(false);
1525 }
1526 let kept: Vec<&str> = current.lines().filter(|l| *l != line).collect();
1527 let mut next = kept.join("\n");
1528 if !next.is_empty() {
1529 next.push('\n');
1530 }
1531 fs::write(path, next).map_err(|e| CliError(format!("cannot write {}: {e}", path.display())))?;
1532 Ok(true)
1533}
1534
1535pub(crate) fn git_hooks_dir(project_root: &Path) -> Option<PathBuf> {
1538 let dot_git = project_root.join(".git");
1539 if dot_git.is_dir() {
1540 return Some(dot_git.join("hooks"));
1541 }
1542 let text = fs::read_to_string(&dot_git).ok()?;
1543 let target = text.strip_prefix("gitdir:")?.trim();
1544 let target = Path::new(target);
1545 let resolved = if target.is_absolute() {
1546 target.to_path_buf()
1547 } else {
1548 project_root.join(target)
1549 };
1550 Some(resolved.join("hooks"))
1551}
1552
1553fn install_git_hooks(ctx: &Ctx<'_>, manifest: &mut Manifest) -> Result<(), CliError> {
1554 let Some(dir) = git_hooks_dir(ctx.project_root) else {
1556 return Ok(());
1557 };
1558 let shell = ctx.cmd.shell();
1559 for name in GIT_HOOKS {
1560 let file = dir.join(name);
1561 if merge_git_hook(&file, &shell, ctx.db)? {
1562 manifest.git_hooks.push(file);
1563 }
1564 }
1565 Ok(())
1566}
1567
1568fn prewarm(ctx: &Ctx<'_>) -> Option<String> {
1579 if !ctx.prewarm {
1580 return None;
1581 }
1582 let McpCommand::Npx { version } = ctx.cmd else {
1583 return None;
1584 };
1585 let args = vec![
1586 "-y".to_string(),
1587 format!("{NPM_PACKAGE}@{version}"),
1588 "--version".to_string(),
1589 ];
1590 let Some(npx) = ctx.ext.which("npx") else {
1591 return Some(
1592 "warning: pre-warm skipped — npx is not on PATH; the first MCP \
1593 spawn will download the package"
1594 .to_string(),
1595 );
1596 };
1597 match run_with_timeout(&npx, &args, ctx.ext.prewarm_timeout) {
1598 Ok(()) => None,
1599 Err(e) => Some(format!(
1600 "warning: pre-warm of {NPM_PACKAGE}@{version} failed ({e}) — \
1601 the first MCP spawn will download the package"
1602 )),
1603 }
1604}
1605
1606fn merge_mcp_entry(
1615 mcp_file: &Path,
1616 ctx: &Ctx<'_>,
1617 manifest: &mut Manifest,
1618 notes: &mut Vec<String>,
1619) -> Result<(), CliError> {
1620 let mut root: serde_json::Value = if mcp_file.exists() {
1621 let raw = fs::read_to_string(mcp_file)
1622 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1623 serde_json::from_str(&raw)
1624 .map_err(|e| CliError(format!("invalid JSON in {}: {e}", mcp_file.display())))?
1625 } else {
1626 serde_json::json!({})
1627 };
1628
1629 if !root["mcpServers"].is_object() {
1631 root["mcpServers"] = serde_json::json!({});
1632 }
1633
1634 let desired = ctx.cmd.json_entry("mcp", ctx.db);
1635 let existing = &root["mcpServers"][SERVER_NAME];
1636
1637 if existing == &desired {
1638 return Ok(()); }
1640 let replaced = !existing.is_null();
1641
1642 root["mcpServers"][SERVER_NAME] = desired;
1644
1645 let parent = mcp_file.parent().unwrap_or(Path::new("."));
1646 fs::create_dir_all(parent)
1647 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1648
1649 let json = serde_json::to_string_pretty(&root)
1650 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
1651 fs::write(mcp_file, json)
1652 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
1653
1654 manifest.mcp_keys.push(ManagedMcpKey {
1655 file: mcp_file.to_path_buf(),
1656 server: SERVER_NAME.to_string(),
1657 });
1658 if replaced {
1659 notes.push(format!(
1660 "updated mcp command in {} → {}",
1661 mcp_file.display(),
1662 ctx.cmd.shell()
1663 ));
1664 }
1665
1666 Ok(())
1667}
1668
1669fn remove_mcp_key(mcp_file: &Path, server: &str) -> Result<bool, CliError> {
1678 if !mcp_file.exists() {
1679 return Ok(false);
1680 }
1681 let raw = fs::read_to_string(mcp_file)
1682 .map_err(|e| CliError(format!("cannot read {}: {e}", mcp_file.display())))?;
1683 let mut root: serde_json::Value = serde_json::from_str(&raw)
1684 .map_err(|e| CliError(format!("corrupt mcp json at {}: {e}", mcp_file.display())))?;
1685
1686 let removed = root["mcpServers"]
1687 .as_object_mut()
1688 .is_some_and(|servers| servers.remove(server).is_some());
1689 if !removed {
1690 return Ok(false);
1691 }
1692
1693 let json = serde_json::to_string_pretty(&root)
1694 .map_err(|e| CliError(format!("cannot serialize mcp json: {e}")))?;
1695 fs::write(mcp_file, json)
1696 .map_err(|e| CliError(format!("cannot write {}: {e}", mcp_file.display())))?;
1697 Ok(true)
1698}
1699
1700fn manifest_path(
1705 project_root: &Path,
1706 home: &Path,
1707 scope: Scope,
1708 platforms: &[Platform],
1709) -> PathBuf {
1710 if platforms == [Platform::Codex] {
1715 return home.join(".mushroomdb").join("install-manifest-codex.json");
1716 }
1717 if scope == Scope::User {
1718 return home.join(".mushroomdb").join("install-manifest.json");
1719 }
1720 if platforms.contains(&Platform::ClaudeCode) {
1722 project_root
1723 .join(".claude")
1724 .join("skills")
1725 .join("mushroom")
1726 .join(".install-manifest.json")
1727 } else {
1728 project_root.join(".cursor").join(".install-manifest.json")
1729 }
1730}
1731
1732fn load_manifest(path: &Path) -> Manifest {
1742 let raw = match fs::read_to_string(path) {
1743 Ok(s) => s,
1744 Err(_) => return Manifest::default(),
1745 };
1746 serde_json::from_str::<Manifest>(&raw)
1747 .unwrap_or_default()
1748 .sanitised()
1749}
1750
1751fn union_manifests(mut existing: Manifest, this_run: &Manifest) -> Manifest {
1756 for f in &this_run.files {
1757 if !existing.files.contains(f) {
1758 existing.files.push(f.clone());
1759 }
1760 }
1761 for k in &this_run.mcp_keys {
1762 let already = existing
1763 .mcp_keys
1764 .iter()
1765 .any(|e| e.file == k.file && e.server == k.server);
1766 if !already {
1767 existing.mcp_keys.push(k.clone());
1768 }
1769 }
1770 for h in &this_run.hooks {
1771 if !existing.hooks.contains(h) {
1772 existing.hooks.push(h.clone());
1773 }
1774 }
1775 for h in &this_run.git_hooks {
1776 if !existing.git_hooks.contains(h) {
1777 existing.git_hooks.push(h.clone());
1778 }
1779 }
1780 for l in &this_run.gitignore {
1781 if !existing.gitignore.contains(l) {
1782 existing.gitignore.push(l.clone());
1783 }
1784 }
1785 existing.codex |= this_run.codex;
1786 existing
1787}
1788
1789fn write_manifest(path: &Path, manifest: &Manifest) -> Result<(), CliError> {
1790 let parent = path.parent().unwrap_or(Path::new("."));
1791 fs::create_dir_all(parent).map_err(|e| {
1792 CliError(format!(
1793 "cannot create manifest dir {}: {e}",
1794 parent.display()
1795 ))
1796 })?;
1797 let json = serde_json::to_string_pretty(manifest)
1798 .map_err(|e| CliError(format!("cannot serialize manifest: {e}")))?;
1799 fs::write(path, json)
1800 .map_err(|e| CliError(format!("cannot write manifest {}: {e}", path.display())))?;
1801 Ok(())
1802}
1803
1804pub const HOOK_BEGIN: &str = "# >>> mushroomdb >>>";
1816pub const HOOK_END: &str = "# <<< mushroomdb <<<";
1818const HOOK_SHEBANG: &str = "#!/bin/sh";
1820
1821#[must_use]
1833pub fn git_hook_block(shell: &str, db: &str) -> String {
1834 format!(
1835 "{HOOK_BEGIN}\n( {shell} sync {} >/dev/null 2>&1 & )\n{HOOK_END}\n",
1836 sh_quote(db)
1837 )
1838}
1839
1840enum Stripped {
1842 Absent,
1844 Removed(String),
1846 Unterminated,
1849}
1850
1851fn strip_hook_block(text: &str) -> Stripped {
1865 let mut kept: Vec<&str> = Vec::new();
1866 let mut inside = false;
1867 let mut found = false;
1868 for line in text.lines() {
1869 if !inside && line.trim_end() == HOOK_BEGIN {
1870 inside = true;
1871 found = true;
1872 continue;
1873 }
1874 if inside {
1875 if line.trim_end() == HOOK_END {
1876 inside = false;
1877 }
1878 continue;
1879 }
1880 kept.push(line);
1881 }
1882 if !found {
1883 return Stripped::Absent;
1884 }
1885 if inside {
1886 return Stripped::Unterminated;
1887 }
1888 while kept.last().is_some_and(|l| l.trim().is_empty()) {
1889 kept.pop();
1890 }
1891 let mut out = kept.join("\n");
1892 if !out.is_empty() {
1893 out.push('\n');
1894 }
1895 Stripped::Removed(out)
1896}
1897
1898fn unterminated(hook_file: &Path) -> CliError {
1900 CliError(format!(
1901 "{}: a mushroomdb block opens with `{HOOK_BEGIN}` but never closes \
1902 — refusing to edit it; delete the block by hand and re-run",
1903 hook_file.display()
1904 ))
1905}
1906
1907fn merged_hook_text(existing: Option<&str>, block: &str) -> Result<String, ()> {
1914 let base = match existing {
1915 None => String::new(),
1916 Some(text) => match strip_hook_block(text) {
1917 Stripped::Absent => text.to_string(),
1918 Stripped::Removed(rest) => rest,
1919 Stripped::Unterminated => return Err(()),
1920 },
1921 };
1922 let mut lines: Vec<&str> = base.lines().collect();
1923 while lines.last().is_some_and(|l| l.trim().is_empty()) {
1924 lines.pop();
1925 }
1926 if lines.is_empty() {
1929 lines.push(HOOK_SHEBANG);
1930 }
1931 let mut out = lines.join("\n");
1932 out.push_str("\n\n");
1933 out.push_str(block);
1934 Ok(out)
1935}
1936
1937fn only_a_shebang(text: &str) -> bool {
1940 text.lines()
1941 .filter(|l| !l.trim().is_empty())
1942 .all(|l| l.starts_with("#!"))
1943}
1944
1945pub fn merge_git_hook(hook_file: &Path, shell: &str, db: &str) -> Result<bool, CliError> {
1953 let existing = if hook_file.exists() {
1954 Some(
1955 fs::read_to_string(hook_file)
1956 .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?,
1957 )
1958 } else {
1959 None
1960 };
1961 let next = merged_hook_text(existing.as_deref(), &git_hook_block(shell, db))
1962 .map_err(|()| unterminated(hook_file))?;
1963 if existing.as_deref() == Some(next.as_str()) {
1964 return Ok(false);
1965 }
1966 let parent = hook_file.parent().unwrap_or(Path::new("."));
1967 fs::create_dir_all(parent)
1968 .map_err(|e| CliError(format!("cannot create {}: {e}", parent.display())))?;
1969 fs::write(hook_file, &next)
1970 .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
1971 #[cfg(unix)]
1972 {
1973 use std::os::unix::fs::PermissionsExt;
1974 fs::set_permissions(hook_file, fs::Permissions::from_mode(0o755)).map_err(|e| {
1976 CliError(format!(
1977 "cannot make {} executable: {e}",
1978 hook_file.display()
1979 ))
1980 })?;
1981 }
1982 Ok(true)
1983}
1984
1985pub fn remove_git_hook(hook_file: &Path) -> Result<bool, CliError> {
1997 if !hook_file.exists() {
1998 return Ok(false);
1999 }
2000 let existing = fs::read_to_string(hook_file)
2001 .map_err(|e| CliError(format!("cannot read {}: {e}", hook_file.display())))?;
2002 let next = match strip_hook_block(&existing) {
2003 Stripped::Absent => return Ok(false),
2005 Stripped::Removed(rest) => rest,
2006 Stripped::Unterminated => return Err(unterminated(hook_file)),
2007 };
2008 if only_a_shebang(&next) {
2009 fs::remove_file(hook_file)
2010 .map_err(|e| CliError(format!("cannot remove {}: {e}", hook_file.display())))?;
2011 return Ok(true);
2012 }
2013 fs::write(hook_file, next)
2014 .map_err(|e| CliError(format!("cannot write {}: {e}", hook_file.display())))?;
2015 Ok(true)
2016}
2017
2018fn file_matches(path: &Path, expected: &str) -> bool {
2024 fs::read_to_string(path)
2025 .map(|s| s == expected)
2026 .unwrap_or(false)
2027}