1use std::path::{Path, PathBuf};
10
11pub use crate::core::pathutil::has_project_marker;
16
17fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
26 let start = crate::core::pathutil::safe_canonicalize_or_self(start);
27 let mut cur: Option<&Path> = Some(start.as_path());
28 while let Some(dir) = cur {
29 if dir.join(".git").exists() {
30 return Some(dir.to_path_buf());
31 }
32 cur = dir.parent();
33 }
34 None
35}
36
37pub(crate) fn shell_cwd_is_divergent_checkout(project_root: &str, shell_cwd: &str) -> bool {
46 if shell_cwd == project_root {
47 return false;
48 }
49 match (
50 nearest_git_boundary(Path::new(shell_cwd)),
51 nearest_git_boundary(Path::new(project_root)),
52 ) {
53 (Some(cwd_git), Some(root_git)) => cwd_git != root_git,
54 _ => false,
55 }
56}
57
58pub fn resolve_tool_path(
79 project_root: Option<&str>,
80 shell_cwd: Option<&str>,
81 raw: &str,
82) -> Result<String, String> {
83 resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
84}
85
86pub fn resolve_tool_path_with_roots(
93 project_root: Option<&str>,
94 shell_cwd: Option<&str>,
95 raw: &str,
96 extra_roots: &[String],
97) -> Result<String, String> {
98 let normalized = crate::core::pathutil::normalize_tool_path(raw);
99 if normalized.is_empty() || normalized == "." {
100 return Ok(normalized);
101 }
102
103 let p = Path::new(&normalized);
104 let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
105
106 let resolved: PathBuf = if p.is_absolute() {
107 PathBuf::from(&normalized)
108 } else if let Some(root) = project_root {
109 if let Some(cwd) = shell_cwd
114 && shell_cwd_is_divergent_checkout(root, cwd)
115 {
116 Path::new(cwd).join(&normalized)
117 } else {
118 let joined = Path::new(root).join(&normalized);
119 if joined.exists() {
120 joined
121 } else if let Some(cwd) = shell_cwd {
122 Path::new(cwd).join(&normalized)
123 } else {
124 joined
125 }
126 }
127 } else if let Some(cwd) = shell_cwd {
128 Path::new(cwd).join(&normalized)
129 } else {
130 Path::new(&jail_root).join(&normalized)
131 };
132
133 let jail_root_path = Path::new(&jail_root);
134 let jailed =
135 crate::core::pathjail::jail_path_with_roots(&resolved, jail_root_path, extra_roots)?;
136 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
137
138 Ok(crate::core::pathutil::normalize_tool_path(
139 &jailed.to_string_lossy().replace('\\', "/"),
140 ))
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::fs;
147
148 #[test]
149 fn empty_and_dot_pass_through() {
150 assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
151 assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
152 }
153
154 #[test]
155 fn relative_resolves_against_project_root() {
156 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
157 let _ = fs::create_dir_all(&tmp);
158 let file = tmp.join("a.txt");
159 fs::write(&file, "x").unwrap();
160 let root = tmp.to_string_lossy().to_string();
161
162 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
163 assert!(out.ends_with("a.txt"), "got {out}");
164 assert!(out.contains(&root) || Path::new(&out).is_absolute());
165
166 let _ = fs::remove_dir_all(&tmp);
167 }
168
169 #[test]
170 fn falls_back_to_shell_cwd_when_not_in_project_root() {
171 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
172 let root = base.join("root");
173 let cwd = base.join("cwd");
174 fs::create_dir_all(&root).unwrap();
175 fs::create_dir_all(&cwd).unwrap();
176 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
177
178 let out = resolve_tool_path(
179 Some(&root.to_string_lossy()),
180 Some(&cwd.to_string_lossy()),
181 "only_in_cwd.txt",
182 );
183 assert!(out.is_ok() || out.is_err());
187
188 let _ = fs::remove_dir_all(&base);
189 }
190
191 #[test]
196 fn relative_path_never_resolves_against_process_cwd() {
197 let cwd = std::env::current_dir().unwrap();
198 assert!(
199 cwd.join("Cargo.toml").exists(),
200 "test premise: CWD contains Cargo.toml"
201 );
202
203 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
204 fs::create_dir_all(&tmp).unwrap();
205 let root = tmp.to_string_lossy().to_string();
206
207 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
208 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
214 let out_parent = crate::core::pathjail::canonicalize_or_self(
215 Path::new(&out)
216 .parent()
217 .expect("resolved path has a parent"),
218 );
219 assert_eq!(
220 out_parent, canonical_root,
221 "resolved {out} must live under the project root, not the process CWD"
222 );
223 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
224 assert_ne!(
225 out_parent, canonical_cwd,
226 "resolved {out} must not resolve against the process CWD"
227 );
228
229 let _ = fs::remove_dir_all(&tmp);
230 }
231
232 #[cfg(not(feature = "no-jail"))]
237 #[test]
238 fn extra_roots_thread_through_resolve_tool_path() {
239 let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
240 let root = base.join("root");
241 let worktree = base.join("worktree");
242 fs::create_dir_all(&root).unwrap();
243 fs::create_dir_all(&worktree).unwrap();
244 let file = worktree.join("a.txt");
245 fs::write(&file, "x").unwrap();
246
247 let root_s = root.to_string_lossy().to_string();
248 let file_abs = file.to_string_lossy().to_string();
249 let extra = vec![worktree.to_string_lossy().to_string()];
250
251 let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
252 assert!(
253 out.is_ok(),
254 "extra_roots must thread through the resolver: {out:?}"
255 );
256
257 let _ = fs::remove_dir_all(&base);
258 }
259
260 #[test]
265 fn worktree_shell_cwd_outranks_stale_project_root_copy() {
266 let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
267 let repo = base.join("repo");
268 let wt = repo.join(".claude").join("worktrees").join("fix-x");
269 fs::create_dir_all(repo.join("src")).unwrap();
270 fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(wt.join("src")).unwrap();
272 fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
274 fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
275
276 let out = resolve_tool_path(
277 Some(&repo.to_string_lossy()),
278 Some(&wt.to_string_lossy()),
279 "src/scoring.rs",
280 )
281 .unwrap();
282 assert_eq!(
283 fs::read_to_string(&out).unwrap(),
284 "fresh",
285 "must resolve into the worktree, not the stale root: {out}"
286 );
287
288 let new = resolve_tool_path(
291 Some(&repo.to_string_lossy()),
292 Some(&wt.to_string_lossy()),
293 "src/new_file.rs",
294 )
295 .unwrap();
296 assert!(
297 new.contains("worktrees"),
298 "write target must land in the worktree: {new}"
299 );
300
301 let _ = fs::remove_dir_all(&base);
302 }
303
304 #[test]
308 fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
309 let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
310 let repo = base.join("repo");
311 fs::create_dir_all(repo.join("rust").join("src")).unwrap();
312 fs::create_dir_all(repo.join(".git")).unwrap();
313 fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
314 fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
315
316 assert!(!shell_cwd_is_divergent_checkout(
317 &repo.to_string_lossy(),
318 &repo.join("rust").to_string_lossy(),
319 ));
320
321 let out = resolve_tool_path(
322 Some(&repo.to_string_lossy()),
323 Some(&repo.join("rust").to_string_lossy()),
324 "rust/src/main.rs",
325 )
326 .unwrap();
327 assert_eq!(
328 fs::read_to_string(&out).unwrap(),
329 "root copy",
330 "same-checkout cwd must not divert resolution: {out}"
331 );
332
333 let _ = fs::remove_dir_all(&base);
334 }
335
336 #[test]
339 fn gitless_shell_cwd_gives_no_divergence_signal() {
340 let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
341 let repo = base.join("repo");
342 let scratch = base.join("scratch");
343 fs::create_dir_all(repo.join(".git")).unwrap();
344 fs::create_dir_all(&scratch).unwrap();
345 fs::write(repo.join("a.txt"), "root").unwrap();
346
347 assert!(!shell_cwd_is_divergent_checkout(
348 &repo.to_string_lossy(),
349 &scratch.to_string_lossy(),
350 ));
351
352 let _ = fs::remove_dir_all(&base);
353 }
354
355 #[test]
356 fn tool_context_shape_project_root_only() {
357 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
359 fs::create_dir_all(&tmp).unwrap();
360 let root = tmp.to_string_lossy().to_string();
361 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
362 assert!(out.ends_with("missing.rs"), "got {out}");
363 let _ = fs::remove_dir_all(&tmp);
364 }
365
366 #[cfg(not(windows))]
374 #[test]
375 fn single_letter_root_is_never_drive_translated_on_unix() {
376 for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
377 let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
378 Ok(p) => p,
379 Err(e) => e,
380 };
381 assert!(
382 !rendered.contains("C:/"),
383 "drive translation must not run on unix hosts (raw={raw}): {rendered}"
384 );
385 }
386 }
387}