use std::path::{Path, PathBuf};
pub use crate::core::pathutil::has_project_marker;
fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
let start = crate::core::pathutil::safe_canonicalize_or_self(start);
let mut cur: Option<&Path> = Some(start.as_path());
while let Some(dir) = cur {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
cur = dir.parent();
}
None
}
pub fn shell_cwd_is_divergent_checkout(project_root: &str, shell_cwd: &str) -> bool {
if shell_cwd == project_root {
return false;
}
match (
nearest_git_boundary(Path::new(shell_cwd)),
nearest_git_boundary(Path::new(project_root)),
) {
(Some(cwd_git), Some(root_git)) => cwd_git != root_git,
_ => false,
}
}
pub fn resolve_tool_path(
project_root: Option<&str>,
shell_cwd: Option<&str>,
raw: &str,
) -> Result<String, String> {
resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
}
pub fn resolve_tool_path_with_roots(
project_root: Option<&str>,
shell_cwd: Option<&str>,
raw: &str,
extra_roots: &[String],
) -> Result<String, String> {
resolve_tool_path_refusing(project_root, shell_cwd, raw, extra_roots)
.map_err(|refusal| refusal.message)
}
#[derive(Debug)]
pub struct PathRefusal {
pub message: String,
pub class: Option<crate::core::security_events::RefusalClass>,
}
impl From<String> for PathRefusal {
fn from(message: String) -> Self {
Self {
message,
class: None,
}
}
}
impl PathRefusal {
pub fn secret_path(message: String) -> Self {
Self {
message,
class: Some(crate::core::security_events::RefusalClass::SecretPath),
}
}
pub fn jail(error: &crate::core::error::PathJailError) -> Self {
Self {
message: error.to_string(),
class: crate::core::security_events::RefusalClass::of_jail_error(error),
}
}
}
pub fn resolve_tool_path_refusing(
project_root: Option<&str>,
shell_cwd: Option<&str>,
raw: &str,
extra_roots: &[String],
) -> Result<String, PathRefusal> {
let normalized = crate::core::pathutil::normalize_tool_path(raw);
if normalized.is_empty() || normalized == "." {
return Ok(crate::core::workspace_authority::project_dot(
&normalized,
project_root,
)?);
}
let p = Path::new(&normalized);
let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
let resolved: PathBuf = if p.is_absolute() {
PathBuf::from(&normalized)
} else if let Some(root) = project_root {
if let Some(cwd) = shell_cwd
&& shell_cwd_is_divergent_checkout(root, cwd)
{
Path::new(cwd).join(&normalized)
} else {
let joined = Path::new(root).join(&normalized);
if joined.exists() {
joined
} else if let Some(cwd) = shell_cwd {
Path::new(cwd).join(&normalized)
} else {
joined
}
}
} else if let Some(cwd) = shell_cwd {
Path::new(cwd).join(&normalized)
} else {
Path::new(&jail_root).join(&normalized)
};
if project_root.is_none()
&& let Some(error) = unbound_relative_path(raw, &resolved, shell_cwd.unwrap_or(&jail_root))
{
return Err(error.into());
}
let jail_root_path = Path::new(&jail_root);
let jailed =
crate::core::pathjail::jail_path_auto_admitting(&resolved, jail_root_path, extra_roots)
.map_err(|e| PathRefusal::jail(&e))?;
crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)
.map_err(PathRefusal::secret_path)?;
Ok(crate::core::pathutil::normalize_tool_path(
&jailed.to_string_lossy().replace('\\', "/"),
))
}
pub fn unbound_relative_path(raw: &str, resolved: &Path, base: &str) -> Option<String> {
let normalized = crate::core::pathutil::normalize_tool_path(raw);
if Path::new(&normalized).is_absolute() || resolved.exists() {
return None;
}
let base = std::path::absolute(base).unwrap_or_else(|_| PathBuf::from(base));
if !crate::core::pathutil::is_broad_or_unsafe_root(&base) {
return None;
}
Some(format!(
"relative path '{raw}' has no project to resolve against: lean-ctx has no project for \
this session yet (the host started it in {}, outside a project). Pass the absolute \
path instead, e.g. /home/me/code/my-app/{normalized}; that project then stays the \
session project.",
base.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn empty_and_dot_pass_through() {
assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
}
#[test]
fn missing_relative_path_without_a_project_says_what_is_missing() {
let _home = crate::core::data_dir::test_env_lock();
let home = dirs::home_dir()
.expect("home dir")
.to_string_lossy()
.to_string();
let error =
resolve_tool_path(None, Some(&home), "src/no_such_file_lc_7f3a.rs").unwrap_err();
assert!(
error.contains("has no project to resolve against"),
"{error}"
);
assert!(error.contains("absolute path"), "{error}");
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let outcome = resolve_tool_path(None, Some(&cwd), "src/no_such_file_lc.rs");
assert!(
outcome
.as_ref()
.err()
.is_none_or(|e| !e.contains("no project to resolve against")),
"{outcome:?}"
);
}
#[test]
fn relative_resolves_against_project_root() {
let _environment = crate::core::data_dir::test_env_lock();
let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
let _ = fs::create_dir_all(&tmp);
let file = tmp.join("a.txt");
fs::write(&file, "x").unwrap();
let root = tmp.to_string_lossy().to_string();
let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
assert!(out.ends_with("a.txt"), "got {out}");
assert!(out.contains(&root) || Path::new(&out).is_absolute());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn falls_back_to_shell_cwd_when_not_in_project_root() {
let _environment = crate::core::data_dir::test_env_lock();
let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
let root = base.join("root");
let cwd = base.join("cwd");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&cwd).unwrap();
fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
let out = resolve_tool_path(
Some(&root.to_string_lossy()),
Some(&cwd.to_string_lossy()),
"only_in_cwd.txt",
);
assert!(out.is_ok() || out.is_err());
let _ = fs::remove_dir_all(&base);
}
#[test]
fn relative_path_never_resolves_against_process_cwd() {
let _environment = crate::core::data_dir::test_env_lock();
let cwd = std::env::current_dir().unwrap();
assert!(
cwd.join("Cargo.toml").exists(),
"test premise: CWD contains Cargo.toml"
);
let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
fs::create_dir_all(&tmp).unwrap();
let root = tmp.to_string_lossy().to_string();
let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
let out_parent = crate::core::pathjail::canonicalize_or_self(
Path::new(&out)
.parent()
.expect("resolved path has a parent"),
);
assert_eq!(
out_parent, canonical_root,
"resolved {out} must live under the project root, not the process CWD"
);
let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
assert_ne!(
out_parent, canonical_cwd,
"resolved {out} must not resolve against the process CWD"
);
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(not(feature = "no-jail"))]
#[test]
fn extra_roots_thread_through_resolve_tool_path() {
let _environment = crate::core::data_dir::test_env_lock();
let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
let root = base.join("root");
let worktree = base.join("worktree");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&worktree).unwrap();
let file = worktree.join("a.txt");
fs::write(&file, "x").unwrap();
let root_s = root.to_string_lossy().to_string();
let file_abs = file.to_string_lossy().to_string();
let extra = vec![worktree.to_string_lossy().to_string()];
let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
assert!(
out.is_ok(),
"extra_roots must thread through the resolver: {out:?}"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn worktree_shell_cwd_outranks_stale_project_root_copy() {
let _environment = crate::core::data_dir::test_env_lock();
let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
let repo = base.join("repo");
let wt = repo.join(".claude").join("worktrees").join("fix-x");
fs::create_dir_all(repo.join("src")).unwrap();
fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(wt.join("src")).unwrap();
fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
let out = resolve_tool_path(
Some(&repo.to_string_lossy()),
Some(&wt.to_string_lossy()),
"src/scoring.rs",
)
.unwrap();
assert_eq!(
fs::read_to_string(&out).unwrap(),
"fresh",
"must resolve into the worktree, not the stale root: {out}"
);
let new = resolve_tool_path(
Some(&repo.to_string_lossy()),
Some(&wt.to_string_lossy()),
"src/new_file.rs",
)
.unwrap();
assert!(
new.contains("worktrees"),
"write target must land in the worktree: {new}"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
let _environment = crate::core::data_dir::test_env_lock();
let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
let repo = base.join("repo");
fs::create_dir_all(repo.join("rust").join("src")).unwrap();
fs::create_dir_all(repo.join(".git")).unwrap();
fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
assert!(!shell_cwd_is_divergent_checkout(
&repo.to_string_lossy(),
&repo.join("rust").to_string_lossy(),
));
let out = resolve_tool_path(
Some(&repo.to_string_lossy()),
Some(&repo.join("rust").to_string_lossy()),
"rust/src/main.rs",
)
.unwrap();
assert_eq!(
fs::read_to_string(&out).unwrap(),
"root copy",
"same-checkout cwd must not divert resolution: {out}"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn gitless_shell_cwd_gives_no_divergence_signal() {
let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
let repo = base.join("repo");
let scratch = base.join("scratch");
fs::create_dir_all(repo.join(".git")).unwrap();
fs::create_dir_all(&scratch).unwrap();
fs::write(repo.join("a.txt"), "root").unwrap();
assert!(!shell_cwd_is_divergent_checkout(
&repo.to_string_lossy(),
&scratch.to_string_lossy(),
));
let _ = fs::remove_dir_all(&base);
}
#[test]
fn tool_context_shape_project_root_only() {
let _environment = crate::core::data_dir::test_env_lock();
let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
fs::create_dir_all(&tmp).unwrap();
let root = tmp.to_string_lossy().to_string();
let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
assert!(out.ends_with("missing.rs"), "got {out}");
let _ = fs::remove_dir_all(&tmp);
}
#[cfg(not(windows))]
#[test]
fn single_letter_root_is_never_drive_translated_on_unix() {
let _environment = crate::core::data_dir::test_env_lock();
for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
Ok(p) => p,
Err(e) => e,
};
assert!(
!rendered.contains("C:/"),
"drive translation must not run on unix hosts (raw={raw}): {rendered}"
);
}
}
#[test]
fn a_refusal_names_the_boundary_that_refused() {
use crate::core::security_events::RefusalClass;
let _environment = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
fs::write(root.join(".env"), "TOKEN=x").unwrap();
let root = root.to_string_lossy().into_owned();
let prev = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE").ok();
crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
let secret = resolve_tool_path_refusing(Some(&root), None, ".env", &[]);
let outside =
resolve_tool_path_refusing(Some(&root), None, &format!("{root}/../outside.txt"), &[]);
match prev {
Some(v) => crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", v),
None => crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE"),
}
let secret = secret.expect_err("enforce mode refuses a secret path");
assert_eq!(secret.class, Some(RefusalClass::SecretPath));
assert!(
secret.message.contains("Secret-like path"),
"{}",
secret.message
);
if !cfg!(feature = "no-jail") {
let outside = outside.expect_err("the jail refuses a path outside the root");
assert_eq!(outside.class, Some(RefusalClass::OutsideRoot));
}
let unbound = resolve_tool_path_refusing(None, None, "src/no_such_file_lc_9c1.rs", &[]);
if let Err(refusal) = unbound {
assert_eq!(refusal.class, None, "{}", refusal.message);
}
}
}