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
72fn 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> {
81 let paths: Vec<String> = uris
82 .iter()
83 .filter_map(|u| uri_to_path(u))
84 .filter(|p| Path::new(p).is_dir())
85 .collect();
86
87 if paths.is_empty() {
88 return None;
89 }
90
91 for p in &paths {
92 if has_project_marker(Path::new(p)) {
93 return Some(p.clone());
94 }
95 }
96
97 Some(paths[0].clone())
98}
99
100pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
102 uris.iter()
103 .filter_map(|u| uri_to_path(u))
104 .filter(|p| Path::new(p).is_dir())
105 .collect()
106}
107
108pub fn root_from_env() -> Option<String> {
111 for var in ["LEAN_CTX_PROJECT_ROOT", "CLAUDE_PROJECT_DIR"] {
112 if let Ok(val) = std::env::var(var) {
113 let trimmed = val.trim().to_string();
114 if !trimmed.is_empty() && Path::new(&trimmed).is_dir() {
115 return Some(trimmed);
116 }
117 }
118 }
119 None
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[cfg(unix)]
127 #[test]
128 fn parse_file_uri_unix() {
129 assert_eq!(
130 uri_to_path("file:///home/user/project"),
131 Some("/home/user/project".to_string())
132 );
133 }
134
135 #[cfg(unix)]
136 #[test]
137 fn parse_file_uri_windows() {
138 assert_eq!(
139 uri_to_path("file:///C:/Users/dev/project"),
140 Some("/C:/Users/dev/project".to_string())
141 );
142 }
143
144 #[cfg(unix)]
145 #[test]
146 fn parse_file_uri_with_spaces() {
147 assert_eq!(
148 uri_to_path("file:///home/user/my%20project"),
149 Some("/home/user/my project".to_string())
150 );
151 }
152
153 #[test]
154 fn parse_non_file_uri_returns_none() {
155 assert!(uri_to_path("https://example.com").is_none());
156 assert!(uri_to_path("").is_none());
157 }
158
159 #[test]
160 fn rejects_null_bytes() {
161 assert!(uri_to_path("file:///tmp/evil%00path").is_none());
162 }
163
164 #[test]
165 fn rejects_relative_uri() {
166 assert!(uri_to_path("file://relative/path").is_none());
167 }
168
169 #[test]
170 fn canonicalizes_traversal() {
171 let tmp = tempfile::tempdir().unwrap();
172 let sub = tmp.path().join("a").join("b");
173 std::fs::create_dir_all(&sub).unwrap();
174 let traversal = format!("file://{}/a/b/../..", tmp.path().display());
175 let result = uri_to_path(&traversal);
176 assert!(result.is_some());
177 let resolved = result.unwrap();
178 assert!(
179 !resolved.contains(".."),
180 "should be canonicalized: {resolved}"
181 );
182 }
183
184 #[test]
185 fn best_root_prefers_marker() {
186 let tmp = tempfile::tempdir().unwrap();
187 let with_marker = tmp.path().join("has_git");
188 let without = tmp.path().join("plain");
189 std::fs::create_dir_all(&with_marker).unwrap();
190 std::fs::create_dir_all(&without).unwrap();
191 std::fs::create_dir(with_marker.join(".git")).unwrap();
192
193 let uris = vec![
194 format!("file://{}", without.display()),
195 format!("file://{}", with_marker.display()),
196 ];
197 let result = best_root_from_uris(&uris).unwrap();
198 assert!(result.contains("has_git"));
199 }
200
201 #[test]
202 fn best_root_falls_back_to_first_existing_dir() {
203 let tmp = tempfile::tempdir().unwrap();
204 let a = tmp.path().join("dir_a");
205 let b = tmp.path().join("dir_b");
206 std::fs::create_dir_all(&a).unwrap();
207 std::fs::create_dir_all(&b).unwrap();
208
209 let uris = vec![
210 format!("file://{}", a.display()),
211 format!("file://{}", b.display()),
212 ];
213 let result = best_root_from_uris(&uris).unwrap();
214 assert!(result.contains("dir_a"));
215 }
216
217 #[test]
218 fn best_root_skips_nonexistent() {
219 let uris = vec!["file:///nonexistent_abc_123".to_string()];
220 assert!(best_root_from_uris(&uris).is_none());
221 }
222
223 #[test]
224 fn best_root_empty_returns_none() {
225 assert!(best_root_from_uris(&[]).is_none());
226 }
227
228 #[test]
229 fn env_override_returns_none_when_unset() {
230 let _ = root_from_env();
231 }
232
233 #[test]
234 fn all_paths_from_uris() {
235 let tmp = tempfile::tempdir().unwrap();
236 let a = tmp.path().join("project_a");
237 let b = tmp.path().join("project_b");
238 std::fs::create_dir_all(&a).unwrap();
239 std::fs::create_dir_all(&b).unwrap();
240 std::fs::create_dir(a.join(".git")).unwrap();
241
242 let uris = vec![
243 format!("file://{}", a.display()),
244 format!("file://{}", b.display()),
245 ];
246
247 let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
248 assert_eq!(paths.len(), 2);
249 assert!(paths[0].contains("project_a"));
250 assert!(paths[1].contains("project_b"));
251
252 let best = best_root_from_uris(&uris).unwrap();
253 assert!(best.contains("project_a"));
254 }
255}