1use std::path::Path;
3
4mod derive;
5pub use derive::{derive_project_root_from, derive_project_root_from_cwd};
6
7pub fn uri_to_path(uri: &str) -> Option<String> {
11 let raw = uri.strip_prefix("file://")?;
12 if raw.contains("%00") {
13 return None;
14 }
15 let decoded = percent_decode(raw);
16 if decoded.is_empty() || decoded.contains('\0') {
17 return None;
18 }
19 #[cfg(windows)]
27 let decoded = if has_leading_slash_drive(&decoded) {
28 decoded[1..].to_string()
29 } else {
30 decoded
31 };
32 let path = Path::new(&decoded);
33 if !path.is_absolute() {
34 return None;
35 }
36 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
37 let s = canonical.to_string_lossy().to_string();
38 if s.is_empty() {
39 return None;
40 }
41 Some(s)
42}
43
44fn percent_decode(s: &str) -> String {
45 let mut out = String::with_capacity(s.len());
46 let mut chars = s.bytes();
47 while let Some(b) = chars.next() {
48 if b == b'%' {
49 let hi = chars.next().and_then(hex_val);
50 let lo = chars.next().and_then(hex_val);
51 if let (Some(h), Some(l)) = (hi, lo) {
52 let byte = h << 4 | l;
53 if byte == 0 {
54 continue;
55 }
56 out.push(byte as char);
57 } else {
58 out.push('%');
59 }
60 } else {
61 out.push(b as char);
62 }
63 }
64 out
65}
66
67fn hex_val(b: u8) -> Option<u8> {
68 match b {
69 b'0'..=b'9' => Some(b - b'0'),
70 b'a'..=b'f' => Some(b - b'a' + 10),
71 b'A'..=b'F' => Some(b - b'A' + 10),
72 _ => None,
73 }
74}
75
76#[cfg_attr(not(windows), allow(dead_code))]
83fn has_leading_slash_drive(p: &str) -> bool {
84 let b = p.as_bytes();
85 b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
86}
87
88pub(super) fn has_project_marker(dir: &Path) -> bool {
89 crate::core::pathutil::has_project_marker(dir)
90}
91
92pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
99 root_selection_from_uris(uris).map(|selection| selection.chosen_root)
100}
101
102pub fn root_selection_from_uris(
105 uris: &[String],
106) -> Option<crate::core::root_selection::RootSelection> {
107 crate::core::root_selection::select_with_preferences(
108 uris.iter().filter_map(|u| uri_to_path(u)).collect(),
109 )
110}
111
112#[cfg(test)]
122fn best_root_from_paths(paths: Vec<String>) -> Option<String> {
123 crate::core::root_selection::select_with_preferences(paths).map(|s| s.chosen_root)
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
170pub fn session_root(env_root: Option<String>, cwd_root: Option<String>) -> Option<String> {
180 let pin = std::env::var("LEAN_CTX_PROJECT_ROOT")
181 .ok()
182 .map(|v| v.trim().to_string());
183 let env_is_pin = env_root.is_some() && env_root == pin;
184 reconcile_session_root(env_root, env_is_pin, cwd_root)
185}
186
187pub(crate) fn reconcile_pinned_root(pin: String, cwd_root: Option<String>) -> String {
191 reconcile_session_root(Some(pin.clone()), true, cwd_root).unwrap_or(pin)
192}
193
194fn reconcile_session_root(
195 env_root: Option<String>,
196 env_is_pin: bool,
197 cwd_root: Option<String>,
198) -> Option<String> {
199 let cwd_root =
200 cwd_root.filter(|cwd| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(cwd)));
201 if let (true, Some(pin), Some(cwd)) = (env_is_pin, env_root.as_deref(), cwd_root.as_deref()) {
202 let pin_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(pin));
203 let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(cwd));
204 let unrelated = !cwd_path.starts_with(&pin_path) && !pin_path.starts_with(&cwd_path);
205 if unrelated && has_project_marker(&cwd_path) {
206 tracing::warn!(
207 "LEAN_CTX_PROJECT_ROOT pins {pin}, but this session started in project {cwd} — \
208 using {cwd}. Remove the pin from the agent's global MCP config \
209 (`lean-ctx doctor --fix`)."
210 );
211 return cwd_root;
212 }
213 }
214 env_root.or(cwd_root)
215}
216
217pub fn pinned_root() -> Option<String> {
223 let pinned = std::env::var("LEAN_CTX_PROJECT_ROOT").ok()?;
224 let pinned = pinned.trim();
225 (!pinned.is_empty()
226 && Path::new(pinned).is_dir()
227 && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(pinned)))
228 .then(|| pinned.to_string())
229}
230
231fn split_workspace_paths(raw: &str) -> Vec<String> {
237 let delims: &[char] = if cfg!(windows) {
238 &[',', ';']
239 } else {
240 &[',', ':']
241 };
242 raw.split(delims)
243 .map(str::trim)
244 .filter(|s| !s.is_empty())
245 .map(ToString::to_string)
246 .collect()
247}
248
249pub fn workspace_selection_from_env() -> Option<crate::core::root_selection::RootSelection> {
261 for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
262 if let Ok(raw) = std::env::var(var)
263 && let Some(selection) =
264 crate::core::root_selection::select_with_preferences(split_workspace_paths(&raw))
265 {
266 return Some(selection);
267 }
268 }
269 None
270}
271
272pub fn workspace_roots_from_env() -> Vec<String> {
277 for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
278 if let Ok(raw) = std::env::var(var) {
279 let paths: Vec<String> = split_workspace_paths(&raw)
280 .into_iter()
281 .filter(|p| Path::new(p).is_dir())
282 .filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
283 .collect();
284 if !paths.is_empty() {
285 return paths;
286 }
287 }
288 }
289 Vec::new()
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[cfg(unix)]
297 #[test]
298 fn parse_file_uri_unix() {
299 assert_eq!(
300 uri_to_path("file:///home/user/project"),
301 Some("/home/user/project".to_string())
302 );
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn parse_file_uri_windows() {
308 assert_eq!(
309 uri_to_path("file:///C:/Users/dev/project"),
310 Some("/C:/Users/dev/project".to_string())
311 );
312 }
313
314 #[cfg(unix)]
315 #[test]
316 fn parse_file_uri_with_spaces() {
317 assert_eq!(
318 uri_to_path("file:///home/user/my%20project"),
319 Some("/home/user/my project".to_string())
320 );
321 }
322
323 #[test]
324 fn parse_non_file_uri_returns_none() {
325 assert!(uri_to_path("https://example.com").is_none());
326 assert!(uri_to_path("").is_none());
327 }
328
329 #[test]
330 fn detects_leading_slash_windows_drive() {
331 assert!(has_leading_slash_drive("/C:/Users/dev"));
335 assert!(has_leading_slash_drive("/c:/proj"));
336 assert!(has_leading_slash_drive("/Z:"));
337 assert!(!has_leading_slash_drive("/home/user/proj"));
339 assert!(!has_leading_slash_drive("C:/already"));
340 assert!(!has_leading_slash_drive("//server/share"));
341 assert!(!has_leading_slash_drive("/"));
342 assert!(!has_leading_slash_drive("/1:/x"));
343 }
344
345 #[cfg(windows)]
346 #[test]
347 fn parse_file_uri_windows_drive_strips_leading_slash() {
348 let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
352 assert!(
353 !got.starts_with('/'),
354 "leading slash must be stripped: {got}"
355 );
356 assert!(
357 got.to_ascii_lowercase().starts_with("c:"),
358 "drive prefix must survive: {got}"
359 );
360 }
361
362 #[cfg(windows)]
363 #[test]
364 fn parse_file_uri_windows_percent_encoded_colon() {
365 let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
367 assert!(
368 !got.starts_with('/'),
369 "leading slash must be stripped: {got}"
370 );
371 assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
372 }
373
374 #[test]
375 fn rejects_null_bytes() {
376 assert!(uri_to_path("file:///tmp/evil%00path").is_none());
377 }
378
379 #[test]
380 fn rejects_relative_uri() {
381 assert!(uri_to_path("file://relative/path").is_none());
382 }
383
384 #[test]
385 fn canonicalizes_traversal() {
386 let tmp = tempfile::tempdir().unwrap();
387 let sub = tmp.path().join("a").join("b");
388 std::fs::create_dir_all(&sub).unwrap();
389 let traversal = format!("file://{}/a/b/../..", tmp.path().display());
390 let result = uri_to_path(&traversal);
391 assert!(result.is_some());
392 let resolved = result.unwrap();
393 assert!(
394 !resolved.contains(".."),
395 "should be canonicalized: {resolved}"
396 );
397 }
398
399 #[test]
400 fn best_root_prefers_marker() {
401 let tmp = tempfile::tempdir().unwrap();
402 let with_marker = tmp.path().join("has_git");
403 let without = tmp.path().join("plain");
404 std::fs::create_dir_all(&with_marker).unwrap();
405 std::fs::create_dir_all(&without).unwrap();
406 std::fs::create_dir(with_marker.join(".git")).unwrap();
407
408 let uris = vec![
409 format!("file://{}", without.display()),
410 format!("file://{}", with_marker.display()),
411 ];
412 let result = best_root_from_uris(&uris).unwrap();
413 assert!(result.contains("has_git"));
414 }
415
416 #[test]
417 fn best_root_falls_back_to_first_existing_dir() {
418 let tmp = tempfile::tempdir().unwrap();
419 let a = tmp.path().join("dir_a");
420 let b = tmp.path().join("dir_b");
421 std::fs::create_dir_all(&a).unwrap();
422 std::fs::create_dir_all(&b).unwrap();
423
424 let uris = vec![
425 format!("file://{}", a.display()),
426 format!("file://{}", b.display()),
427 ];
428 let result = best_root_from_uris(&uris).unwrap();
429 assert!(result.contains("dir_a"));
430 }
431
432 #[test]
433 fn best_root_skips_nonexistent() {
434 let uris = vec!["file:///nonexistent_abc_123".to_string()];
435 assert!(best_root_from_uris(&uris).is_none());
436 }
437
438 #[test]
439 fn best_root_empty_returns_none() {
440 assert!(best_root_from_uris(&[]).is_none());
441 }
442
443 #[test]
444 fn env_override_returns_none_when_unset() {
445 let _ = root_from_env();
446 }
447
448 #[test]
449 fn best_root_rejects_home_without_marker() {
450 if let Some(home) = dirs::home_dir() {
453 let uris = vec![format!("file://{}", home.display())];
454 assert_eq!(
455 best_root_from_uris(&uris),
456 None,
457 "HOME must never be accepted as a marker-less project root"
458 );
459 }
460 }
461
462 #[test]
463 fn best_root_prefers_safe_dir_over_home() {
464 if let Some(home) = dirs::home_dir() {
465 let tmp = tempfile::tempdir().unwrap();
466 let safe = tmp.path().join("real_project");
467 std::fs::create_dir_all(&safe).unwrap();
468 let uris = vec![
469 format!("file://{}", home.display()),
470 format!("file://{}", safe.display()),
471 ];
472 let result = best_root_from_uris(&uris).unwrap();
473 assert!(result.contains("real_project"));
474 }
475 }
476
477 #[test]
478 fn best_root_rejects_filesystem_root() {
479 let uris = vec!["file:///".to_string()];
480 assert!(best_root_from_uris(&uris).is_none());
481 }
482
483 #[test]
484 fn all_paths_from_uris() {
485 let tmp = tempfile::tempdir().unwrap();
486 let a = tmp.path().join("project_a");
487 let b = tmp.path().join("project_b");
488 std::fs::create_dir_all(&a).unwrap();
489 std::fs::create_dir_all(&b).unwrap();
490 std::fs::create_dir(a.join(".git")).unwrap();
491
492 let uris = vec![
493 format!("file://{}", a.display()),
494 format!("file://{}", b.display()),
495 ];
496
497 let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
498 assert_eq!(paths.len(), 2);
499 assert!(paths[0].contains("project_a"));
500 assert!(paths[1].contains("project_b"));
501
502 let best = best_root_from_uris(&uris).unwrap();
503 assert!(best.contains("project_a"));
504 }
505
506 #[test]
507 fn split_workspace_paths_comma_separated() {
508 assert_eq!(
509 split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
510 vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
511 );
512 }
513
514 #[test]
515 fn split_workspace_paths_trims_and_drops_empty() {
516 assert_eq!(
517 split_workspace_paths(" /a , , /b ,"),
518 vec!["/a".to_string(), "/b".to_string()]
519 );
520 }
521
522 #[cfg(unix)]
523 #[test]
524 fn split_workspace_paths_unix_colon_delimiter() {
525 assert_eq!(
527 split_workspace_paths("/a:/b"),
528 vec!["/a".to_string(), "/b".to_string()]
529 );
530 }
531
532 #[test]
533 fn best_root_from_paths_prefers_marker_over_first() {
534 let tmp = tempfile::tempdir().unwrap();
535 let plain = tmp.path().join("plain");
536 let marked = tmp.path().join("marked");
537 std::fs::create_dir_all(&plain).unwrap();
538 std::fs::create_dir_all(&marked).unwrap();
539 std::fs::create_dir(marked.join(".git")).unwrap();
540 let got = best_root_from_paths(vec![
541 plain.to_string_lossy().to_string(),
542 marked.to_string_lossy().to_string(),
543 ])
544 .unwrap();
545 assert!(got.contains("marked"), "marker dir must win: {got}");
546 }
547
548 #[test]
549 fn best_root_from_paths_filters_nonexistent() {
550 let tmp = tempfile::tempdir().unwrap();
551 let safe = tmp.path().join("real_proj");
552 std::fs::create_dir_all(&safe).unwrap();
553 let got = best_root_from_paths(vec![
554 "/nonexistent_xyz_987".to_string(),
555 safe.to_string_lossy().to_string(),
556 ])
557 .unwrap();
558 assert!(got.contains("real_proj"));
559 }
560
561 #[test]
562 fn best_root_from_paths_empty_returns_none() {
563 assert!(best_root_from_paths(vec![]).is_none());
564 assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
565 }
566
567 #[test]
568 fn workspace_env_value_picks_marker_root() {
569 let tmp = tempfile::tempdir().unwrap();
572 let a = tmp.path().join("ws_a");
573 let b = tmp.path().join("ws_b");
574 std::fs::create_dir_all(&a).unwrap();
575 std::fs::create_dir_all(&b).unwrap();
576 std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
577 let raw = format!("{},{}", a.display(), b.display());
578 let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
579 assert!(got.contains("ws_b"), "marker workspace must win: {got}");
580 }
581
582 #[test]
583 fn stale_global_pin_yields_to_the_sessions_real_project() {
584 let tmp = tempfile::tempdir().unwrap();
585 let pinned = tmp.path().join("thinkery");
586 let actual = tmp.path().join("lean-ctx");
587 for dir in [&pinned, &actual] {
588 std::fs::create_dir_all(dir.join(".git")).unwrap();
589 }
590 let s = |p: &std::path::Path| p.to_string_lossy().to_string();
591
592 assert_eq!(
594 reconcile_session_root(Some(s(&pinned)), true, Some(s(&actual))),
595 Some(s(&actual))
596 );
597 assert_eq!(
599 reconcile_session_root(Some(s(&pinned)), false, Some(s(&actual))),
600 Some(s(&pinned))
601 );
602 let sub = pinned.join("crates/a");
604 std::fs::create_dir_all(sub.join(".git")).unwrap();
605 assert_eq!(
606 reconcile_session_root(Some(s(&pinned)), true, Some(s(&sub))),
607 Some(s(&pinned))
608 );
609 let bare = tmp.path().join("scratch");
611 std::fs::create_dir_all(&bare).unwrap();
612 assert_eq!(
613 reconcile_session_root(Some(s(&pinned)), true, Some(s(&bare))),
614 Some(s(&pinned))
615 );
616 assert_eq!(
617 reconcile_session_root(None, false, Some(s(&actual))),
618 Some(s(&actual))
619 );
620 }
621
622 #[test]
623 fn workspace_env_readers_do_not_panic() {
624 let _ = workspace_selection_from_env();
626 let _ = workspace_roots_from_env();
627 }
628}