use crate::error::{CursorError, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct WorkspaceManifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
folder: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EnsuredWorkspaceId {
pub id: String,
pub created: bool,
}
const ANYSPHERE_SUBDIR: &str = ".cursor";
const PROJECTS_SUBDIR: &str = "projects";
const AGENT_TRANSCRIPTS_SUBDIR: &str = "agent-transcripts";
const USER_SUBDIR: &str = "User";
const GLOBAL_STORAGE_SUBDIR: &str = "globalStorage";
const WORKSPACE_STORAGE_SUBDIR: &str = "workspaceStorage";
const WORKSPACE_JSON: &str = "workspace.json";
const DB_FILE: &str = "state.vscdb";
#[derive(Debug, Clone)]
pub struct PathResolver {
home_dir: Option<PathBuf>,
anysphere_dir: Option<PathBuf>,
user_data_dir: Option<PathBuf>,
}
impl Default for PathResolver {
fn default() -> Self {
Self::new()
}
}
impl PathResolver {
pub fn new() -> Self {
Self {
home_dir: home_dir(),
anysphere_dir: None,
user_data_dir: None,
}
}
pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
self.home_dir = Some(home.into());
self
}
pub fn with_anysphere_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.anysphere_dir = Some(dir.into());
self
}
pub fn with_user_data_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.user_data_dir = Some(dir.into());
self
}
pub fn home_dir(&self) -> Result<&Path> {
self.home_dir.as_deref().ok_or(CursorError::NoHomeDirectory)
}
pub fn anysphere_dir(&self) -> Result<PathBuf> {
if let Some(d) = &self.anysphere_dir {
return Ok(d.clone());
}
Ok(self.home_dir()?.join(ANYSPHERE_SUBDIR))
}
pub fn projects_dir(&self) -> Result<PathBuf> {
Ok(self.anysphere_dir()?.join(PROJECTS_SUBDIR))
}
pub fn project_transcripts_dir(&self, slug: &str) -> Result<PathBuf> {
Ok(self.projects_dir()?.join(slug).join(AGENT_TRANSCRIPTS_SUBDIR))
}
pub fn transcript_path(&self, slug: &str, composer_id: &str) -> Result<PathBuf> {
Ok(self
.project_transcripts_dir(slug)?
.join(composer_id)
.join(format!("{composer_id}.jsonl")))
}
pub fn user_data_dir(&self) -> Result<PathBuf> {
if let Some(d) = &self.user_data_dir {
return Ok(d.clone());
}
Ok(default_user_data_dir(self.home_dir()?))
}
pub fn user_dir(&self) -> Result<PathBuf> {
Ok(self.user_data_dir()?.join(USER_SUBDIR))
}
pub fn global_storage_dir(&self) -> Result<PathBuf> {
Ok(self.user_dir()?.join(GLOBAL_STORAGE_SUBDIR))
}
pub fn db_path(&self) -> Result<PathBuf> {
Ok(self.global_storage_dir()?.join(DB_FILE))
}
pub fn workspace_storage_dir(&self) -> Result<PathBuf> {
Ok(self.user_dir()?.join(WORKSPACE_STORAGE_SUBDIR))
}
pub fn find_workspace_id(&self, folder: &Path) -> Result<Option<String>> {
let storage_root = match self.workspace_storage_dir() {
Ok(p) => p,
Err(_) => return Ok(None),
};
if !storage_root.exists() {
return Ok(None);
}
let target = std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf());
for entry in std::fs::read_dir(&storage_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let manifest = entry.path().join(WORKSPACE_JSON);
let Ok(raw) = std::fs::read_to_string(&manifest) else {
continue;
};
let Ok(parsed) = serde_json::from_str::<WorkspaceManifest>(&raw) else {
continue;
};
let Some(folder_uri) = parsed.folder.as_deref() else {
continue;
};
let Some(path_part) = folder_uri.strip_prefix("file://") else {
continue;
};
let recorded = std::fs::canonicalize(path_part)
.unwrap_or_else(|_| PathBuf::from(path_part));
if recorded == target {
let id = entry
.file_name()
.to_string_lossy()
.into_owned();
return Ok(Some(id));
}
}
Ok(None)
}
pub fn ensure_workspace_storage_entry(
&self,
folder: &Path,
synthesize_id: impl FnOnce(&Path) -> String,
) -> Result<EnsuredWorkspaceId> {
if let Some(id) = self.find_workspace_id(folder)? {
return Ok(EnsuredWorkspaceId {
id,
created: false,
});
}
let id = synthesize_id(folder);
let dir = self.workspace_storage_dir()?.join(&id);
std::fs::create_dir_all(&dir)?;
let canonical = std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf());
let folder_uri = format!("file://{}", canonical.to_string_lossy());
let manifest = WorkspaceManifest {
folder: Some(folder_uri),
};
let json = serde_json::to_string_pretty(&manifest)?;
std::fs::write(dir.join(WORKSPACE_JSON), json)?;
Ok(EnsuredWorkspaceId { id, created: true })
}
pub fn exists(&self) -> bool {
self.user_data_dir().map(|p| p.exists()).unwrap_or(false)
}
pub fn db_exists(&self) -> bool {
self.db_path().map(|p| p.exists()).unwrap_or(false)
}
}
pub fn slug_from_abs_path(abs: &str) -> String {
abs.trim_start_matches('/').replace('/', "-")
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn default_user_data_dir(home: &Path) -> PathBuf {
home.join("Library/Application Support/Cursor")
}
#[cfg(target_os = "linux")]
fn default_user_data_dir(home: &Path) -> PathBuf {
home.join(".config/Cursor")
}
#[cfg(target_os = "windows")]
fn default_user_data_dir(home: &Path) -> PathBuf {
if let Some(appdata) = std::env::var_os("APPDATA") {
PathBuf::from(appdata).join("Cursor")
} else {
home.join("AppData/Roaming/Cursor")
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn default_user_data_dir(home: &Path) -> PathBuf {
home.join(".config/Cursor")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, PathResolver) {
let temp = TempDir::new().unwrap();
let resolver = PathResolver::new()
.with_home(temp.path())
.with_anysphere_dir(temp.path().join(".cursor"))
.with_user_data_dir(temp.path().join("UserData"));
(temp, resolver)
}
#[test]
fn anysphere_dir_defaults_to_home_dotcursor() {
let temp = TempDir::new().unwrap();
let r = PathResolver::new().with_home(temp.path());
assert_eq!(r.anysphere_dir().unwrap(), temp.path().join(".cursor"));
}
#[test]
fn db_path_under_global_storage() {
let (_t, r) = setup();
assert!(
r.db_path()
.unwrap()
.ends_with("UserData/User/globalStorage/state.vscdb")
);
}
#[test]
fn transcript_path_uses_double_uuid() {
let (_t, r) = setup();
let uuid = "724686cd-875e-47da-a90b-dbc3e523efb8";
let p = r.transcript_path("my-project", uuid).unwrap();
assert!(p.ends_with(format!("agent-transcripts/{uuid}/{uuid}.jsonl")));
}
#[test]
fn slug_strips_leading_slash_and_replaces() {
assert_eq!(
slug_from_abs_path("/Users/ben/projects/temp/cursortest"),
"Users-ben-projects-temp-cursortest"
);
assert_eq!(slug_from_abs_path("/a"), "a");
}
#[test]
fn exists_reflects_user_data_dir() {
let (_t, r) = setup();
std::fs::create_dir_all(r.user_data_dir().unwrap()).unwrap();
assert!(r.exists());
let missing = PathResolver::new().with_user_data_dir("/never/exists");
assert!(!missing.exists());
}
#[test]
fn find_workspace_id_matches_by_folder_uri() {
let (t, r) = setup();
let folder = t.path().join("project");
std::fs::create_dir_all(&folder).unwrap();
let canonical = std::fs::canonicalize(&folder).unwrap();
let folder_uri = format!("file://{}", canonical.to_string_lossy());
let storage = r.workspace_storage_dir().unwrap();
let ws_dir = storage.join("deadbeefdeadbeefdeadbeefdeadbeef");
std::fs::create_dir_all(&ws_dir).unwrap();
std::fs::write(
ws_dir.join("workspace.json"),
format!(r#"{{"folder": "{folder_uri}"}}"#),
)
.unwrap();
let found = r.find_workspace_id(&folder).unwrap();
assert_eq!(found.as_deref(), Some("deadbeefdeadbeefdeadbeefdeadbeef"));
let other = t.path().join("nope");
std::fs::create_dir_all(&other).unwrap();
assert!(r.find_workspace_id(&other).unwrap().is_none());
}
#[test]
fn ensure_workspace_storage_creates_entry_when_missing() {
let (t, r) = setup();
let folder = t.path().join("brand-new");
std::fs::create_dir_all(&folder).unwrap();
let ensured = r
.ensure_workspace_storage_entry(&folder, |_| "11feedbeef00000000000000feedbeef".into())
.unwrap();
assert!(ensured.created);
assert_eq!(ensured.id, "11feedbeef00000000000000feedbeef");
let manifest_path = r
.workspace_storage_dir()
.unwrap()
.join(&ensured.id)
.join("workspace.json");
let raw = std::fs::read_to_string(&manifest_path).unwrap();
let canonical = std::fs::canonicalize(&folder).unwrap();
assert!(raw.contains(&format!("file://{}", canonical.to_string_lossy())));
let again = r
.ensure_workspace_storage_entry(&folder, |_| "should-not-be-used".into())
.unwrap();
assert!(!again.created);
assert_eq!(again.id, ensured.id);
}
}