1use crate::error::{CursorError, Result};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23struct WorkspaceManifest {
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 folder: Option<String>,
26}
27
28#[derive(Debug, Clone)]
30pub struct EnsuredWorkspaceId {
31 pub id: String,
34 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#[derive(Debug, Clone)]
50pub struct PathResolver {
51 home_dir: Option<PathBuf>,
52 anysphere_dir: Option<PathBuf>,
54 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 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 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 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 pub fn projects_dir(&self) -> Result<PathBuf> {
106 Ok(self.anysphere_dir()?.join(PROJECTS_SUBDIR))
107 }
108
109 pub fn project_transcripts_dir(&self, slug: &str) -> Result<PathBuf> {
111 Ok(self.projects_dir()?.join(slug).join(AGENT_TRANSCRIPTS_SUBDIR))
112 }
113
114 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 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 pub fn user_dir(&self) -> Result<PathBuf> {
132 Ok(self.user_data_dir()?.join(USER_SUBDIR))
133 }
134
135 pub fn global_storage_dir(&self) -> Result<PathBuf> {
137 Ok(self.user_dir()?.join(GLOBAL_STORAGE_SUBDIR))
138 }
139
140 pub fn db_path(&self) -> Result<PathBuf> {
143 Ok(self.global_storage_dir()?.join(DB_FILE))
144 }
145
146 pub fn workspace_storage_dir(&self) -> Result<PathBuf> {
153 Ok(self.user_dir()?.join(WORKSPACE_STORAGE_SUBDIR))
154 }
155
156 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 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 pub fn exists(&self) -> bool {
243 self.user_data_dir().map(|p| p.exists()).unwrap_or(false)
244 }
245
246 pub fn db_exists(&self) -> bool {
248 self.db_path().map(|p| p.exists()).unwrap_or(false)
249 }
250}
251
252pub 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 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 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}