Skip to main content

lean_ctx/server/
roots.rs

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
16/// Parse a `file://` URI to a validated local path string.
17/// Rejects non-file URIs, null bytes, `..` traversal, and non-directory paths.
18/// Returns a canonicalized absolute path.
19pub 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
76/// Select the best project root from MCP client roots.
77/// Only considers paths that are existing directories.
78/// Prefers roots with project markers (.git, Cargo.toml, etc.).
79/// Falls back to the first valid directory if none have markers.
80pub 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
100/// Filter and validate URIs to existing directories only.
101pub 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
108/// Detect project root from IDE-specific environment variables.
109/// Priority: LEAN_CTX_PROJECT_ROOT > CLAUDE_PROJECT_DIR
110pub 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}