cursor_helper/cursor/
workspace.rs1use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::fs;
12use std::path::Path;
13use url::Url;
14
15pub 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 let metadata = fs::metadata(path)
30 .with_context(|| format!("Failed to get metadata for: {}", path.display()))?;
31
32 let birthtime_ms = get_birthtime_ms(&metadata)?;
34
35 let birthtime_rounded = birthtime_ms.round() as u64;
37
38 let input = format!("{}{}", path_str, birthtime_rounded);
40 let hash = md5::compute(input.as_bytes());
41
42 Ok(format!("{:x}", hash))
43}
44
45fn normalize_path_for_hash(path: &Path) -> String {
48 let path_str = path.to_string_lossy();
49
50 #[cfg(windows)]
51 {
52 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#[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 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 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#[derive(Debug, Serialize, Deserialize)]
105pub struct WorkspaceJson {
106 pub folder: String,
107}
108
109impl WorkspaceJson {
110 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 #[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 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
138pub 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 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 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}