use std::path::Path;
mod derive;
pub use derive::{derive_project_root_from, derive_project_root_from_cwd};
pub fn uri_to_path(uri: &str) -> Option<String> {
let raw = uri.strip_prefix("file://")?;
if raw.contains("%00") {
return None;
}
let decoded = percent_decode(raw);
if decoded.is_empty() || decoded.contains('\0') {
return None;
}
#[cfg(windows)]
let decoded = if has_leading_slash_drive(&decoded) {
decoded[1..].to_string()
} else {
decoded
};
let path = Path::new(&decoded);
if !path.is_absolute() {
return None;
}
let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
let s = canonical.to_string_lossy().to_string();
if s.is_empty() {
return None;
}
Some(s)
}
fn percent_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let hi = chars.next().and_then(hex_val);
let lo = chars.next().and_then(hex_val);
if let (Some(h), Some(l)) = (hi, lo) {
let byte = h << 4 | l;
if byte == 0 {
continue;
}
out.push(byte as char);
} else {
out.push('%');
}
} else {
out.push(b as char);
}
}
out
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg_attr(not(windows), allow(dead_code))]
fn has_leading_slash_drive(p: &str) -> bool {
let b = p.as_bytes();
b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
}
pub(super) fn has_project_marker(dir: &Path) -> bool {
crate::core::pathutil::has_project_marker(dir)
}
pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
root_selection_from_uris(uris).map(|selection| selection.chosen_root)
}
pub fn root_selection_from_uris(
uris: &[String],
) -> Option<crate::core::root_selection::RootSelection> {
crate::core::root_selection::select_with_preferences(
uris.iter().filter_map(|u| uri_to_path(u)).collect(),
)
}
#[cfg(test)]
fn best_root_from_paths(paths: Vec<String>) -> Option<String> {
crate::core::root_selection::select_with_preferences(paths).map(|s| s.chosen_root)
}
pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
uris.iter()
.filter_map(|u| uri_to_path(u))
.filter(|p| Path::new(p).is_dir())
.collect()
}
pub fn root_from_env() -> Option<String> {
const VARS: &[&str] = &[
"LEAN_CTX_PROJECT_ROOT",
"CURSOR_PROJECT_DIR",
"CLAUDE_PROJECT_DIR",
"WINDSURF_PROJECT_DIR",
"TRAE_PROJECT_DIR",
"KIRO_PROJECT_DIR",
"CODEX_SANDBOX_DIR",
"GIT_WORK_TREE",
];
for var in VARS {
if let Ok(val) = std::env::var(var) {
let trimmed = val.trim().to_string();
if !trimmed.is_empty()
&& Path::new(&trimmed).is_dir()
&& !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
{
return Some(trimmed);
}
}
}
None
}
pub fn session_root(env_root: Option<String>, cwd_root: Option<String>) -> Option<String> {
let pin = std::env::var("LEAN_CTX_PROJECT_ROOT")
.ok()
.map(|v| v.trim().to_string());
let env_is_pin = env_root.is_some() && env_root == pin;
reconcile_session_root(env_root, env_is_pin, cwd_root)
}
pub(crate) fn reconcile_pinned_root(pin: String, cwd_root: Option<String>) -> String {
reconcile_session_root(Some(pin.clone()), true, cwd_root).unwrap_or(pin)
}
fn reconcile_session_root(
env_root: Option<String>,
env_is_pin: bool,
cwd_root: Option<String>,
) -> Option<String> {
let cwd_root =
cwd_root.filter(|cwd| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(cwd)));
if let (true, Some(pin), Some(cwd)) = (env_is_pin, env_root.as_deref(), cwd_root.as_deref()) {
let pin_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(pin));
let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(cwd));
let unrelated = !cwd_path.starts_with(&pin_path) && !pin_path.starts_with(&cwd_path);
if unrelated && has_project_marker(&cwd_path) {
tracing::warn!(
"LEAN_CTX_PROJECT_ROOT pins {pin}, but this session started in project {cwd} — \
using {cwd}. Remove the pin from the agent's global MCP config \
(`lean-ctx doctor --fix`)."
);
return cwd_root;
}
}
env_root.or(cwd_root)
}
pub fn pinned_root() -> Option<String> {
let pinned = std::env::var("LEAN_CTX_PROJECT_ROOT").ok()?;
let pinned = pinned.trim();
(!pinned.is_empty()
&& Path::new(pinned).is_dir()
&& !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(pinned)))
.then(|| pinned.to_string())
}
fn split_workspace_paths(raw: &str) -> Vec<String> {
let delims: &[char] = if cfg!(windows) {
&[',', ';']
} else {
&[',', ':']
};
raw.split(delims)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.collect()
}
pub fn workspace_selection_from_env() -> Option<crate::core::root_selection::RootSelection> {
for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
if let Ok(raw) = std::env::var(var)
&& let Some(selection) =
crate::core::root_selection::select_with_preferences(split_workspace_paths(&raw))
{
return Some(selection);
}
}
None
}
pub fn workspace_roots_from_env() -> Vec<String> {
for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
if let Ok(raw) = std::env::var(var) {
let paths: Vec<String> = split_workspace_paths(&raw)
.into_iter()
.filter(|p| Path::new(p).is_dir())
.filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
.collect();
if !paths.is_empty() {
return paths;
}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn parse_file_uri_unix() {
assert_eq!(
uri_to_path("file:///home/user/project"),
Some("/home/user/project".to_string())
);
}
#[cfg(unix)]
#[test]
fn parse_file_uri_windows() {
assert_eq!(
uri_to_path("file:///C:/Users/dev/project"),
Some("/C:/Users/dev/project".to_string())
);
}
#[cfg(unix)]
#[test]
fn parse_file_uri_with_spaces() {
assert_eq!(
uri_to_path("file:///home/user/my%20project"),
Some("/home/user/my project".to_string())
);
}
#[test]
fn parse_non_file_uri_returns_none() {
assert!(uri_to_path("https://example.com").is_none());
assert!(uri_to_path("").is_none());
}
#[test]
fn detects_leading_slash_windows_drive() {
assert!(has_leading_slash_drive("/C:/Users/dev"));
assert!(has_leading_slash_drive("/c:/proj"));
assert!(has_leading_slash_drive("/Z:"));
assert!(!has_leading_slash_drive("/home/user/proj"));
assert!(!has_leading_slash_drive("C:/already"));
assert!(!has_leading_slash_drive("//server/share"));
assert!(!has_leading_slash_drive("/"));
assert!(!has_leading_slash_drive("/1:/x"));
}
#[cfg(windows)]
#[test]
fn parse_file_uri_windows_drive_strips_leading_slash() {
let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
assert!(
!got.starts_with('/'),
"leading slash must be stripped: {got}"
);
assert!(
got.to_ascii_lowercase().starts_with("c:"),
"drive prefix must survive: {got}"
);
}
#[cfg(windows)]
#[test]
fn parse_file_uri_windows_percent_encoded_colon() {
let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
assert!(
!got.starts_with('/'),
"leading slash must be stripped: {got}"
);
assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
}
#[test]
fn rejects_null_bytes() {
assert!(uri_to_path("file:///tmp/evil%00path").is_none());
}
#[test]
fn rejects_relative_uri() {
assert!(uri_to_path("file://relative/path").is_none());
}
#[test]
fn canonicalizes_traversal() {
let tmp = tempfile::tempdir().unwrap();
let sub = tmp.path().join("a").join("b");
std::fs::create_dir_all(&sub).unwrap();
let traversal = format!("file://{}/a/b/../..", tmp.path().display());
let result = uri_to_path(&traversal);
assert!(result.is_some());
let resolved = result.unwrap();
assert!(
!resolved.contains(".."),
"should be canonicalized: {resolved}"
);
}
#[test]
fn best_root_prefers_marker() {
let tmp = tempfile::tempdir().unwrap();
let with_marker = tmp.path().join("has_git");
let without = tmp.path().join("plain");
std::fs::create_dir_all(&with_marker).unwrap();
std::fs::create_dir_all(&without).unwrap();
std::fs::create_dir(with_marker.join(".git")).unwrap();
let uris = vec![
format!("file://{}", without.display()),
format!("file://{}", with_marker.display()),
];
let result = best_root_from_uris(&uris).unwrap();
assert!(result.contains("has_git"));
}
#[test]
fn best_root_falls_back_to_first_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("dir_a");
let b = tmp.path().join("dir_b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let uris = vec![
format!("file://{}", a.display()),
format!("file://{}", b.display()),
];
let result = best_root_from_uris(&uris).unwrap();
assert!(result.contains("dir_a"));
}
#[test]
fn best_root_skips_nonexistent() {
let uris = vec!["file:///nonexistent_abc_123".to_string()];
assert!(best_root_from_uris(&uris).is_none());
}
#[test]
fn best_root_empty_returns_none() {
assert!(best_root_from_uris(&[]).is_none());
}
#[test]
fn env_override_returns_none_when_unset() {
let _ = root_from_env();
}
#[test]
fn best_root_rejects_home_without_marker() {
let _home = crate::core::data_dir::test_env_lock();
if let Some(home) = dirs::home_dir() {
let uris = vec![format!("file://{}", home.display())];
assert_eq!(
best_root_from_uris(&uris),
None,
"HOME must never be accepted as a marker-less project root"
);
}
}
#[test]
fn best_root_prefers_safe_dir_over_home() {
let _home = crate::core::data_dir::test_env_lock();
if let Some(home) = dirs::home_dir() {
let tmp = tempfile::tempdir().unwrap();
let safe = tmp.path().join("real_project");
std::fs::create_dir_all(&safe).unwrap();
let uris = vec![
format!("file://{}", home.display()),
format!("file://{}", safe.display()),
];
let result = best_root_from_uris(&uris).unwrap();
assert!(result.contains("real_project"));
}
}
#[test]
fn best_root_rejects_filesystem_root() {
let uris = vec!["file:///".to_string()];
assert!(best_root_from_uris(&uris).is_none());
}
#[test]
fn all_paths_from_uris() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("project_a");
let b = tmp.path().join("project_b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::create_dir(a.join(".git")).unwrap();
let uris = vec![
format!("file://{}", a.display()),
format!("file://{}", b.display()),
];
let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
assert_eq!(paths.len(), 2);
assert!(paths[0].contains("project_a"));
assert!(paths[1].contains("project_b"));
let best = best_root_from_uris(&uris).unwrap();
assert!(best.contains("project_a"));
}
#[test]
fn split_workspace_paths_comma_separated() {
assert_eq!(
split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
);
}
#[test]
fn split_workspace_paths_trims_and_drops_empty() {
assert_eq!(
split_workspace_paths(" /a , , /b ,"),
vec!["/a".to_string(), "/b".to_string()]
);
}
#[cfg(unix)]
#[test]
fn split_workspace_paths_unix_colon_delimiter() {
assert_eq!(
split_workspace_paths("/a:/b"),
vec!["/a".to_string(), "/b".to_string()]
);
}
#[test]
fn best_root_from_paths_prefers_marker_over_first() {
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("plain");
let marked = tmp.path().join("marked");
std::fs::create_dir_all(&plain).unwrap();
std::fs::create_dir_all(&marked).unwrap();
std::fs::create_dir(marked.join(".git")).unwrap();
let got = best_root_from_paths(vec![
plain.to_string_lossy().to_string(),
marked.to_string_lossy().to_string(),
])
.unwrap();
assert!(got.contains("marked"), "marker dir must win: {got}");
}
#[test]
fn best_root_from_paths_filters_nonexistent() {
let tmp = tempfile::tempdir().unwrap();
let safe = tmp.path().join("real_proj");
std::fs::create_dir_all(&safe).unwrap();
let got = best_root_from_paths(vec![
"/nonexistent_xyz_987".to_string(),
safe.to_string_lossy().to_string(),
])
.unwrap();
assert!(got.contains("real_proj"));
}
#[test]
fn best_root_from_paths_empty_returns_none() {
assert!(best_root_from_paths(vec![]).is_none());
assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
}
#[test]
fn workspace_env_value_picks_marker_root() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("ws_a");
let b = tmp.path().join("ws_b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
let raw = format!("{},{}", a.display(), b.display());
let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
assert!(got.contains("ws_b"), "marker workspace must win: {got}");
}
#[test]
fn stale_global_pin_yields_to_the_sessions_real_project() {
let tmp = tempfile::tempdir().unwrap();
let pinned = tmp.path().join("thinkery");
let actual = tmp.path().join("lean-ctx");
for dir in [&pinned, &actual] {
std::fs::create_dir_all(dir.join(".git")).unwrap();
}
let s = |p: &std::path::Path| p.to_string_lossy().to_string();
assert_eq!(
reconcile_session_root(Some(s(&pinned)), true, Some(s(&actual))),
Some(s(&actual))
);
assert_eq!(
reconcile_session_root(Some(s(&pinned)), false, Some(s(&actual))),
Some(s(&pinned))
);
let sub = pinned.join("crates/a");
std::fs::create_dir_all(sub.join(".git")).unwrap();
assert_eq!(
reconcile_session_root(Some(s(&pinned)), true, Some(s(&sub))),
Some(s(&pinned))
);
let bare = tmp.path().join("scratch");
std::fs::create_dir_all(&bare).unwrap();
assert_eq!(
reconcile_session_root(Some(s(&pinned)), true, Some(s(&bare))),
Some(s(&pinned))
);
assert_eq!(
reconcile_session_root(None, false, Some(s(&actual))),
Some(s(&actual))
);
}
#[test]
fn workspace_env_readers_do_not_panic() {
let _ = workspace_selection_from_env();
let _ = workspace_roots_from_env();
}
}