Skip to main content

devboy_core/agents/
cursor.rs

1//! Cursor IDE detector.
2//!
3//! Cross-platform paths (resolved via `dirs::data_dir()` or platform fallbacks):
4//! - macOS:   `~/Library/Application Support/Cursor/User/workspaceStorage/`
5//! - Linux:   `~/.config/Cursor/User/workspaceStorage/`
6//! - Windows: `%APPDATA%/Cursor/User/workspaceStorage/`
7//!
8//! Sessions = count of subdirectories (one per workspace).
9//! last_used = max mtime of `state.vscdb` files.
10//!
11//! We don't crack open the SQLite — for `onboard` parity, dir count + mtime
12//! is sufficient. Reverse-engineering Cursor's chat keys belongs in
13//! `analyze-usage`, not here.
14
15use std::path::{Path, PathBuf};
16
17use super::fs_util::{count_subdirs, to_utc};
18use super::{AgentDetector, AgentSnapshot, InstallStatus};
19
20const ID: &str = "cursor";
21const DISPLAY_NAME: &str = "Cursor";
22
23pub struct CursorDetector;
24
25impl AgentDetector for CursorDetector {
26    fn id(&self) -> &'static str {
27        ID
28    }
29    fn display_name(&self) -> &'static str {
30        DISPLAY_NAME
31    }
32
33    fn detect(&self, home: &Path) -> AgentSnapshot {
34        let cursor_dir = cursor_data_dir(home);
35        let workspace_storage = cursor_dir.join("User/workspaceStorage");
36        let paths_checked = vec![cursor_dir.clone(), workspace_storage.clone()];
37
38        let dir_present = cursor_dir.is_dir();
39        // Cursor doesn't typically expose a CLI bin, but check anyway.
40        let binary_present = which::which("cursor").is_ok();
41
42        let status = if dir_present || binary_present {
43            InstallStatus::Yes
44        } else {
45            InstallStatus::No
46        };
47        if status == InstallStatus::No {
48            return empty(paths_checked);
49        }
50
51        let count = count_subdirs(&workspace_storage);
52        let last_used = max_state_vscdb_mtime(&workspace_storage);
53
54        AgentSnapshot {
55            id: ID,
56            display_name: DISPLAY_NAME,
57            status,
58            sessions: (count > 0).then_some(count),
59            last_used,
60            score: 0.0,
61            paths_checked,
62        }
63    }
64}
65
66fn cursor_data_dir(home: &Path) -> PathBuf {
67    if cfg!(target_os = "macos") {
68        home.join("Library/Application Support/Cursor")
69    } else if cfg!(target_os = "windows") {
70        std::env::var_os("APPDATA")
71            .map(PathBuf::from)
72            .unwrap_or_else(|| home.join("AppData/Roaming"))
73            .join("Cursor")
74    } else {
75        // Linux / BSD / fallback
76        home.join(".config/Cursor")
77    }
78}
79
80fn max_state_vscdb_mtime(workspace_storage: &Path) -> Option<chrono::DateTime<chrono::Utc>> {
81    let Ok(workspaces) = std::fs::read_dir(workspace_storage) else {
82        return None;
83    };
84    let mut best: Option<chrono::DateTime<chrono::Utc>> = None;
85    for ws in workspaces.flatten() {
86        let p = ws.path().join("state.vscdb");
87        if let Ok(meta) = std::fs::metadata(&p)
88            && let Ok(modified) = meta.modified()
89            && let Some(t) = to_utc(modified)
90        {
91            best = Some(best.map_or(t, |b| b.max(t)));
92        }
93    }
94    best
95}
96
97fn empty(paths_checked: Vec<PathBuf>) -> AgentSnapshot {
98    AgentSnapshot {
99        id: ID,
100        display_name: DISPLAY_NAME,
101        status: InstallStatus::No,
102        sessions: None,
103        last_used: None,
104        score: 0.0,
105        paths_checked,
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use tempfile::tempdir;
113
114    #[test]
115    fn no_cursor_dir_means_not_installed() {
116        let home = tempdir().unwrap();
117        let snap = CursorDetector.detect(home.path());
118        // `cursor` binary may exist in PATH on the test machine; only
119        // assert the negative when both signals are absent.
120        if which::which("cursor").is_err() {
121            assert_eq!(snap.status, InstallStatus::No);
122            assert!(snap.sessions.is_none());
123        }
124    }
125
126    /// Build `<home>/<rel>/<id>/state.vscdb` for each id and assert the
127    /// detector counts them. Only used on platforms where the detector
128    /// resolves its data dir relative to `$HOME` — Windows reads `%APPDATA%`,
129    /// and `unsafe_code = "forbid"` rules out mutating env vars from a
130    /// test, so the platform-specific case ships behind a positive smoke
131    /// only (the always-on `no_cursor_dir_means_not_installed` test
132    /// covers the negative path on every OS).
133    #[cfg(any(target_os = "macos", target_os = "linux"))]
134    fn assert_counts_workspaces(rel_storage: &str, ids: &[&str]) {
135        use std::fs;
136        let home = tempdir().unwrap();
137        let storage = home.path().join(rel_storage);
138        for ws in ids {
139            let dir = storage.join(ws);
140            fs::create_dir_all(&dir).unwrap();
141            fs::write(dir.join("state.vscdb"), b"").unwrap();
142        }
143        let snap = CursorDetector.detect(home.path());
144        assert_eq!(snap.status, InstallStatus::Yes);
145        assert_eq!(snap.sessions, Some(ids.len() as u64));
146    }
147
148    #[test]
149    #[cfg(target_os = "macos")]
150    fn counts_workspace_subdirs_macos() {
151        assert_counts_workspaces(
152            "Library/Application Support/Cursor/User/workspaceStorage",
153            &["aaaa", "bbbb", "cccc"],
154        );
155    }
156
157    #[test]
158    #[cfg(target_os = "linux")]
159    fn counts_workspace_subdirs_linux() {
160        assert_counts_workspaces(".config/Cursor/User/workspaceStorage", &["aaaa", "bbbb"]);
161    }
162}