1use std::path::Path;
2
3pub fn uri_to_path(uri: &str) -> Option<String> {
7 let raw = uri.strip_prefix("file://")?;
8 if raw.contains("%00") {
9 return None;
10 }
11 let decoded = percent_decode(raw);
12 if decoded.is_empty() || decoded.contains('\0') {
13 return None;
14 }
15 let path = Path::new(&decoded);
16 if !path.is_absolute() {
17 return None;
18 }
19 let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
20 let s = canonical.to_string_lossy().to_string();
21 if s.is_empty() {
22 return None;
23 }
24 Some(s)
25}
26
27fn percent_decode(s: &str) -> String {
28 let mut out = String::with_capacity(s.len());
29 let mut chars = s.bytes();
30 while let Some(b) = chars.next() {
31 if b == b'%' {
32 let hi = chars.next().and_then(hex_val);
33 let lo = chars.next().and_then(hex_val);
34 if let (Some(h), Some(l)) = (hi, lo) {
35 let byte = h << 4 | l;
36 if byte == 0 {
37 continue;
38 }
39 out.push(byte as char);
40 } else {
41 out.push('%');
42 }
43 } else {
44 out.push(b as char);
45 }
46 }
47 out
48}
49
50fn hex_val(b: u8) -> Option<u8> {
51 match b {
52 b'0'..=b'9' => Some(b - b'0'),
53 b'a'..=b'f' => Some(b - b'a' + 10),
54 b'A'..=b'F' => Some(b - b'A' + 10),
55 _ => None,
56 }
57}
58
59pub(super) fn has_project_marker(dir: &Path) -> bool {
60 crate::core::pathutil::has_project_marker(dir)
61}
62
63pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
70 let paths: Vec<String> = uris
71 .iter()
72 .filter_map(|u| uri_to_path(u))
73 .filter(|p| Path::new(p).is_dir())
74 .collect();
75
76 if paths.is_empty() {
77 return None;
78 }
79
80 for p in &paths {
81 if has_project_marker(Path::new(p)) {
82 return Some(p.clone());
83 }
84 }
85
86 paths
90 .into_iter()
91 .find(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
92}
93
94pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
96 uris.iter()
97 .filter_map(|u| uri_to_path(u))
98 .filter(|p| Path::new(p).is_dir())
99 .collect()
100}
101
102pub fn root_from_env() -> Option<String> {
105 for var in ["LEAN_CTX_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"] {
106 if let Ok(val) = std::env::var(var) {
107 let trimmed = val.trim().to_string();
108 if !trimmed.is_empty()
109 && Path::new(&trimmed).is_dir()
110 && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
111 {
112 return Some(trimmed);
113 }
114 }
115 }
116 None
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[cfg(unix)]
124 #[test]
125 fn parse_file_uri_unix() {
126 assert_eq!(
127 uri_to_path("file:///home/user/project"),
128 Some("/home/user/project".to_string())
129 );
130 }
131
132 #[cfg(unix)]
133 #[test]
134 fn parse_file_uri_windows() {
135 assert_eq!(
136 uri_to_path("file:///C:/Users/dev/project"),
137 Some("/C:/Users/dev/project".to_string())
138 );
139 }
140
141 #[cfg(unix)]
142 #[test]
143 fn parse_file_uri_with_spaces() {
144 assert_eq!(
145 uri_to_path("file:///home/user/my%20project"),
146 Some("/home/user/my project".to_string())
147 );
148 }
149
150 #[test]
151 fn parse_non_file_uri_returns_none() {
152 assert!(uri_to_path("https://example.com").is_none());
153 assert!(uri_to_path("").is_none());
154 }
155
156 #[test]
157 fn rejects_null_bytes() {
158 assert!(uri_to_path("file:///tmp/evil%00path").is_none());
159 }
160
161 #[test]
162 fn rejects_relative_uri() {
163 assert!(uri_to_path("file://relative/path").is_none());
164 }
165
166 #[test]
167 fn canonicalizes_traversal() {
168 let tmp = tempfile::tempdir().unwrap();
169 let sub = tmp.path().join("a").join("b");
170 std::fs::create_dir_all(&sub).unwrap();
171 let traversal = format!("file://{}/a/b/../..", tmp.path().display());
172 let result = uri_to_path(&traversal);
173 assert!(result.is_some());
174 let resolved = result.unwrap();
175 assert!(
176 !resolved.contains(".."),
177 "should be canonicalized: {resolved}"
178 );
179 }
180
181 #[test]
182 fn best_root_prefers_marker() {
183 let tmp = tempfile::tempdir().unwrap();
184 let with_marker = tmp.path().join("has_git");
185 let without = tmp.path().join("plain");
186 std::fs::create_dir_all(&with_marker).unwrap();
187 std::fs::create_dir_all(&without).unwrap();
188 std::fs::create_dir(with_marker.join(".git")).unwrap();
189
190 let uris = vec![
191 format!("file://{}", without.display()),
192 format!("file://{}", with_marker.display()),
193 ];
194 let result = best_root_from_uris(&uris).unwrap();
195 assert!(result.contains("has_git"));
196 }
197
198 #[test]
199 fn best_root_falls_back_to_first_existing_dir() {
200 let tmp = tempfile::tempdir().unwrap();
201 let a = tmp.path().join("dir_a");
202 let b = tmp.path().join("dir_b");
203 std::fs::create_dir_all(&a).unwrap();
204 std::fs::create_dir_all(&b).unwrap();
205
206 let uris = vec![
207 format!("file://{}", a.display()),
208 format!("file://{}", b.display()),
209 ];
210 let result = best_root_from_uris(&uris).unwrap();
211 assert!(result.contains("dir_a"));
212 }
213
214 #[test]
215 fn best_root_skips_nonexistent() {
216 let uris = vec!["file:///nonexistent_abc_123".to_string()];
217 assert!(best_root_from_uris(&uris).is_none());
218 }
219
220 #[test]
221 fn best_root_empty_returns_none() {
222 assert!(best_root_from_uris(&[]).is_none());
223 }
224
225 #[test]
226 fn env_override_returns_none_when_unset() {
227 let _ = root_from_env();
228 }
229
230 #[test]
231 fn best_root_rejects_home_without_marker() {
232 if let Some(home) = dirs::home_dir() {
235 let uris = vec![format!("file://{}", home.display())];
236 assert_eq!(
237 best_root_from_uris(&uris),
238 None,
239 "HOME must never be accepted as a marker-less project root"
240 );
241 }
242 }
243
244 #[test]
245 fn best_root_prefers_safe_dir_over_home() {
246 if let Some(home) = dirs::home_dir() {
247 let tmp = tempfile::tempdir().unwrap();
248 let safe = tmp.path().join("real_project");
249 std::fs::create_dir_all(&safe).unwrap();
250 let uris = vec![
251 format!("file://{}", home.display()),
252 format!("file://{}", safe.display()),
253 ];
254 let result = best_root_from_uris(&uris).unwrap();
255 assert!(result.contains("real_project"));
256 }
257 }
258
259 #[test]
260 fn best_root_rejects_filesystem_root() {
261 let uris = vec!["file:///".to_string()];
262 assert!(best_root_from_uris(&uris).is_none());
263 }
264
265 #[test]
266 fn all_paths_from_uris() {
267 let tmp = tempfile::tempdir().unwrap();
268 let a = tmp.path().join("project_a");
269 let b = tmp.path().join("project_b");
270 std::fs::create_dir_all(&a).unwrap();
271 std::fs::create_dir_all(&b).unwrap();
272 std::fs::create_dir(a.join(".git")).unwrap();
273
274 let uris = vec![
275 format!("file://{}", a.display()),
276 format!("file://{}", b.display()),
277 ];
278
279 let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
280 assert_eq!(paths.len(), 2);
281 assert!(paths[0].contains("project_a"));
282 assert!(paths[1].contains("project_b"));
283
284 let best = best_root_from_uris(&uris).unwrap();
285 assert!(best.contains("project_a"));
286 }
287}