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 let normalized = crate::core::pathutil::normalize_tool_path(raw);
43 if normalized.is_empty() || normalized == "." {
44 return Ok(normalized);
45 }
46
47 let p = Path::new(&normalized);
48 let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
49
50 let resolved: PathBuf = if p.is_absolute() {
51 PathBuf::from(&normalized)
52 } else if let Some(root) = project_root {
53 let joined = Path::new(root).join(&normalized);
54 if joined.exists() {
55 joined
56 } else if let Some(cwd) = shell_cwd {
57 Path::new(cwd).join(&normalized)
58 } else {
59 Path::new(root).join(&normalized)
60 }
61 } else if let Some(cwd) = shell_cwd {
62 Path::new(cwd).join(&normalized)
63 } else {
64 Path::new(&jail_root).join(&normalized)
65 };
66
67 let jail_root_path = Path::new(&jail_root);
68 let jailed = crate::core::pathjail::jail_path(&resolved, jail_root_path)?;
69 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
70
71 Ok(crate::core::pathutil::normalize_tool_path(
72 &jailed.to_string_lossy().replace('\\', "/"),
73 ))
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use std::fs;
80
81 #[test]
82 fn empty_and_dot_pass_through() {
83 assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
84 assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
85 }
86
87 #[test]
88 fn relative_resolves_against_project_root() {
89 let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
90 let _ = fs::create_dir_all(&tmp);
91 let file = tmp.join("a.txt");
92 fs::write(&file, "x").unwrap();
93 let root = tmp.to_string_lossy().to_string();
94
95 let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
96 assert!(out.ends_with("a.txt"), "got {out}");
97 assert!(out.contains(&root) || Path::new(&out).is_absolute());
98
99 let _ = fs::remove_dir_all(&tmp);
100 }
101
102 #[test]
103 fn falls_back_to_shell_cwd_when_not_in_project_root() {
104 let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
105 let root = base.join("root");
106 let cwd = base.join("cwd");
107 fs::create_dir_all(&root).unwrap();
108 fs::create_dir_all(&cwd).unwrap();
109 fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
110
111 let out = resolve_tool_path(
112 Some(&root.to_string_lossy()),
113 Some(&cwd.to_string_lossy()),
114 "only_in_cwd.txt",
115 );
116 assert!(out.is_ok() || out.is_err());
120
121 let _ = fs::remove_dir_all(&base);
122 }
123
124 #[test]
129 fn relative_path_never_resolves_against_process_cwd() {
130 let cwd = std::env::current_dir().unwrap();
131 assert!(
132 cwd.join("Cargo.toml").exists(),
133 "test premise: CWD contains Cargo.toml"
134 );
135
136 let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
137 fs::create_dir_all(&tmp).unwrap();
138 let root = tmp.to_string_lossy().to_string();
139
140 let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
141 let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
147 let out_parent = crate::core::pathjail::canonicalize_or_self(
148 Path::new(&out)
149 .parent()
150 .expect("resolved path has a parent"),
151 );
152 assert_eq!(
153 out_parent, canonical_root,
154 "resolved {out} must live under the project root, not the process CWD"
155 );
156 let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
157 assert_ne!(
158 out_parent, canonical_cwd,
159 "resolved {out} must not resolve against the process CWD"
160 );
161
162 let _ = fs::remove_dir_all(&tmp);
163 }
164
165 #[test]
166 fn tool_context_shape_project_root_only() {
167 let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
169 fs::create_dir_all(&tmp).unwrap();
170 let root = tmp.to_string_lossy().to_string();
171 let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
172 assert!(out.ends_with("missing.rs"), "got {out}");
173 let _ = fs::remove_dir_all(&tmp);
174 }
175}