Skip to main content

spool/installers/
cursor.rs

1//! Cursor AI installer adapter.
2//!
3//! ## What we touch
4//! - `~/.cursor/mcp.json`       — `mcpServers.spool` entry
5//! - `~/.cursor/settings.json`  — hook entries under `cursor.hooks`
6//! - `~/.cursor/hooks/spool-*.sh` — two hook scripts
7//!
8//! ## Cursor hook format
9//! Cursor uses a settings.json with the shape:
10//! ```json
11//! {
12//!   "cursor.hooks": {
13//!     "onSessionStart": [{"command": "...", "timeout": 5000}],
14//!     "onSessionEnd":   [{"command": "...", "timeout": 10000}]
15//!   }
16//! }
17//! ```
18//!
19//! Unlike Claude Code (which wraps each entry in a `{matcher, hooks: [...]}`
20//! envelope) and Codex (flat `hooks.json`), Cursor puts hooks inside its
21//! main settings.json under the `cursor.hooks` namespace.
22//!
23//! ## Binary resolution
24//! Same strategy as Claude/Codex: default to `~/.cargo/bin/spool-mcp`,
25//! with `--binary-path` as an escape hatch.
26
27use 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
40/// Default timeout for onSessionStart hooks (ms).
41const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
42/// Longer timeout for onSessionEnd (distill pipeline may take longer).
43const 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        // ── 1. mcpServers entry in ~/.cursor/mcp.json ────────────
139        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        // ── 2. settings.json hook entries ────────────────────────
186        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        // ── 3. plan hook script writes ───────────────────────────
203        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        // ── 4. dry-run preview ───────────────────────────────────
216        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        // ── 5. apply mcpServers ──────────────────────────────────
247        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        // ── 6. apply settings.json ──────────────────────────────
259        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        // ── 7. apply hook script files ───────────────────────────
271        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        // ── 1. mcpServers ────────────────────────────────────────
329        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        // ── 2. settings.json purge ───────────────────────────────
343        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        // ── 3. plan file removals ────────────────────────────────
357        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        // ── 4. apply mcpServers purge ────────────────────────────
395        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        // ── 5. apply settings.json purge ─────────────────────────
404        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        // ── 6. delete hook scripts ───────────────────────────────
413        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        // Cursor dir presence
431        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        // mcpServers.spool registration
443        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        // Binary path
461        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        // Config TOML readable
473        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        // settings.json has spool hook entries
484        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        // hook script files present
502        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// ─────────────────────────────────────────────────────────────────────
533// internal helpers
534// ─────────────────────────────────────────────────────────────────────
535
536#[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
561/// Ensure `doc["cursor.hooks"][event]` contains an entry with `command_path`.
562/// Cursor hook entries are: `{"command": "...", "timeout": N}`.
563fn 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
604/// Remove entries from `doc["cursor.hooks"][event]` whose `command`
605/// contains `marker_substring`. Returns the number of entries removed.
606fn 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        // mcp.json written with mcpServers.spool
728        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        // settings.json written with cursor.hooks
734        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        // onSessionEnd has longer timeout
742        let end_entry = &settings_doc["cursor.hooks"]["onSessionEnd"][0];
743        assert_eq!(end_entry["timeout"], HOOK_TIMEOUT_SESSION_END_MS);
744
745        // hook script files exist
746        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        // hook scripts gone
783        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        // mcpServers.spool gone
793        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        // onSessionEnd is now empty → swept.
895        assert!(doc["cursor.hooks"].get("onSessionEnd").is_none());
896    }
897}