1use std::path::Path;
2
3pub fn uri_to_path(uri: &str) -> Option<String> {
7 let raw = uri.strip_prefix("file://")?;
8 if raw.contains("%00") {
9 return None;
10 }
11 let decoded = percent_decode(raw);
12 if decoded.is_empty() || decoded.contains('\0') {
13 return None;
14 }
15 #[cfg(windows)]
23 let decoded = if has_leading_slash_drive(&decoded) {
24 decoded[1..].to_string()
25 } else {
26 decoded
27 };
28 let path = Path::new(&decoded);
29 if !path.is_absolute() {
30 return None;
31 }
32 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
33 let s = canonical.to_string_lossy().to_string();
34 if s.is_empty() {
35 return None;
36 }
37 Some(s)
38}
39
40fn percent_decode(s: &str) -> String {
41 let mut out = String::with_capacity(s.len());
42 let mut chars = s.bytes();
43 while let Some(b) = chars.next() {
44 if b == b'%' {
45 let hi = chars.next().and_then(hex_val);
46 let lo = chars.next().and_then(hex_val);
47 if let (Some(h), Some(l)) = (hi, lo) {
48 let byte = h << 4 | l;
49 if byte == 0 {
50 continue;
51 }
52 out.push(byte as char);
53 } else {
54 out.push('%');
55 }
56 } else {
57 out.push(b as char);
58 }
59 }
60 out
61}
62
63fn hex_val(b: u8) -> Option<u8> {
64 match b {
65 b'0'..=b'9' => Some(b - b'0'),
66 b'a'..=b'f' => Some(b - b'a' + 10),
67 b'A'..=b'F' => Some(b - b'A' + 10),
68 _ => None,
69 }
70}
71
72#[cfg_attr(not(windows), allow(dead_code))]
79fn has_leading_slash_drive(p: &str) -> bool {
80 let b = p.as_bytes();
81 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
82}
83
84pub(super) fn has_project_marker(dir: &Path) -> bool {
85 crate::core::pathutil::has_project_marker(dir)
86}
87
88pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
95 best_root_from_paths(uris.iter().filter_map(|u| uri_to_path(u)).collect())
96}
97
98fn best_root_from_paths(paths: Vec<String>) -> Option<String> {
106 let paths: Vec<String> = paths
107 .into_iter()
108 .filter(|p| Path::new(p).is_dir())
109 .collect();
110
111 if paths.is_empty() {
112 return None;
113 }
114
115 for p in &paths {
116 if has_project_marker(Path::new(p)) {
117 return Some(p.clone());
118 }
119 }
120
121 paths
122 .into_iter()
123 .find(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
124}
125
126pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
128 uris.iter()
129 .filter_map(|u| uri_to_path(u))
130 .filter(|p| Path::new(p).is_dir())
131 .collect()
132}
133
134pub fn root_from_env() -> Option<String> {
146 const VARS: &[&str] = &[
147 "LEAN_CTX_PROJECT_ROOT",
148 "CURSOR_PROJECT_DIR",
149 "CLAUDE_PROJECT_DIR",
150 "WINDSURF_PROJECT_DIR",
151 "TRAE_PROJECT_DIR",
152 "KIRO_PROJECT_DIR",
153 "CODEX_SANDBOX_DIR",
154 "GIT_WORK_TREE",
155 ];
156 for var in VARS {
157 if let Ok(val) = std::env::var(var) {
158 let trimmed = val.trim().to_string();
159 if !trimmed.is_empty()
160 && Path::new(&trimmed).is_dir()
161 && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
162 {
163 return Some(trimmed);
164 }
165 }
166 }
167 None
168}
169
170fn split_workspace_paths(raw: &str) -> Vec<String> {
176 let delims: &[char] = if cfg!(windows) {
177 &[',', ';']
178 } else {
179 &[',', ':']
180 };
181 raw.split(delims)
182 .map(str::trim)
183 .filter(|s| !s.is_empty())
184 .map(ToString::to_string)
185 .collect()
186}
187
188pub fn root_from_workspace_env() -> Option<String> {
197 for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
198 if let Ok(raw) = std::env::var(var) {
199 if let Some(root) = best_root_from_paths(split_workspace_paths(&raw)) {
200 return Some(root);
201 }
202 }
203 }
204 None
205}
206
207pub fn workspace_roots_from_env() -> Vec<String> {
212 for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
213 if let Ok(raw) = std::env::var(var) {
214 let paths: Vec<String> = split_workspace_paths(&raw)
215 .into_iter()
216 .filter(|p| Path::new(p).is_dir())
217 .filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
218 .collect();
219 if !paths.is_empty() {
220 return paths;
221 }
222 }
223 }
224 Vec::new()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[cfg(unix)]
232 #[test]
233 fn parse_file_uri_unix() {
234 assert_eq!(
235 uri_to_path("file:///home/user/project"),
236 Some("/home/user/project".to_string())
237 );
238 }
239
240 #[cfg(unix)]
241 #[test]
242 fn parse_file_uri_windows() {
243 assert_eq!(
244 uri_to_path("file:///C:/Users/dev/project"),
245 Some("/C:/Users/dev/project".to_string())
246 );
247 }
248
249 #[cfg(unix)]
250 #[test]
251 fn parse_file_uri_with_spaces() {
252 assert_eq!(
253 uri_to_path("file:///home/user/my%20project"),
254 Some("/home/user/my project".to_string())
255 );
256 }
257
258 #[test]
259 fn parse_non_file_uri_returns_none() {
260 assert!(uri_to_path("https://example.com").is_none());
261 assert!(uri_to_path("").is_none());
262 }
263
264 #[test]
265 fn detects_leading_slash_windows_drive() {
266 assert!(has_leading_slash_drive("/C:/Users/dev"));
270 assert!(has_leading_slash_drive("/c:/proj"));
271 assert!(has_leading_slash_drive("/Z:"));
272 assert!(!has_leading_slash_drive("/home/user/proj"));
274 assert!(!has_leading_slash_drive("C:/already"));
275 assert!(!has_leading_slash_drive("//server/share"));
276 assert!(!has_leading_slash_drive("/"));
277 assert!(!has_leading_slash_drive("/1:/x"));
278 }
279
280 #[cfg(windows)]
281 #[test]
282 fn parse_file_uri_windows_drive_strips_leading_slash() {
283 let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
287 assert!(
288 !got.starts_with('/'),
289 "leading slash must be stripped: {got}"
290 );
291 assert!(
292 got.to_ascii_lowercase().starts_with("c:"),
293 "drive prefix must survive: {got}"
294 );
295 }
296
297 #[cfg(windows)]
298 #[test]
299 fn parse_file_uri_windows_percent_encoded_colon() {
300 let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
302 assert!(
303 !got.starts_with('/'),
304 "leading slash must be stripped: {got}"
305 );
306 assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
307 }
308
309 #[test]
310 fn rejects_null_bytes() {
311 assert!(uri_to_path("file:///tmp/evil%00path").is_none());
312 }
313
314 #[test]
315 fn rejects_relative_uri() {
316 assert!(uri_to_path("file://relative/path").is_none());
317 }
318
319 #[test]
320 fn canonicalizes_traversal() {
321 let tmp = tempfile::tempdir().unwrap();
322 let sub = tmp.path().join("a").join("b");
323 std::fs::create_dir_all(&sub).unwrap();
324 let traversal = format!("file://{}/a/b/../..", tmp.path().display());
325 let result = uri_to_path(&traversal);
326 assert!(result.is_some());
327 let resolved = result.unwrap();
328 assert!(
329 !resolved.contains(".."),
330 "should be canonicalized: {resolved}"
331 );
332 }
333
334 #[test]
335 fn best_root_prefers_marker() {
336 let tmp = tempfile::tempdir().unwrap();
337 let with_marker = tmp.path().join("has_git");
338 let without = tmp.path().join("plain");
339 std::fs::create_dir_all(&with_marker).unwrap();
340 std::fs::create_dir_all(&without).unwrap();
341 std::fs::create_dir(with_marker.join(".git")).unwrap();
342
343 let uris = vec![
344 format!("file://{}", without.display()),
345 format!("file://{}", with_marker.display()),
346 ];
347 let result = best_root_from_uris(&uris).unwrap();
348 assert!(result.contains("has_git"));
349 }
350
351 #[test]
352 fn best_root_falls_back_to_first_existing_dir() {
353 let tmp = tempfile::tempdir().unwrap();
354 let a = tmp.path().join("dir_a");
355 let b = tmp.path().join("dir_b");
356 std::fs::create_dir_all(&a).unwrap();
357 std::fs::create_dir_all(&b).unwrap();
358
359 let uris = vec![
360 format!("file://{}", a.display()),
361 format!("file://{}", b.display()),
362 ];
363 let result = best_root_from_uris(&uris).unwrap();
364 assert!(result.contains("dir_a"));
365 }
366
367 #[test]
368 fn best_root_skips_nonexistent() {
369 let uris = vec!["file:///nonexistent_abc_123".to_string()];
370 assert!(best_root_from_uris(&uris).is_none());
371 }
372
373 #[test]
374 fn best_root_empty_returns_none() {
375 assert!(best_root_from_uris(&[]).is_none());
376 }
377
378 #[test]
379 fn env_override_returns_none_when_unset() {
380 let _ = root_from_env();
381 }
382
383 #[test]
384 fn best_root_rejects_home_without_marker() {
385 if let Some(home) = dirs::home_dir() {
388 let uris = vec![format!("file://{}", home.display())];
389 assert_eq!(
390 best_root_from_uris(&uris),
391 None,
392 "HOME must never be accepted as a marker-less project root"
393 );
394 }
395 }
396
397 #[test]
398 fn best_root_prefers_safe_dir_over_home() {
399 if let Some(home) = dirs::home_dir() {
400 let tmp = tempfile::tempdir().unwrap();
401 let safe = tmp.path().join("real_project");
402 std::fs::create_dir_all(&safe).unwrap();
403 let uris = vec![
404 format!("file://{}", home.display()),
405 format!("file://{}", safe.display()),
406 ];
407 let result = best_root_from_uris(&uris).unwrap();
408 assert!(result.contains("real_project"));
409 }
410 }
411
412 #[test]
413 fn best_root_rejects_filesystem_root() {
414 let uris = vec!["file:///".to_string()];
415 assert!(best_root_from_uris(&uris).is_none());
416 }
417
418 #[test]
419 fn all_paths_from_uris() {
420 let tmp = tempfile::tempdir().unwrap();
421 let a = tmp.path().join("project_a");
422 let b = tmp.path().join("project_b");
423 std::fs::create_dir_all(&a).unwrap();
424 std::fs::create_dir_all(&b).unwrap();
425 std::fs::create_dir(a.join(".git")).unwrap();
426
427 let uris = vec![
428 format!("file://{}", a.display()),
429 format!("file://{}", b.display()),
430 ];
431
432 let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
433 assert_eq!(paths.len(), 2);
434 assert!(paths[0].contains("project_a"));
435 assert!(paths[1].contains("project_b"));
436
437 let best = best_root_from_uris(&uris).unwrap();
438 assert!(best.contains("project_a"));
439 }
440
441 #[test]
442 fn split_workspace_paths_comma_separated() {
443 assert_eq!(
444 split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
445 vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
446 );
447 }
448
449 #[test]
450 fn split_workspace_paths_trims_and_drops_empty() {
451 assert_eq!(
452 split_workspace_paths(" /a , , /b ,"),
453 vec!["/a".to_string(), "/b".to_string()]
454 );
455 }
456
457 #[cfg(unix)]
458 #[test]
459 fn split_workspace_paths_unix_colon_delimiter() {
460 assert_eq!(
462 split_workspace_paths("/a:/b"),
463 vec!["/a".to_string(), "/b".to_string()]
464 );
465 }
466
467 #[test]
468 fn best_root_from_paths_prefers_marker_over_first() {
469 let tmp = tempfile::tempdir().unwrap();
470 let plain = tmp.path().join("plain");
471 let marked = tmp.path().join("marked");
472 std::fs::create_dir_all(&plain).unwrap();
473 std::fs::create_dir_all(&marked).unwrap();
474 std::fs::create_dir(marked.join(".git")).unwrap();
475 let got = best_root_from_paths(vec![
476 plain.to_string_lossy().to_string(),
477 marked.to_string_lossy().to_string(),
478 ])
479 .unwrap();
480 assert!(got.contains("marked"), "marker dir must win: {got}");
481 }
482
483 #[test]
484 fn best_root_from_paths_filters_nonexistent() {
485 let tmp = tempfile::tempdir().unwrap();
486 let safe = tmp.path().join("real_proj");
487 std::fs::create_dir_all(&safe).unwrap();
488 let got = best_root_from_paths(vec![
489 "/nonexistent_xyz_987".to_string(),
490 safe.to_string_lossy().to_string(),
491 ])
492 .unwrap();
493 assert!(got.contains("real_proj"));
494 }
495
496 #[test]
497 fn best_root_from_paths_empty_returns_none() {
498 assert!(best_root_from_paths(vec![]).is_none());
499 assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
500 }
501
502 #[test]
503 fn workspace_env_value_picks_marker_root() {
504 let tmp = tempfile::tempdir().unwrap();
507 let a = tmp.path().join("ws_a");
508 let b = tmp.path().join("ws_b");
509 std::fs::create_dir_all(&a).unwrap();
510 std::fs::create_dir_all(&b).unwrap();
511 std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
512 let raw = format!("{},{}", a.display(), b.display());
513 let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
514 assert!(got.contains("ws_b"), "marker workspace must win: {got}");
515 }
516
517 #[test]
518 fn workspace_env_readers_do_not_panic() {
519 let _ = root_from_workspace_env();
521 let _ = workspace_roots_from_env();
522 }
523}