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 .map_err(|e| e.to_string())?;
137 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
138
139 Ok(crate::core::pathutil::normalize_tool_path(
140 &jailed.to_string_lossy().replace('\\', "/"),
141 ))
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::fs;
148
149 #[test]
150 fn empty_and_dot_pass_through() {
151 assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
152 assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
153 }
154
155 #[test]
156 fn relative_resolves_against_project_root() {
157 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
158 let _ = fs::create_dir_all(&tmp);
159 let file = tmp.join("a.txt");
160 fs::write(&file, "x").unwrap();
161 let root = tmp.to_string_lossy().to_string();
162
163 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
164 assert!(out.ends_with("a.txt"), "got {out}");
165 assert!(out.contains(&root) || Path::new(&out).is_absolute());
166
167 let _ = fs::remove_dir_all(&tmp);
168 }
169
170 #[test]
171 fn falls_back_to_shell_cwd_when_not_in_project_root() {
172 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
173 let root = base.join("root");
174 let cwd = base.join("cwd");
175 fs::create_dir_all(&root).unwrap();
176 fs::create_dir_all(&cwd).unwrap();
177 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
178
179 let out = resolve_tool_path(
180 Some(&root.to_string_lossy()),
181 Some(&cwd.to_string_lossy()),
182 "only_in_cwd.txt",
183 );
184 assert!(out.is_ok() || out.is_err());
188
189 let _ = fs::remove_dir_all(&base);
190 }
191
192 #[test]
197 fn relative_path_never_resolves_against_process_cwd() {
198 let cwd = std::env::current_dir().unwrap();
199 assert!(
200 cwd.join("Cargo.toml").exists(),
201 "test premise: CWD contains Cargo.toml"
202 );
203
204 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
205 fs::create_dir_all(&tmp).unwrap();
206 let root = tmp.to_string_lossy().to_string();
207
208 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
209 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
215 let out_parent = crate::core::pathjail::canonicalize_or_self(
216 Path::new(&out)
217 .parent()
218 .expect("resolved path has a parent"),
219 );
220 assert_eq!(
221 out_parent, canonical_root,
222 "resolved {out} must live under the project root, not the process CWD"
223 );
224 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
225 assert_ne!(
226 out_parent, canonical_cwd,
227 "resolved {out} must not resolve against the process CWD"
228 );
229
230 let _ = fs::remove_dir_all(&tmp);
231 }
232
233 #[cfg(not(feature = "no-jail"))]
238 #[test]
239 fn extra_roots_thread_through_resolve_tool_path() {
240 let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
241 let root = base.join("root");
242 let worktree = base.join("worktree");
243 fs::create_dir_all(&root).unwrap();
244 fs::create_dir_all(&worktree).unwrap();
245 let file = worktree.join("a.txt");
246 fs::write(&file, "x").unwrap();
247
248 let root_s = root.to_string_lossy().to_string();
249 let file_abs = file.to_string_lossy().to_string();
250 let extra = vec![worktree.to_string_lossy().to_string()];
251
252 let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
253 assert!(
254 out.is_ok(),
255 "extra_roots must thread through the resolver: {out:?}"
256 );
257
258 let _ = fs::remove_dir_all(&base);
259 }
260
261 #[test]
266 fn worktree_shell_cwd_outranks_stale_project_root_copy() {
267 let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
268 let repo = base.join("repo");
269 let wt = repo.join(".claude").join("worktrees").join("fix-x");
270 fs::create_dir_all(repo.join("src")).unwrap();
271 fs::create_dir_all(repo.join(".git")).unwrap(); fs::create_dir_all(wt.join("src")).unwrap();
273 fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
275 fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
276
277 let out = resolve_tool_path(
278 Some(&repo.to_string_lossy()),
279 Some(&wt.to_string_lossy()),
280 "src/scoring.rs",
281 )
282 .unwrap();
283 assert_eq!(
284 fs::read_to_string(&out).unwrap(),
285 "fresh",
286 "must resolve into the worktree, not the stale root: {out}"
287 );
288
289 let new = resolve_tool_path(
292 Some(&repo.to_string_lossy()),
293 Some(&wt.to_string_lossy()),
294 "src/new_file.rs",
295 )
296 .unwrap();
297 assert!(
298 new.contains("worktrees"),
299 "write target must land in the worktree: {new}"
300 );
301
302 let _ = fs::remove_dir_all(&base);
303 }
304
305 #[test]
309 fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
310 let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
311 let repo = base.join("repo");
312 fs::create_dir_all(repo.join("rust").join("src")).unwrap();
313 fs::create_dir_all(repo.join(".git")).unwrap();
314 fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
315 fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
316
317 assert!(!shell_cwd_is_divergent_checkout(
318 &repo.to_string_lossy(),
319 &repo.join("rust").to_string_lossy(),
320 ));
321
322 let out = resolve_tool_path(
323 Some(&repo.to_string_lossy()),
324 Some(&repo.join("rust").to_string_lossy()),
325 "rust/src/main.rs",
326 )
327 .unwrap();
328 assert_eq!(
329 fs::read_to_string(&out).unwrap(),
330 "root copy",
331 "same-checkout cwd must not divert resolution: {out}"
332 );
333
334 let _ = fs::remove_dir_all(&base);
335 }
336
337 #[test]
340 fn gitless_shell_cwd_gives_no_divergence_signal() {
341 let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
342 let repo = base.join("repo");
343 let scratch = base.join("scratch");
344 fs::create_dir_all(repo.join(".git")).unwrap();
345 fs::create_dir_all(&scratch).unwrap();
346 fs::write(repo.join("a.txt"), "root").unwrap();
347
348 assert!(!shell_cwd_is_divergent_checkout(
349 &repo.to_string_lossy(),
350 &scratch.to_string_lossy(),
351 ));
352
353 let _ = fs::remove_dir_all(&base);
354 }
355
356 #[test]
357 fn tool_context_shape_project_root_only() {
358 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
360 fs::create_dir_all(&tmp).unwrap();
361 let root = tmp.to_string_lossy().to_string();
362 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
363 assert!(out.ends_with("missing.rs"), "got {out}");
364 let _ = fs::remove_dir_all(&tmp);
365 }
366
367 #[cfg(not(windows))]
375 #[test]
376 fn single_letter_root_is_never_drive_translated_on_unix() {
377 for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
378 let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
379 Ok(p) => p,
380 Err(e) => e,
381 };
382 assert!(
383 !rendered.contains("C:/"),
384 "drive translation must not run on unix hosts (raw={raw}): {rendered}"
385 );
386 }
387 }
388}