1use anyhow::{Context, Result};
28use serde_json::{Value, json};
29use std::path::{Path, PathBuf};
30
31use super::shared::{
32 self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
33};
34use super::templates::{self, cursor_hook_specs};
35use super::{
36 ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
37 InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
38};
39
40const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
42const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;
44
45pub struct CursorInstaller {
46 home_override: Option<PathBuf>,
47}
48
49impl CursorInstaller {
50 pub fn new() -> Self {
51 Self {
52 home_override: None,
53 }
54 }
55
56 #[doc(hidden)]
57 pub fn with_home_root(root: PathBuf) -> Self {
58 Self {
59 home_override: Some(root),
60 }
61 }
62
63 fn home(&self) -> Result<PathBuf> {
64 match &self.home_override {
65 Some(p) => Ok(p.clone()),
66 None => shared::home_dir(),
67 }
68 }
69
70 fn cursor_dir(&self) -> Result<PathBuf> {
71 Ok(self.home()?.join(".cursor"))
72 }
73
74 fn mcp_json_path(&self) -> Result<PathBuf> {
75 Ok(self.cursor_dir()?.join("mcp.json"))
76 }
77
78 fn settings_json_path(&self) -> Result<PathBuf> {
79 Ok(self.cursor_dir()?.join("settings.json"))
80 }
81
82 fn hooks_dir(&self) -> Result<PathBuf> {
83 Ok(self.cursor_dir()?.join("hooks"))
84 }
85
86 fn default_binary_path(&self) -> Result<PathBuf> {
87 Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
88 }
89
90 fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
91 match &ctx.binary_path {
92 Some(p) => Ok(p.clone()),
93 None => self.default_binary_path(),
94 }
95 }
96
97 fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
98 if !ctx.config_path.is_absolute() {
99 anyhow::bail!(
100 "config path must be absolute, got: {}",
101 ctx.config_path.display()
102 );
103 }
104 if !binary_path.is_absolute() {
105 anyhow::bail!(
106 "binary path must be absolute, got: {}",
107 binary_path.display()
108 );
109 }
110 Ok(())
111 }
112}
113
114impl Default for CursorInstaller {
115 fn default() -> Self {
116 Self::new()
117 }
118}
119
120impl Installer for CursorInstaller {
121 fn id(&self) -> ClientId {
122 ClientId::Cursor
123 }
124
125 fn detect(&self) -> Result<bool> {
126 let cursor_dir = self.cursor_dir()?;
127 Ok(cursor_dir.exists())
128 }
129
130 fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
131 let binary_path = self.resolve_binary_path(ctx)?;
132 self.validate_inputs(ctx, &binary_path)?;
133
134 let mut planned_writes: Vec<PathBuf> = Vec::new();
135 let mut backups: Vec<PathBuf> = Vec::new();
136 let mut notes: Vec<String> = Vec::new();
137
138 let mcp_json = self.mcp_json_path()?;
140 let mut mcp_doc = shared::read_json_or_empty(&mcp_json)?;
141 let desired = build_mcp_entry(&binary_path, &ctx.config_path);
142 let mcp_outcome = merge_mcp_entry(&mut mcp_doc, "spool", desired, ctx.force);
143
144 if !binary_path.exists() {
145 notes.push(format!(
146 "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
147 binary_path.display()
148 ));
149 }
150
151 let mcp_status = match mcp_outcome {
152 McpMergeOutcome::Inserted => MergeStatus::Changed,
153 McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
154 McpMergeOutcome::Conflict {
155 force_applied: true,
156 } => {
157 notes.push(format!(
158 "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
159 mcp_json.display()
160 ));
161 MergeStatus::Changed
162 }
163 McpMergeOutcome::Conflict {
164 force_applied: false,
165 } => {
166 notes.push(
167 "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first.".to_string(),
168 );
169 MergeStatus::Conflict
170 }
171 };
172
173 if matches!(mcp_status, MergeStatus::Conflict) {
174 return Ok(InstallReport {
175 client: ClientId::Cursor.as_str().to_string(),
176 binary_path,
177 config_path: ctx.config_path.clone(),
178 status: InstallStatus::Conflict,
179 planned_writes,
180 backups,
181 notes,
182 });
183 }
184
185 let settings_json = self.settings_json_path()?;
187 let mut settings_doc = shared::read_json_or_empty(&settings_json)?;
188 let hooks_dir = self.hooks_dir()?;
189 let hook_specs = cursor_hook_specs();
190 let mut settings_changed = false;
191 for spec in &hook_specs {
192 let target_path = hooks_dir.join(spec.file_name);
193 let target_str = target_path.to_string_lossy().into_owned();
194 let timeout = timeout_for_event(spec.hook_event);
195 match upsert_cursor_hook_entry(&mut settings_doc, spec.hook_event, &target_str, timeout)
196 {
197 CursorHookOutcome::Appended => settings_changed = true,
198 CursorHookOutcome::Unchanged => {}
199 }
200 }
201
202 let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
204 let hook_files: Vec<HookFilePlan> = hook_specs
205 .iter()
206 .map(|spec| HookFilePlan {
207 target_path: hooks_dir.join(spec.file_name),
208 rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
209 })
210 .collect();
211 let hook_files_changed = hook_files
212 .iter()
213 .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));
214
215 let any_change =
217 matches!(mcp_status, MergeStatus::Changed) || settings_changed || hook_files_changed;
218
219 if ctx.dry_run {
220 if matches!(mcp_status, MergeStatus::Changed) {
221 planned_writes.push(mcp_json.clone());
222 }
223 if settings_changed {
224 planned_writes.push(settings_json.clone());
225 }
226 for plan in &hook_files {
227 if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
228 planned_writes.push(plan.target_path.clone());
229 }
230 }
231 return Ok(InstallReport {
232 client: ClientId::Cursor.as_str().to_string(),
233 binary_path,
234 config_path: ctx.config_path.clone(),
235 status: if any_change {
236 InstallStatus::DryRun
237 } else {
238 InstallStatus::Unchanged
239 },
240 planned_writes,
241 backups,
242 notes,
243 });
244 }
245
246 if matches!(mcp_status, MergeStatus::Changed) {
248 if let Some(b) = shared::backup_file(&mcp_json)
249 .with_context(|| format!("backing up {}", mcp_json.display()))?
250 {
251 backups.push(b);
252 }
253 shared::write_json_atomic(&mcp_json, &mcp_doc)
254 .with_context(|| format!("writing {}", mcp_json.display()))?;
255 planned_writes.push(mcp_json.clone());
256 }
257
258 if settings_changed {
260 if let Some(b) = shared::backup_file(&settings_json)
261 .with_context(|| format!("backing up {}", settings_json.display()))?
262 {
263 backups.push(b);
264 }
265 shared::write_json_atomic(&settings_json, &settings_doc)
266 .with_context(|| format!("writing {}", settings_json.display()))?;
267 planned_writes.push(settings_json.clone());
268 }
269
270 if !hooks_dir.exists() {
272 std::fs::create_dir_all(&hooks_dir)
273 .with_context(|| format!("creating {}", hooks_dir.display()))?;
274 }
275 for plan in &hook_files {
276 if file_has_exact_contents(&plan.target_path, &plan.rendered) {
277 continue;
278 }
279 std::fs::write(&plan.target_path, &plan.rendered)
280 .with_context(|| format!("writing {}", plan.target_path.display()))?;
281 set_executable(&plan.target_path)?;
282 planned_writes.push(plan.target_path.clone());
283 }
284
285 let final_status = if any_change {
286 InstallStatus::Installed
287 } else {
288 InstallStatus::Unchanged
289 };
290
291 Ok(InstallReport {
292 client: ClientId::Cursor.as_str().to_string(),
293 binary_path,
294 config_path: ctx.config_path.clone(),
295 status: final_status,
296 planned_writes,
297 backups,
298 notes,
299 })
300 }
301
302 fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
303 let report = self.install(ctx)?;
304 let status = match report.status {
305 InstallStatus::Installed => UpdateStatus::Updated,
306 InstallStatus::Unchanged => UpdateStatus::Unchanged,
307 InstallStatus::DryRun => UpdateStatus::DryRun,
308 InstallStatus::Conflict => UpdateStatus::NotInstalled,
309 };
310 Ok(UpdateReport {
311 client: report.client,
312 status,
313 updated_paths: report.planned_writes,
314 notes: report.notes,
315 })
316 }
317
318 fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
319 let mcp_json = self.mcp_json_path()?;
320 let settings_json = self.settings_json_path()?;
321 let hooks_dir = self.hooks_dir()?;
322
323 let mut notes: Vec<String> = Vec::new();
324 let mut removed_paths: Vec<PathBuf> = Vec::new();
325 let mut backups: Vec<PathBuf> = Vec::new();
326 let mut any_change = false;
327
328 let mcp_doc_after_purge = if mcp_json.exists() {
330 let mut doc = shared::read_json_or_empty(&mcp_json)?;
331 match remove_mcp_entry(&mut doc, "spool") {
332 McpRemoveOutcome::Removed => {
333 any_change = true;
334 Some(doc)
335 }
336 McpRemoveOutcome::NotPresent => None,
337 }
338 } else {
339 None
340 };
341
342 let settings_doc_after_purge = if settings_json.exists() {
344 let mut doc = shared::read_json_or_empty(&settings_json)?;
345 let removed = purge_cursor_hook_entries(&mut doc, "spool-");
346 if removed > 0 {
347 any_change = true;
348 Some(doc)
349 } else {
350 None
351 }
352 } else {
353 None
354 };
355
356 let hook_files: Vec<PathBuf> = cursor_hook_specs()
358 .iter()
359 .map(|s| hooks_dir.join(s.file_name))
360 .filter(|p| p.exists())
361 .collect();
362 if !hook_files.is_empty() {
363 any_change = true;
364 }
365
366 if !any_change {
367 notes.push("nothing to uninstall — no spool artifacts found.".to_string());
368 return Ok(UninstallReport {
369 client: ClientId::Cursor.as_str().to_string(),
370 status: UninstallStatus::NotInstalled,
371 removed_paths,
372 backups,
373 notes,
374 });
375 }
376
377 if ctx.dry_run {
378 if mcp_doc_after_purge.is_some() {
379 removed_paths.push(mcp_json);
380 }
381 if settings_doc_after_purge.is_some() {
382 removed_paths.push(settings_json);
383 }
384 removed_paths.extend(hook_files);
385 return Ok(UninstallReport {
386 client: ClientId::Cursor.as_str().to_string(),
387 status: UninstallStatus::DryRun,
388 removed_paths,
389 backups,
390 notes,
391 });
392 }
393
394 if let Some(doc) = mcp_doc_after_purge {
396 if let Some(b) = shared::backup_file(&mcp_json)? {
397 backups.push(b);
398 }
399 shared::write_json_atomic(&mcp_json, &doc)?;
400 removed_paths.push(mcp_json);
401 }
402
403 if let Some(doc) = settings_doc_after_purge {
405 if let Some(b) = shared::backup_file(&settings_json)? {
406 backups.push(b);
407 }
408 shared::write_json_atomic(&settings_json, &doc)?;
409 removed_paths.push(settings_json);
410 }
411
412 for p in hook_files {
414 std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
415 removed_paths.push(p);
416 }
417
418 Ok(UninstallReport {
419 client: ClientId::Cursor.as_str().to_string(),
420 status: UninstallStatus::Removed,
421 removed_paths,
422 backups,
423 notes,
424 })
425 }
426
427 fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
428 let mut checks = Vec::new();
429
430 let cursor_dir = self.cursor_dir()?;
432 checks.push(DiagnosticCheck {
433 name: "cursor_dir_exists".into(),
434 status: if cursor_dir.exists() {
435 DiagnosticStatus::Ok
436 } else {
437 DiagnosticStatus::Warn
438 },
439 detail: format!("{}", cursor_dir.display()),
440 });
441
442 let mcp_json = self.mcp_json_path()?;
444 let registration_status = if mcp_json.exists() {
445 let doc = shared::read_json_or_empty(&mcp_json)?;
446 if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
447 DiagnosticStatus::Ok
448 } else {
449 DiagnosticStatus::Warn
450 }
451 } else {
452 DiagnosticStatus::NotApplicable
453 };
454 checks.push(DiagnosticCheck {
455 name: "mcp_servers_spool_registered".into(),
456 status: registration_status,
457 detail: "mcpServers.spool entry presence".into(),
458 });
459
460 let binary_path = self.resolve_binary_path(ctx)?;
462 checks.push(DiagnosticCheck {
463 name: "spool_mcp_binary".into(),
464 status: if binary_path.exists() {
465 DiagnosticStatus::Ok
466 } else {
467 DiagnosticStatus::Fail
468 },
469 detail: format!("{}", binary_path.display()),
470 });
471
472 checks.push(DiagnosticCheck {
474 name: "spool_config_readable".into(),
475 status: if ctx.config_path.exists() {
476 DiagnosticStatus::Ok
477 } else {
478 DiagnosticStatus::Fail
479 },
480 detail: format!("{}", ctx.config_path.display()),
481 });
482
483 let settings_json = self.settings_json_path()?;
485 let hooks_registered_status = if settings_json.exists() {
486 let doc = shared::read_json_or_empty(&settings_json)?;
487 if has_any_spool_cursor_hook(&doc) {
488 DiagnosticStatus::Ok
489 } else {
490 DiagnosticStatus::Warn
491 }
492 } else {
493 DiagnosticStatus::Warn
494 };
495 checks.push(DiagnosticCheck {
496 name: "cursor_hooks_registered".into(),
497 status: hooks_registered_status,
498 detail: format!("{}", settings_json.display()),
499 });
500
501 let hooks_dir = self.hooks_dir()?;
503 let mut missing: Vec<String> = Vec::new();
504 for spec in cursor_hook_specs() {
505 let p = hooks_dir.join(spec.file_name);
506 if !p.exists() {
507 missing.push(spec.file_name.to_string());
508 }
509 }
510 let hook_files_detail = if missing.is_empty() {
511 format!("{} (2/2 present)", hooks_dir.display())
512 } else {
513 format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
514 };
515 checks.push(DiagnosticCheck {
516 name: "spool_hook_scripts".into(),
517 status: if missing.is_empty() {
518 DiagnosticStatus::Ok
519 } else {
520 DiagnosticStatus::Warn
521 },
522 detail: hook_files_detail,
523 });
524
525 Ok(DiagnosticReport {
526 client: ClientId::Cursor.as_str().to_string(),
527 checks,
528 })
529 }
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537enum MergeStatus {
538 Changed,
539 Unchanged,
540 Conflict,
541}
542
543struct HookFilePlan {
544 target_path: PathBuf,
545 rendered: String,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
549enum CursorHookOutcome {
550 Appended,
551 Unchanged,
552}
553
554fn timeout_for_event(event: &str) -> u64 {
555 match event {
556 "onSessionEnd" => HOOK_TIMEOUT_SESSION_END_MS,
557 _ => HOOK_TIMEOUT_DEFAULT_MS,
558 }
559}
560
561fn upsert_cursor_hook_entry(
564 doc: &mut Value,
565 event: &str,
566 command_path: &str,
567 timeout_ms: u64,
568) -> CursorHookOutcome {
569 let root = match doc.as_object_mut() {
570 Some(obj) => obj,
571 None => {
572 *doc = json!({});
573 doc.as_object_mut().expect("just inserted")
574 }
575 };
576 let cursor_hooks = root.entry("cursor.hooks").or_insert_with(|| json!({}));
577 if !cursor_hooks.is_object() {
578 *cursor_hooks = json!({});
579 }
580 let hooks_obj = cursor_hooks
581 .as_object_mut()
582 .expect("cursor.hooks must be object");
583 let entries = hooks_obj
584 .entry(event)
585 .or_insert_with(|| Value::Array(Vec::new()));
586 if !entries.is_array() {
587 *entries = Value::Array(Vec::new());
588 }
589 let array = entries.as_array_mut().expect("entries must be array");
590
591 for entry in array.iter() {
592 if entry.get("command").and_then(Value::as_str) == Some(command_path) {
593 return CursorHookOutcome::Unchanged;
594 }
595 }
596
597 array.push(json!({
598 "command": command_path,
599 "timeout": timeout_ms,
600 }));
601 CursorHookOutcome::Appended
602}
603
604fn purge_cursor_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
607 let mut removed = 0usize;
608 let Some(root) = doc.as_object_mut() else {
609 return 0;
610 };
611 let Some(cursor_hooks) = root.get_mut("cursor.hooks").and_then(|v| v.as_object_mut()) else {
612 return 0;
613 };
614 for (_event, entries) in cursor_hooks.iter_mut() {
615 let Some(array) = entries.as_array_mut() else {
616 continue;
617 };
618 let before = array.len();
619 array.retain(|entry| {
620 !entry
621 .get("command")
622 .and_then(Value::as_str)
623 .is_some_and(|c| c.contains(marker_substring))
624 });
625 removed += before - array.len();
626 }
627 cursor_hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
628 if cursor_hooks.is_empty() {
629 root.remove("cursor.hooks");
630 }
631 removed
632}
633
634fn has_any_spool_cursor_hook(doc: &Value) -> bool {
635 let Some(cursor_hooks) = doc.get("cursor.hooks").and_then(|v| v.as_object()) else {
636 return false;
637 };
638 for entries in cursor_hooks.values() {
639 let Some(arr) = entries.as_array() else {
640 continue;
641 };
642 for entry in arr {
643 if entry
644 .get("command")
645 .and_then(Value::as_str)
646 .is_some_and(|c| c.contains("spool-"))
647 {
648 return true;
649 }
650 }
651 }
652 false
653}
654
655fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
656 if !path.exists() {
657 return false;
658 }
659 match std::fs::read_to_string(path) {
660 Ok(actual) => actual == expected,
661 Err(_) => false,
662 }
663}
664
665#[cfg(unix)]
666fn set_executable(path: &Path) -> Result<()> {
667 use std::os::unix::fs::PermissionsExt;
668 let mut perms = std::fs::metadata(path)
669 .with_context(|| format!("stat {}", path.display()))?
670 .permissions();
671 perms.set_mode(0o755);
672 std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
673 Ok(())
674}
675
676#[cfg(not(unix))]
677fn set_executable(_path: &Path) -> Result<()> {
678 Ok(())
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use std::fs;
685 use tempfile::tempdir;
686
687 fn setup() -> (tempfile::TempDir, CursorInstaller, InstallContext) {
688 let temp = tempdir().unwrap();
689 let home = temp.path().to_path_buf();
690 let installer = CursorInstaller::with_home_root(home.clone());
691
692 let config_path = home.join("spool.toml");
693 fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
694 let binary_path = home.join("fake-spool-mcp");
695 fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
696
697 let ctx = InstallContext {
698 binary_path: Some(binary_path),
699 config_path,
700 dry_run: false,
701 force: false,
702 };
703 (temp, installer, ctx)
704 }
705
706 #[test]
707 fn detect_returns_false_when_no_cursor_dir() {
708 let temp = tempdir().unwrap();
709 let installer = CursorInstaller::with_home_root(temp.path().to_path_buf());
710 assert!(!installer.detect().unwrap());
711 }
712
713 #[test]
714 fn detect_returns_true_when_cursor_dir_present() {
715 let temp = tempdir().unwrap();
716 fs::create_dir_all(temp.path().join(".cursor")).unwrap();
717 let installer = CursorInstaller::with_home_root(temp.path().to_path_buf());
718 assert!(installer.detect().unwrap());
719 }
720
721 #[test]
722 fn install_creates_mcp_json_and_hooks() {
723 let (temp, installer, ctx) = setup();
724 let report = installer.install(&ctx).unwrap();
725 assert_eq!(report.status, InstallStatus::Installed);
726
727 let mcp_json = temp.path().join(".cursor").join("mcp.json");
729 assert!(mcp_json.exists());
730 let doc: Value = serde_json::from_str(&fs::read_to_string(&mcp_json).unwrap()).unwrap();
731 assert!(doc["mcpServers"]["spool"].is_object());
732
733 let settings_json = temp.path().join(".cursor").join("settings.json");
735 assert!(settings_json.exists());
736 let settings_doc: Value =
737 serde_json::from_str(&fs::read_to_string(&settings_json).unwrap()).unwrap();
738 assert!(settings_doc["cursor.hooks"]["onSessionStart"].is_array());
739 assert!(settings_doc["cursor.hooks"]["onSessionEnd"].is_array());
740
741 let end_entry = &settings_doc["cursor.hooks"]["onSessionEnd"][0];
743 assert_eq!(end_entry["timeout"], HOOK_TIMEOUT_SESSION_END_MS);
744
745 for spec in cursor_hook_specs() {
747 let p = temp
748 .path()
749 .join(".cursor")
750 .join("hooks")
751 .join(spec.file_name);
752 assert!(p.exists(), "{} missing", p.display());
753 }
754 }
755
756 #[test]
757 fn install_dry_run_does_not_write() {
758 let (temp, installer, mut ctx) = setup();
759 ctx.dry_run = true;
760 let report = installer.install(&ctx).unwrap();
761 assert_eq!(report.status, InstallStatus::DryRun);
762 assert!(!report.planned_writes.is_empty());
763 assert!(!temp.path().join(".cursor").exists());
764 }
765
766 #[test]
767 fn install_unchanged_on_repeat() {
768 let (_temp, installer, ctx) = setup();
769 let _ = installer.install(&ctx).unwrap();
770 let second = installer.install(&ctx).unwrap();
771 assert_eq!(second.status, InstallStatus::Unchanged);
772 }
773
774 #[test]
775 fn uninstall_removes_entries() {
776 let (temp, installer, ctx) = setup();
777 let _ = installer.install(&ctx).unwrap();
778
779 let report = installer.uninstall(&ctx).unwrap();
780 assert_eq!(report.status, UninstallStatus::Removed);
781
782 for spec in cursor_hook_specs() {
784 let p = temp
785 .path()
786 .join(".cursor")
787 .join("hooks")
788 .join(spec.file_name);
789 assert!(!p.exists(), "{} should be removed", p.display());
790 }
791
792 let mcp_json = temp.path().join(".cursor").join("mcp.json");
794 let doc: Value = serde_json::from_str(&fs::read_to_string(&mcp_json).unwrap()).unwrap();
795 assert!(doc["mcpServers"].get("spool").is_none());
796 }
797
798 #[test]
799 fn uninstall_not_installed_when_clean() {
800 let (_temp, installer, ctx) = setup();
801 let report = installer.uninstall(&ctx).unwrap();
802 assert_eq!(report.status, UninstallStatus::NotInstalled);
803 }
804
805 #[test]
806 fn update_unchanged_when_templates_match() {
807 let (_temp, installer, ctx) = setup();
808 let _ = installer.install(&ctx).unwrap();
809 let report = installer.update(&ctx).unwrap();
810 assert_eq!(report.status, UpdateStatus::Unchanged);
811 }
812
813 #[test]
814 fn diagnose_reports_full_check_set_after_install() {
815 let (_temp, installer, ctx) = setup();
816 let _ = installer.install(&ctx).unwrap();
817 let report = installer.diagnose(&ctx).unwrap();
818 let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
819 for expected in [
820 "cursor_dir_exists",
821 "mcp_servers_spool_registered",
822 "spool_mcp_binary",
823 "spool_config_readable",
824 "cursor_hooks_registered",
825 "spool_hook_scripts",
826 ] {
827 assert!(names.contains(&expected), "missing check {}", expected);
828 }
829 }
830
831 #[cfg(unix)]
832 #[test]
833 fn install_makes_hook_scripts_executable() {
834 use std::os::unix::fs::PermissionsExt;
835 let (temp, installer, ctx) = setup();
836 let _ = installer.install(&ctx).unwrap();
837 let session = temp
838 .path()
839 .join(".cursor")
840 .join("hooks")
841 .join("spool-on_session_start.sh");
842 let perms = fs::metadata(&session).unwrap().permissions();
843 assert_eq!(perms.mode() & 0o777, 0o755);
844 }
845
846 #[test]
847 fn cursor_hook_entries_use_flat_format_with_timeout() {
848 let mut doc = json!({});
849 upsert_cursor_hook_entry(
850 &mut doc,
851 "onSessionStart",
852 "/abs/spool-on_session_start.sh",
853 5000,
854 );
855 let entries = doc["cursor.hooks"]["onSessionStart"].as_array().unwrap();
856 assert_eq!(entries.len(), 1);
857 assert_eq!(entries[0]["command"], "/abs/spool-on_session_start.sh");
858 assert_eq!(entries[0]["timeout"], 5000);
859 }
860
861 #[test]
862 fn upsert_cursor_hook_unchanged_on_repeat() {
863 let mut doc = json!({});
864 upsert_cursor_hook_entry(&mut doc, "onSessionStart", "/abs/hook.sh", 5000);
865 let outcome = upsert_cursor_hook_entry(&mut doc, "onSessionStart", "/abs/hook.sh", 5000);
866 assert_eq!(outcome, CursorHookOutcome::Unchanged);
867 assert_eq!(
868 doc["cursor.hooks"]["onSessionStart"]
869 .as_array()
870 .unwrap()
871 .len(),
872 1
873 );
874 }
875
876 #[test]
877 fn purge_cursor_hook_entries_removes_spool_only() {
878 let mut doc = json!({
879 "cursor.hooks": {
880 "onSessionStart": [
881 {"command": "/other/tool", "timeout": 3000},
882 {"command": "/abs/.cursor/hooks/spool-on_session_start.sh", "timeout": 5000}
883 ],
884 "onSessionEnd": [
885 {"command": "/abs/spool-on_session_end.sh", "timeout": 10000}
886 ]
887 }
888 });
889 let removed = purge_cursor_hook_entries(&mut doc, "spool-");
890 assert_eq!(removed, 2);
891 let entries = doc["cursor.hooks"]["onSessionStart"].as_array().unwrap();
892 assert_eq!(entries.len(), 1);
893 assert_eq!(entries[0]["command"], "/other/tool");
894 assert!(doc["cursor.hooks"].get("onSessionEnd").is_none());
896 }
897}