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