lean_ctx/core/
path_resolve.rs1use std::path::{Path, PathBuf};
10
11pub use crate::core::pathutil::has_project_marker;
16
17pub fn resolve_tool_path(
38 project_root: Option<&str>,
39 shell_cwd: Option<&str>,
40 raw: &str,
41) -> Result<String, String> {
42 resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
43}
44
45pub fn resolve_tool_path_with_roots(
52 project_root: Option<&str>,
53 shell_cwd: Option<&str>,
54 raw: &str,
55 extra_roots: &[String],
56) -> Result<String, String> {
57 let normalized = crate::core::pathutil::normalize_tool_path(raw);
58 if normalized.is_empty() || normalized == "." {
59 return Ok(normalized);
60 }
61
62 let p = Path::new(&normalized);
63 let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
64
65 let resolved: PathBuf = if p.is_absolute() {
66 PathBuf::from(&normalized)
67 } else if let Some(root) = project_root {
68 let joined = Path::new(root).join(&normalized);
69 if joined.exists() {
70 joined
71 } else if let Some(cwd) = shell_cwd {
72 Path::new(cwd).join(&normalized)
73 } else {
74 Path::new(root).join(&normalized)
75 }
76 } else if let Some(cwd) = shell_cwd {
77 Path::new(cwd).join(&normalized)
78 } else {
79 Path::new(&jail_root).join(&normalized)
80 };
81
82 let jail_root_path = Path::new(&jail_root);
83 let jailed =
84 crate::core::pathjail::jail_path_with_roots(&resolved, jail_root_path, extra_roots)?;
85 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
86
87 Ok(crate::core::pathutil::normalize_tool_path(
88 &jailed.to_string_lossy().replace('\\', "/"),
89 ))
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::fs;
96
97 #[test]
98 fn empty_and_dot_pass_through() {
99 assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
100 assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
101 }
102
103 #[test]
104 fn relative_resolves_against_project_root() {
105 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
106 let _ = fs::create_dir_all(&tmp);
107 let file = tmp.join("a.txt");
108 fs::write(&file, "x").unwrap();
109 let root = tmp.to_string_lossy().to_string();
110
111 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
112 assert!(out.ends_with("a.txt"), "got {out}");
113 assert!(out.contains(&root) || Path::new(&out).is_absolute());
114
115 let _ = fs::remove_dir_all(&tmp);
116 }
117
118 #[test]
119 fn falls_back_to_shell_cwd_when_not_in_project_root() {
120 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
121 let root = base.join("root");
122 let cwd = base.join("cwd");
123 fs::create_dir_all(&root).unwrap();
124 fs::create_dir_all(&cwd).unwrap();
125 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
126
127 let out = resolve_tool_path(
128 Some(&root.to_string_lossy()),
129 Some(&cwd.to_string_lossy()),
130 "only_in_cwd.txt",
131 );
132 assert!(out.is_ok() || out.is_err());
136
137 let _ = fs::remove_dir_all(&base);
138 }
139
140 #[test]
145 fn relative_path_never_resolves_against_process_cwd() {
146 let cwd = std::env::current_dir().unwrap();
147 assert!(
148 cwd.join("Cargo.toml").exists(),
149 "test premise: CWD contains Cargo.toml"
150 );
151
152 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
153 fs::create_dir_all(&tmp).unwrap();
154 let root = tmp.to_string_lossy().to_string();
155
156 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
157 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
163 let out_parent = crate::core::pathjail::canonicalize_or_self(
164 Path::new(&out)
165 .parent()
166 .expect("resolved path has a parent"),
167 );
168 assert_eq!(
169 out_parent, canonical_root,
170 "resolved {out} must live under the project root, not the process CWD"
171 );
172 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
173 assert_ne!(
174 out_parent, canonical_cwd,
175 "resolved {out} must not resolve against the process CWD"
176 );
177
178 let _ = fs::remove_dir_all(&tmp);
179 }
180
181 #[cfg(not(feature = "no-jail"))]
186 #[test]
187 fn extra_roots_thread_through_resolve_tool_path() {
188 let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
189 let root = base.join("root");
190 let worktree = base.join("worktree");
191 fs::create_dir_all(&root).unwrap();
192 fs::create_dir_all(&worktree).unwrap();
193 let file = worktree.join("a.txt");
194 fs::write(&file, "x").unwrap();
195
196 let root_s = root.to_string_lossy().to_string();
197 let file_abs = file.to_string_lossy().to_string();
198 let extra = vec![worktree.to_string_lossy().to_string()];
199
200 let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
201 assert!(
202 out.is_ok(),
203 "extra_roots must thread through the resolver: {out:?}"
204 );
205
206 let _ = fs::remove_dir_all(&base);
207 }
208
209 #[test]
210 fn tool_context_shape_project_root_only() {
211 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
213 fs::create_dir_all(&tmp).unwrap();
214 let root = tmp.to_string_lossy().to_string();
215 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
216 assert!(out.ends_with("missing.rs"), "got {out}");
217 let _ = fs::remove_dir_all(&tmp);
218 }
219
220 #[cfg(not(windows))]
228 #[test]
229 fn single_letter_root_is_never_drive_translated_on_unix() {
230 for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
231 let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
232 Ok(p) => p,
233 Err(e) => e,
234 };
235 assert!(
236 !rendered.contains("C:/"),
237 "drive translation must not run on unix hosts (raw={raw}): {rendered}"
238 );
239 }
240 }
241}