Skip to main content

cursor_helper/cursor/
workspace.rs

1//! Workspace storage operations
2//!
3//! Cursor stores workspace state in:
4//! ~/Library/Application Support/Cursor/User/workspaceStorage/<hash>/
5//!
6//! The hash is computed as: MD5(absolutePath + Math.round(birthtimeMs))
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::fs;
12use std::path::Path;
13use url::Url;
14
15/// Compute the workspace storage hash for a given path
16///
17/// Formula: MD5(absolutePath + Math.round(birthtimeMs))
18///
19/// # Arguments
20/// * `path` - The absolute path to the project directory
21///
22/// # Returns
23/// The MD5 hash as a hex string
24pub fn compute_workspace_hash<P: AsRef<Path>>(path: P) -> Result<String> {
25    let path = path.as_ref();
26    let path_str = normalize_path_for_hash(path);
27
28    // Get file metadata to extract birth time
29    let metadata = fs::metadata(path)
30        .with_context(|| format!("Failed to get metadata for: {}", path.display()))?;
31
32    // Get birth time (creation time) in milliseconds
33    let birthtime_ms = get_birthtime_ms(&metadata)?;
34
35    // Round to nearest integer (like JavaScript's Math.round)
36    let birthtime_rounded = birthtime_ms.round() as u64;
37
38    // Compute MD5 hash of "path + birthtimeMs"
39    let input = format!("{}{}", path_str, birthtime_rounded);
40    let hash = md5::compute(input.as_bytes());
41
42    Ok(format!("{:x}", hash))
43}
44
45/// Normalize path for hash computation
46/// On Windows, Cursor uses lowercase drive letters (c: not C:)
47fn normalize_path_for_hash(path: &Path) -> String {
48    let path_str = path.to_string_lossy();
49
50    #[cfg(windows)]
51    {
52        // Lowercase the drive letter (C: -> c:)
53        if path_str.len() >= 2 && path_str.as_bytes()[1] == b':' {
54            let mut chars: Vec<char> = path_str.chars().collect();
55            chars[0] = chars[0].to_ascii_lowercase();
56            return chars.into_iter().collect();
57        }
58    }
59
60    path_str.into_owned()
61}
62
63/// Get birth time in milliseconds from file metadata
64#[cfg(target_os = "macos")]
65fn get_birthtime_ms(metadata: &fs::Metadata) -> Result<f64> {
66    use std::time::UNIX_EPOCH;
67    let created = metadata.created().context("Failed to get creation time")?;
68    let duration = created
69        .duration_since(UNIX_EPOCH)
70        .context("Time went backwards")?;
71    Ok(duration.as_secs_f64() * 1000.0)
72}
73
74#[cfg(target_os = "linux")]
75fn get_birthtime_ms(metadata: &fs::Metadata) -> Result<f64> {
76    use std::os::unix::fs::MetadataExt;
77    // Linux often doesn't have true birthtime. We use statx() birthtime if available,
78    // otherwise fall back to ctime. Note: ctime changes on metadata updates, so this
79    // may not match Cursor's hash. Use find_workspace_by_uri() as fallback.
80    if let Ok(created) = metadata.created() {
81        use std::time::UNIX_EPOCH;
82        let duration = created
83            .duration_since(UNIX_EPOCH)
84            .context("Time went backwards")?;
85        return Ok(duration.as_secs_f64() * 1000.0);
86    }
87    // Fallback to ctime (inode change time) - may not match Cursor's hash
88    let ctime_sec = metadata.ctime();
89    let ctime_nsec = metadata.ctime_nsec();
90    Ok((ctime_sec as f64 * 1000.0) + (ctime_nsec as f64 / 1_000_000.0))
91}
92
93#[cfg(windows)]
94fn get_birthtime_ms(metadata: &fs::Metadata) -> Result<f64> {
95    use std::time::UNIX_EPOCH;
96    let created = metadata.created().context("Failed to get creation time")?;
97    let duration = created
98        .duration_since(UNIX_EPOCH)
99        .context("Time went backwards")?;
100    Ok(duration.as_secs_f64() * 1000.0)
101}
102
103/// The workspace.json file structure
104#[derive(Debug, Serialize, Deserialize)]
105pub struct WorkspaceJson {
106    pub folder: String,
107}
108
109impl WorkspaceJson {
110    /// Create a new workspace.json for a given path
111    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
112        let path = path.as_ref();
113        let url = Url::from_file_path(path)
114            .map_err(|_| anyhow::anyhow!("Failed to convert path to URL: {}", path.display()))?;
115
116        Ok(Self {
117            folder: url.to_string(),
118        })
119    }
120
121    /// Read workspace.json from a file
122    #[allow(dead_code)]
123    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
124        let content = fs::read_to_string(path.as_ref())
125            .with_context(|| format!("Failed to read: {}", path.as_ref().display()))?;
126        serde_json::from_str(&content).context("Failed to parse workspace.json")
127    }
128
129    /// Write workspace.json to a file
130    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<()> {
131        let content = serde_json::to_string_pretty(self)?;
132        fs::write(path.as_ref(), content)
133            .with_context(|| format!("Failed to write: {}", path.as_ref().display()))?;
134        Ok(())
135    }
136}
137
138/// Read the primary workspace target URI from a workspace storage directory.
139///
140/// Cursor stores either:
141/// - `folder`: single-folder workspace
142/// - `workspace`: multi-root `.code-workspace` file
143pub fn read_workspace_target_uri(workspace_dir: &Path) -> Result<Option<String>> {
144    let workspace_json = workspace_dir.join("workspace.json");
145    if !workspace_json.exists() {
146        return Ok(None);
147    }
148
149    let content = fs::read_to_string(&workspace_json)
150        .with_context(|| format!("Failed to read: {}", workspace_json.display()))?;
151    let ws: Value = serde_json::from_str(&content)
152        .with_context(|| format!("Failed to parse: {}", workspace_json.display()))?;
153
154    Ok(ws
155        .get("folder")
156        .and_then(|value| value.as_str())
157        .or_else(|| ws.get("workspace").and_then(|value| value.as_str()))
158        .map(|value| value.to_string()))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use tempfile::TempDir;
165
166    #[cfg(not(windows))]
167    #[test]
168    fn test_workspace_json_new() {
169        let ws = WorkspaceJson::new("/Users/me/projects/myapp").unwrap();
170        assert_eq!(ws.folder, "file:///Users/me/projects/myapp");
171    }
172
173    #[cfg(not(windows))]
174    #[test]
175    fn test_workspace_json_with_spaces() {
176        let ws = WorkspaceJson::new("/Users/me/my project").unwrap();
177        assert_eq!(ws.folder, "file:///Users/me/my%20project");
178    }
179
180    #[cfg(windows)]
181    #[test]
182    fn test_workspace_json_new_windows() {
183        let ws = WorkspaceJson::new("C:\\Users\\me\\projects\\myapp").unwrap();
184        assert_eq!(ws.folder, "file:///C:/Users/me/projects/myapp");
185    }
186
187    #[cfg(windows)]
188    #[test]
189    fn test_workspace_json_with_spaces_windows() {
190        let ws = WorkspaceJson::new("C:\\Users\\me\\my project").unwrap();
191        assert_eq!(ws.folder, "file:///C:/Users/me/my%20project");
192    }
193
194    #[cfg(windows)]
195    #[test]
196    fn test_normalize_path_for_hash_windows() {
197        use std::path::Path;
198        // Windows: drive letter should be lowercased
199        assert_eq!(
200            normalize_path_for_hash(Path::new("C:\\com.github\\project")),
201            "c:\\com.github\\project"
202        );
203        assert_eq!(
204            normalize_path_for_hash(Path::new("D:\\Users\\me")),
205            "d:\\Users\\me"
206        );
207    }
208
209    #[cfg(not(windows))]
210    #[test]
211    fn test_normalize_path_for_hash_unix() {
212        use std::path::Path;
213        // Unix: path unchanged
214        assert_eq!(
215            normalize_path_for_hash(Path::new("/Users/me/project")),
216            "/Users/me/project"
217        );
218    }
219
220    #[test]
221    fn test_read_workspace_target_uri_prefers_folder_then_workspace() {
222        let temp_dir = TempDir::new().unwrap();
223        let workspace_dir = temp_dir.path().join("ws");
224        fs::create_dir(&workspace_dir).unwrap();
225
226        fs::write(
227            workspace_dir.join("workspace.json"),
228            r#"{"folder":"file:///tmp/project","workspace":"file:///tmp/project.code-workspace"}"#,
229        )
230        .unwrap();
231
232        let target = read_workspace_target_uri(&workspace_dir).unwrap();
233        assert_eq!(target.as_deref(), Some("file:///tmp/project"));
234    }
235
236    #[test]
237    fn test_read_workspace_target_uri_reads_multi_root_workspace() {
238        let temp_dir = TempDir::new().unwrap();
239        let workspace_dir = temp_dir.path().join("ws");
240        fs::create_dir(&workspace_dir).unwrap();
241
242        fs::write(
243            workspace_dir.join("workspace.json"),
244            r#"{"workspace":"file:///tmp/project.code-workspace"}"#,
245        )
246        .unwrap();
247
248        let target = read_workspace_target_uri(&workspace_dir).unwrap();
249        assert_eq!(
250            target.as_deref(),
251            Some("file:///tmp/project.code-workspace")
252        );
253    }
254}