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> {
137 for var in ["LEAN_CTX_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"] {
138 if let Ok(val) = std::env::var(var) {
139 let trimmed = val.trim().to_string();
140 if !trimmed.is_empty()
141 && Path::new(&trimmed).is_dir()
142 && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
143 {
144 return Some(trimmed);
145 }
146 }
147 }
148 None
149}
150
151fn split_workspace_paths(raw: &str) -> Vec<String> {
157 let delims: &[char] = if cfg!(windows) {
158 &[',', ';']
159 } else {
160 &[',', ':']
161 };
162 raw.split(delims)
163 .map(str::trim)
164 .filter(|s| !s.is_empty())
165 .map(ToString::to_string)
166 .collect()
167}
168
169pub fn root_from_workspace_env() -> Option<String> {
178 let raw = std::env::var("WORKSPACE_FOLDER_PATHS").ok()?;
179 best_root_from_paths(split_workspace_paths(&raw))
180}
181
182pub fn workspace_roots_from_env() -> Vec<String> {
187 let Ok(raw) = std::env::var("WORKSPACE_FOLDER_PATHS") else {
188 return Vec::new();
189 };
190 split_workspace_paths(&raw)
191 .into_iter()
192 .filter(|p| Path::new(p).is_dir())
193 .filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
194 .collect()
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[cfg(unix)]
202 #[test]
203 fn parse_file_uri_unix() {
204 assert_eq!(
205 uri_to_path("file:///home/user/project"),
206 Some("/home/user/project".to_string())
207 );
208 }
209
210 #[cfg(unix)]
211 #[test]
212 fn parse_file_uri_windows() {
213 assert_eq!(
214 uri_to_path("file:///C:/Users/dev/project"),
215 Some("/C:/Users/dev/project".to_string())
216 );
217 }
218
219 #[cfg(unix)]
220 #[test]
221 fn parse_file_uri_with_spaces() {
222 assert_eq!(
223 uri_to_path("file:///home/user/my%20project"),
224 Some("/home/user/my project".to_string())
225 );
226 }
227
228 #[test]
229 fn parse_non_file_uri_returns_none() {
230 assert!(uri_to_path("https://example.com").is_none());
231 assert!(uri_to_path("").is_none());
232 }
233
234 #[test]
235 fn detects_leading_slash_windows_drive() {
236 assert!(has_leading_slash_drive("/C:/Users/dev"));
240 assert!(has_leading_slash_drive("/c:/proj"));
241 assert!(has_leading_slash_drive("/Z:"));
242 assert!(!has_leading_slash_drive("/home/user/proj"));
244 assert!(!has_leading_slash_drive("C:/already"));
245 assert!(!has_leading_slash_drive("//server/share"));
246 assert!(!has_leading_slash_drive("/"));
247 assert!(!has_leading_slash_drive("/1:/x"));
248 }
249
250 #[cfg(windows)]
251 #[test]
252 fn parse_file_uri_windows_drive_strips_leading_slash() {
253 let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
257 assert!(
258 !got.starts_with('/'),
259 "leading slash must be stripped: {got}"
260 );
261 assert!(
262 got.to_ascii_lowercase().starts_with("c:"),
263 "drive prefix must survive: {got}"
264 );
265 }
266
267 #[cfg(windows)]
268 #[test]
269 fn parse_file_uri_windows_percent_encoded_colon() {
270 let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
272 assert!(
273 !got.starts_with('/'),
274 "leading slash must be stripped: {got}"
275 );
276 assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
277 }
278
279 #[test]
280 fn rejects_null_bytes() {
281 assert!(uri_to_path("file:///tmp/evil%00path").is_none());
282 }
283
284 #[test]
285 fn rejects_relative_uri() {
286 assert!(uri_to_path("file://relative/path").is_none());
287 }
288
289 #[test]
290 fn canonicalizes_traversal() {
291 let tmp = tempfile::tempdir().unwrap();
292 let sub = tmp.path().join("a").join("b");
293 std::fs::create_dir_all(&sub).unwrap();
294 let traversal = format!("file://{}/a/b/../..", tmp.path().display());
295 let result = uri_to_path(&traversal);
296 assert!(result.is_some());
297 let resolved = result.unwrap();
298 assert!(
299 !resolved.contains(".."),
300 "should be canonicalized: {resolved}"
301 );
302 }
303
304 #[test]
305 fn best_root_prefers_marker() {
306 let tmp = tempfile::tempdir().unwrap();
307 let with_marker = tmp.path().join("has_git");
308 let without = tmp.path().join("plain");
309 std::fs::create_dir_all(&with_marker).unwrap();
310 std::fs::create_dir_all(&without).unwrap();
311 std::fs::create_dir(with_marker.join(".git")).unwrap();
312
313 let uris = vec![
314 format!("file://{}", without.display()),
315 format!("file://{}", with_marker.display()),
316 ];
317 let result = best_root_from_uris(&uris).unwrap();
318 assert!(result.contains("has_git"));
319 }
320
321 #[test]
322 fn best_root_falls_back_to_first_existing_dir() {
323 let tmp = tempfile::tempdir().unwrap();
324 let a = tmp.path().join("dir_a");
325 let b = tmp.path().join("dir_b");
326 std::fs::create_dir_all(&a).unwrap();
327 std::fs::create_dir_all(&b).unwrap();
328
329 let uris = vec![
330 format!("file://{}", a.display()),
331 format!("file://{}", b.display()),
332 ];
333 let result = best_root_from_uris(&uris).unwrap();
334 assert!(result.contains("dir_a"));
335 }
336
337 #[test]
338 fn best_root_skips_nonexistent() {
339 let uris = vec!["file:///nonexistent_abc_123".to_string()];
340 assert!(best_root_from_uris(&uris).is_none());
341 }
342
343 #[test]
344 fn best_root_empty_returns_none() {
345 assert!(best_root_from_uris(&[]).is_none());
346 }
347
348 #[test]
349 fn env_override_returns_none_when_unset() {
350 let _ = root_from_env();
351 }
352
353 #[test]
354 fn best_root_rejects_home_without_marker() {
355 if let Some(home) = dirs::home_dir() {
358 let uris = vec![format!("file://{}", home.display())];
359 assert_eq!(
360 best_root_from_uris(&uris),
361 None,
362 "HOME must never be accepted as a marker-less project root"
363 );
364 }
365 }
366
367 #[test]
368 fn best_root_prefers_safe_dir_over_home() {
369 if let Some(home) = dirs::home_dir() {
370 let tmp = tempfile::tempdir().unwrap();
371 let safe = tmp.path().join("real_project");
372 std::fs::create_dir_all(&safe).unwrap();
373 let uris = vec![
374 format!("file://{}", home.display()),
375 format!("file://{}", safe.display()),
376 ];
377 let result = best_root_from_uris(&uris).unwrap();
378 assert!(result.contains("real_project"));
379 }
380 }
381
382 #[test]
383 fn best_root_rejects_filesystem_root() {
384 let uris = vec!["file:///".to_string()];
385 assert!(best_root_from_uris(&uris).is_none());
386 }
387
388 #[test]
389 fn all_paths_from_uris() {
390 let tmp = tempfile::tempdir().unwrap();
391 let a = tmp.path().join("project_a");
392 let b = tmp.path().join("project_b");
393 std::fs::create_dir_all(&a).unwrap();
394 std::fs::create_dir_all(&b).unwrap();
395 std::fs::create_dir(a.join(".git")).unwrap();
396
397 let uris = vec![
398 format!("file://{}", a.display()),
399 format!("file://{}", b.display()),
400 ];
401
402 let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
403 assert_eq!(paths.len(), 2);
404 assert!(paths[0].contains("project_a"));
405 assert!(paths[1].contains("project_b"));
406
407 let best = best_root_from_uris(&uris).unwrap();
408 assert!(best.contains("project_a"));
409 }
410
411 #[test]
412 fn split_workspace_paths_comma_separated() {
413 assert_eq!(
414 split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
415 vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
416 );
417 }
418
419 #[test]
420 fn split_workspace_paths_trims_and_drops_empty() {
421 assert_eq!(
422 split_workspace_paths(" /a , , /b ,"),
423 vec!["/a".to_string(), "/b".to_string()]
424 );
425 }
426
427 #[cfg(unix)]
428 #[test]
429 fn split_workspace_paths_unix_colon_delimiter() {
430 assert_eq!(
432 split_workspace_paths("/a:/b"),
433 vec!["/a".to_string(), "/b".to_string()]
434 );
435 }
436
437 #[test]
438 fn best_root_from_paths_prefers_marker_over_first() {
439 let tmp = tempfile::tempdir().unwrap();
440 let plain = tmp.path().join("plain");
441 let marked = tmp.path().join("marked");
442 std::fs::create_dir_all(&plain).unwrap();
443 std::fs::create_dir_all(&marked).unwrap();
444 std::fs::create_dir(marked.join(".git")).unwrap();
445 let got = best_root_from_paths(vec![
446 plain.to_string_lossy().to_string(),
447 marked.to_string_lossy().to_string(),
448 ])
449 .unwrap();
450 assert!(got.contains("marked"), "marker dir must win: {got}");
451 }
452
453 #[test]
454 fn best_root_from_paths_filters_nonexistent() {
455 let tmp = tempfile::tempdir().unwrap();
456 let safe = tmp.path().join("real_proj");
457 std::fs::create_dir_all(&safe).unwrap();
458 let got = best_root_from_paths(vec![
459 "/nonexistent_xyz_987".to_string(),
460 safe.to_string_lossy().to_string(),
461 ])
462 .unwrap();
463 assert!(got.contains("real_proj"));
464 }
465
466 #[test]
467 fn best_root_from_paths_empty_returns_none() {
468 assert!(best_root_from_paths(vec![]).is_none());
469 assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
470 }
471
472 #[test]
473 fn workspace_env_value_picks_marker_root() {
474 let tmp = tempfile::tempdir().unwrap();
477 let a = tmp.path().join("ws_a");
478 let b = tmp.path().join("ws_b");
479 std::fs::create_dir_all(&a).unwrap();
480 std::fs::create_dir_all(&b).unwrap();
481 std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
482 let raw = format!("{},{}", a.display(), b.display());
483 let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
484 assert!(got.contains("ws_b"), "marker workspace must win: {got}");
485 }
486
487 #[test]
488 fn workspace_env_readers_do_not_panic() {
489 let _ = root_from_workspace_env();
491 let _ = workspace_roots_from_env();
492 }
493}