Skip to main content

toolpath_cursor/
paths.rs

1//! Filesystem layout for Cursor state.
2//!
3//! Cursor splits its data across two roots: an Anysphere-specific tree
4//! at `~/.cursor/` (used for project slugs and the per-composer JSONL
5//! agent transcript), and a VS Code-flavored Electron user-data tree
6//! at `~/Library/Application Support/Cursor/` on macOS,
7//! `~/.config/Cursor/` on Linux, and
8//! `%APPDATA%\Cursor\` on Windows.
9//!
10//! The source of truth for conversations is the global SQLite database
11//! at `<user-data>/User/globalStorage/state.vscdb`. The JSONL
12//! transcripts are useful for fast project-keyed listing but lossy.
13
14use crate::error::{CursorError, Result};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18/// Subset of `workspace.json` that Cursor (and VS Code) writes for
19/// every workspace folder it's opened. We only care about the
20/// `folder` URI — that's what we match on when looking up the
21/// workspace id for a given on-disk folder.
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23struct WorkspaceManifest {
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    folder: Option<String>,
26}
27
28/// Outcome of [`PathResolver::ensure_workspace_storage_entry`].
29#[derive(Debug, Clone)]
30pub struct EnsuredWorkspaceId {
31    /// The 32-hex-char id Cursor will see / does see for this
32    /// workspace.
33    pub id: String,
34    /// `true` if we created a new `workspaceStorage/<id>/workspace.json`
35    /// just now (folder hadn't been opened in Cursor before).
36    pub created: bool,
37}
38
39const ANYSPHERE_SUBDIR: &str = ".cursor";
40const PROJECTS_SUBDIR: &str = "projects";
41const AGENT_TRANSCRIPTS_SUBDIR: &str = "agent-transcripts";
42const USER_SUBDIR: &str = "User";
43const GLOBAL_STORAGE_SUBDIR: &str = "globalStorage";
44const WORKSPACE_STORAGE_SUBDIR: &str = "workspaceStorage";
45const WORKSPACE_JSON: &str = "workspace.json";
46const DB_FILE: &str = "state.vscdb";
47
48/// Builder-style resolver over Cursor's data directories.
49#[derive(Debug, Clone)]
50pub struct PathResolver {
51    home_dir: Option<PathBuf>,
52    /// Override for `~/.cursor/`.
53    anysphere_dir: Option<PathBuf>,
54    /// Override for the Electron user-data root
55    /// (`~/Library/Application Support/Cursor/` on macOS).
56    user_data_dir: Option<PathBuf>,
57}
58
59impl Default for PathResolver {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl PathResolver {
66    pub fn new() -> Self {
67        Self {
68            home_dir: home_dir(),
69            anysphere_dir: None,
70            user_data_dir: None,
71        }
72    }
73
74    pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
75        self.home_dir = Some(home.into());
76        self
77    }
78
79    /// Override `~/.cursor/` directly.
80    pub fn with_anysphere_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
81        self.anysphere_dir = Some(dir.into());
82        self
83    }
84
85    /// Override the Electron user-data root. On macOS this defaults
86    /// to `<home>/Library/Application Support/Cursor`.
87    pub fn with_user_data_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
88        self.user_data_dir = Some(dir.into());
89        self
90    }
91
92    pub fn home_dir(&self) -> Result<&Path> {
93        self.home_dir.as_deref().ok_or(CursorError::NoHomeDirectory)
94    }
95
96    /// Path to `~/.cursor/`.
97    pub fn anysphere_dir(&self) -> Result<PathBuf> {
98        if let Some(d) = &self.anysphere_dir {
99            return Ok(d.clone());
100        }
101        Ok(self.home_dir()?.join(ANYSPHERE_SUBDIR))
102    }
103
104    /// Path to `~/.cursor/projects/`.
105    pub fn projects_dir(&self) -> Result<PathBuf> {
106        Ok(self.anysphere_dir()?.join(PROJECTS_SUBDIR))
107    }
108
109    /// Path to the agent-transcripts folder for a project slug.
110    pub fn project_transcripts_dir(&self, slug: &str) -> Result<PathBuf> {
111        Ok(self.projects_dir()?.join(slug).join(AGENT_TRANSCRIPTS_SUBDIR))
112    }
113
114    /// Path to the JSONL transcript file for a composer in a project.
115    pub fn transcript_path(&self, slug: &str, composer_id: &str) -> Result<PathBuf> {
116        Ok(self
117            .project_transcripts_dir(slug)?
118            .join(composer_id)
119            .join(format!("{composer_id}.jsonl")))
120    }
121
122    /// Path to the Electron user-data root.
123    pub fn user_data_dir(&self) -> Result<PathBuf> {
124        if let Some(d) = &self.user_data_dir {
125            return Ok(d.clone());
126        }
127        Ok(default_user_data_dir(self.home_dir()?))
128    }
129
130    /// Path to `<user-data>/User/`.
131    pub fn user_dir(&self) -> Result<PathBuf> {
132        Ok(self.user_data_dir()?.join(USER_SUBDIR))
133    }
134
135    /// Path to `<user-data>/User/globalStorage/`.
136    pub fn global_storage_dir(&self) -> Result<PathBuf> {
137        Ok(self.user_dir()?.join(GLOBAL_STORAGE_SUBDIR))
138    }
139
140    /// Path to the primary cross-workspace SQLite database
141    /// (`<user-data>/User/globalStorage/state.vscdb`).
142    pub fn db_path(&self) -> Result<PathBuf> {
143        Ok(self.global_storage_dir()?.join(DB_FILE))
144    }
145
146    /// Path to `<user-data>/User/workspaceStorage/`. Cursor stores
147    /// one subdirectory per workspace folder it's been opened
148    /// against, named by the workspace id (an opaque 32-hex-char
149    /// hash Cursor computes from the folder URI). Each subdir
150    /// contains a `workspace.json` with the canonical `folder` URI
151    /// that subdir is bound to.
152    pub fn workspace_storage_dir(&self) -> Result<PathBuf> {
153        Ok(self.user_dir()?.join(WORKSPACE_STORAGE_SUBDIR))
154    }
155
156    /// Look up Cursor's workspace id for a given folder, if it has
157    /// been opened in Cursor.app before. Scans every
158    /// `workspaceStorage/<id>/workspace.json`, returning the `<id>`
159    /// whose recorded `folder` URI canonicalizes to `folder`.
160    ///
161    /// Returns `Ok(None)` when the folder hasn't been opened yet —
162    /// the caller can decide whether to synthesize one via
163    /// [`Self::ensure_workspace_storage_entry`].
164    pub fn find_workspace_id(&self, folder: &Path) -> Result<Option<String>> {
165        let storage_root = match self.workspace_storage_dir() {
166            Ok(p) => p,
167            Err(_) => return Ok(None),
168        };
169        if !storage_root.exists() {
170            return Ok(None);
171        }
172        let target = std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf());
173
174        for entry in std::fs::read_dir(&storage_root)? {
175            let entry = entry?;
176            if !entry.file_type()?.is_dir() {
177                continue;
178            }
179            let manifest = entry.path().join(WORKSPACE_JSON);
180            let Ok(raw) = std::fs::read_to_string(&manifest) else {
181                continue;
182            };
183            let Ok(parsed) = serde_json::from_str::<WorkspaceManifest>(&raw) else {
184                continue;
185            };
186            let Some(folder_uri) = parsed.folder.as_deref() else {
187                continue;
188            };
189            let Some(path_part) = folder_uri.strip_prefix("file://") else {
190                continue;
191            };
192            let recorded = std::fs::canonicalize(path_part)
193                .unwrap_or_else(|_| PathBuf::from(path_part));
194            if recorded == target {
195                let id = entry
196                    .file_name()
197                    .to_string_lossy()
198                    .into_owned();
199                return Ok(Some(id));
200            }
201        }
202        Ok(None)
203    }
204
205    /// Look up the workspace id for `folder`; if none exists, create
206    /// `workspaceStorage/<synthesized-id>/workspace.json` recording
207    /// the folder URI and return the synthesized id. The next time
208    /// Cursor.app opens that folder it'll scan workspaceStorage,
209    /// match by URI, and adopt our id — so any composer we projected
210    /// with `workspaceIdentifier.id = <our-id>` lights up in the
211    /// sidebar.
212    ///
213    /// `synthesize_id` decides what id to assign for new folders.
214    /// Production callers should pass a stable hash (e.g. the
215    /// first 32 hex chars of SHA-256 of the folder path) so re-runs
216    /// don't accumulate orphaned workspaceStorage entries.
217    pub fn ensure_workspace_storage_entry(
218        &self,
219        folder: &Path,
220        synthesize_id: impl FnOnce(&Path) -> String,
221    ) -> Result<EnsuredWorkspaceId> {
222        if let Some(id) = self.find_workspace_id(folder)? {
223            return Ok(EnsuredWorkspaceId {
224                id,
225                created: false,
226            });
227        }
228        let id = synthesize_id(folder);
229        let dir = self.workspace_storage_dir()?.join(&id);
230        std::fs::create_dir_all(&dir)?;
231        let canonical = std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf());
232        let folder_uri = format!("file://{}", canonical.to_string_lossy());
233        let manifest = WorkspaceManifest {
234            folder: Some(folder_uri),
235        };
236        let json = serde_json::to_string_pretty(&manifest)?;
237        std::fs::write(dir.join(WORKSPACE_JSON), json)?;
238        Ok(EnsuredWorkspaceId { id, created: true })
239    }
240
241    /// Whether Cursor's user-data tree exists.
242    pub fn exists(&self) -> bool {
243        self.user_data_dir().map(|p| p.exists()).unwrap_or(false)
244    }
245
246    /// Whether the primary global SQLite database exists.
247    pub fn db_exists(&self) -> bool {
248        self.db_path().map(|p| p.exists()).unwrap_or(false)
249    }
250}
251
252/// Slugify an absolute filesystem path into Cursor's project-slug form
253/// (the directory name under `~/.cursor/projects/`).
254///
255/// Cursor encodes `/Users/ben/projects/temp/cursortest` →
256/// `Users-ben-projects-temp-cursortest` (strip leading `/`, replace `/`
257/// with `-`). Tmp-dir workspaces and remote/untitled workspaces use
258/// different slugs we don't try to reconstruct here.
259pub fn slug_from_abs_path(abs: &str) -> String {
260    abs.trim_start_matches('/').replace('/', "-")
261}
262
263fn home_dir() -> Option<PathBuf> {
264    std::env::var_os("HOME")
265        .or_else(|| std::env::var_os("USERPROFILE"))
266        .map(PathBuf::from)
267}
268
269#[cfg(target_os = "macos")]
270fn default_user_data_dir(home: &Path) -> PathBuf {
271    home.join("Library/Application Support/Cursor")
272}
273
274#[cfg(target_os = "linux")]
275fn default_user_data_dir(home: &Path) -> PathBuf {
276    home.join(".config/Cursor")
277}
278
279#[cfg(target_os = "windows")]
280fn default_user_data_dir(home: &Path) -> PathBuf {
281    if let Some(appdata) = std::env::var_os("APPDATA") {
282        PathBuf::from(appdata).join("Cursor")
283    } else {
284        home.join("AppData/Roaming/Cursor")
285    }
286}
287
288#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
289fn default_user_data_dir(home: &Path) -> PathBuf {
290    home.join(".config/Cursor")
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use tempfile::TempDir;
297
298    fn setup() -> (TempDir, PathResolver) {
299        let temp = TempDir::new().unwrap();
300        let resolver = PathResolver::new()
301            .with_home(temp.path())
302            .with_anysphere_dir(temp.path().join(".cursor"))
303            .with_user_data_dir(temp.path().join("UserData"));
304        (temp, resolver)
305    }
306
307    #[test]
308    fn anysphere_dir_defaults_to_home_dotcursor() {
309        let temp = TempDir::new().unwrap();
310        let r = PathResolver::new().with_home(temp.path());
311        assert_eq!(r.anysphere_dir().unwrap(), temp.path().join(".cursor"));
312    }
313
314    #[test]
315    fn db_path_under_global_storage() {
316        let (_t, r) = setup();
317        assert!(
318            r.db_path()
319                .unwrap()
320                .ends_with("UserData/User/globalStorage/state.vscdb")
321        );
322    }
323
324    #[test]
325    fn transcript_path_uses_double_uuid() {
326        let (_t, r) = setup();
327        let uuid = "724686cd-875e-47da-a90b-dbc3e523efb8";
328        let p = r.transcript_path("my-project", uuid).unwrap();
329        assert!(p.ends_with(format!("agent-transcripts/{uuid}/{uuid}.jsonl")));
330    }
331
332    #[test]
333    fn slug_strips_leading_slash_and_replaces() {
334        assert_eq!(
335            slug_from_abs_path("/Users/ben/projects/temp/cursortest"),
336            "Users-ben-projects-temp-cursortest"
337        );
338        assert_eq!(slug_from_abs_path("/a"), "a");
339    }
340
341    #[test]
342    fn exists_reflects_user_data_dir() {
343        let (_t, r) = setup();
344        std::fs::create_dir_all(r.user_data_dir().unwrap()).unwrap();
345        assert!(r.exists());
346        let missing = PathResolver::new().with_user_data_dir("/never/exists");
347        assert!(!missing.exists());
348    }
349
350    #[test]
351    fn find_workspace_id_matches_by_folder_uri() {
352        let (t, r) = setup();
353        let folder = t.path().join("project");
354        std::fs::create_dir_all(&folder).unwrap();
355        let canonical = std::fs::canonicalize(&folder).unwrap();
356        let folder_uri = format!("file://{}", canonical.to_string_lossy());
357
358        let storage = r.workspace_storage_dir().unwrap();
359        let ws_dir = storage.join("deadbeefdeadbeefdeadbeefdeadbeef");
360        std::fs::create_dir_all(&ws_dir).unwrap();
361        std::fs::write(
362            ws_dir.join("workspace.json"),
363            format!(r#"{{"folder": "{folder_uri}"}}"#),
364        )
365        .unwrap();
366
367        let found = r.find_workspace_id(&folder).unwrap();
368        assert_eq!(found.as_deref(), Some("deadbeefdeadbeefdeadbeefdeadbeef"));
369
370        let other = t.path().join("nope");
371        std::fs::create_dir_all(&other).unwrap();
372        assert!(r.find_workspace_id(&other).unwrap().is_none());
373    }
374
375    #[test]
376    fn ensure_workspace_storage_creates_entry_when_missing() {
377        let (t, r) = setup();
378        let folder = t.path().join("brand-new");
379        std::fs::create_dir_all(&folder).unwrap();
380
381        let ensured = r
382            .ensure_workspace_storage_entry(&folder, |_| "11feedbeef00000000000000feedbeef".into())
383            .unwrap();
384        assert!(ensured.created);
385        assert_eq!(ensured.id, "11feedbeef00000000000000feedbeef");
386
387        // Manifest is written and points back at our folder.
388        let manifest_path = r
389            .workspace_storage_dir()
390            .unwrap()
391            .join(&ensured.id)
392            .join("workspace.json");
393        let raw = std::fs::read_to_string(&manifest_path).unwrap();
394        let canonical = std::fs::canonicalize(&folder).unwrap();
395        assert!(raw.contains(&format!("file://{}", canonical.to_string_lossy())));
396
397        // Second call finds the existing one — doesn't re-create.
398        let again = r
399            .ensure_workspace_storage_entry(&folder, |_| "should-not-be-used".into())
400            .unwrap();
401        assert!(!again.created);
402        assert_eq!(again.id, ensured.id);
403    }
404}