lean_ctx/core/mcp_roots/
derive.rs1use std::path::Path;
7
8use super::has_project_marker;
9
10fn git_toplevel_from(dir: &Path) -> Option<String> {
11 std::process::Command::new("git")
12 .args(["rev-parse", "--show-toplevel"])
13 .current_dir(dir)
14 .stdout(std::process::Stdio::piped())
15 .stderr(std::process::Stdio::null())
16 .output()
17 .ok()
18 .and_then(|o| {
19 if o.status.success() {
20 String::from_utf8(o.stdout)
21 .ok()
22 .map(|s| s.trim().to_string())
23 } else {
24 None
25 }
26 })
27}
28
29pub fn derive_project_root_from_cwd() -> Option<String> {
30 derive_project_root_from(&std::env::current_dir().ok()?)
31}
32
33pub fn derive_project_root_from(cwd: &Path) -> Option<String> {
36 let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
37
38 if crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
39 return git_toplevel_from(&canonical);
40 }
41
42 if has_project_marker(&canonical) {
43 return Some(canonical.to_string_lossy().to_string());
44 }
45
46 if let Some(git_root) = git_toplevel_from(&canonical) {
47 return Some(git_root);
48 }
49
50 if let Some(root) = detect_multi_root_workspace(&canonical) {
51 return Some(root);
52 }
53
54 if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
58 tracing::info!(
59 "No project markers found — using CWD as project root: {}",
60 canonical.display()
61 );
62 return Some(canonical.to_string_lossy().to_string());
63 }
64
65 None
66}
67
68fn detect_multi_root_workspace(dir: &Path) -> Option<String> {
72 if crate::core::pathutil::is_broad_or_unsafe_root(dir)
75 || crate::core::pathutil::is_tcc_sensitive_home_dir(dir)
76 {
77 return None;
78 }
79 let entries = std::fs::read_dir(dir).ok()?;
80 let mut child_projects: Vec<String> = Vec::new();
81
82 for entry in entries.flatten() {
83 let path = entry.path();
84 if path.is_dir() && has_project_marker(&path) {
85 let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
86 child_projects.push(canonical.to_string_lossy().to_string());
87 }
88 }
89
90 if child_projects.len() >= 2 {
91 crate::core::runtime_flags::add_allow_paths(
92 child_projects
93 .iter()
94 .map(std::path::PathBuf::from)
95 .collect(),
96 );
97 tracing::info!(
98 "Multi-root workspace detected at {}: auto-allowing {} child projects",
99 dir.display(),
100 child_projects.len()
101 );
102 return Some(dir.to_string_lossy().to_string());
103 }
104
105 None
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn project_markers_detected() {
114 let tmp = tempfile::tempdir().unwrap();
115 let root = tmp.path().join("myproject");
116 std::fs::create_dir_all(&root).unwrap();
117 assert!(!has_project_marker(&root));
118
119 std::fs::create_dir(root.join(".git")).unwrap();
120 assert!(has_project_marker(&root));
121 }
122
123 #[test]
124 fn detect_multi_root_workspace_with_child_projects() {
125 if crate::test_env::run_in_own_process(
130 "core::mcp_roots::derive::tests::detect_multi_root_workspace_with_child_projects",
131 &[],
132 ) {
133 return;
134 }
135 let _guard = crate::core::data_dir::test_env_lock();
136 let tmp = tempfile::tempdir().unwrap();
137 let workspace = tmp.path().join("workspace");
138 std::fs::create_dir_all(&workspace).unwrap();
139
140 let proj_a = workspace.join("project-a");
141 let proj_b = workspace.join("project-b");
142 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
143 std::fs::create_dir_all(&proj_b).unwrap();
144 std::fs::write(proj_b.join("package.json"), "{}").unwrap();
145
146 let result = detect_multi_root_workspace(&workspace);
147 assert!(
148 result.is_some(),
149 "should detect workspace with 2 child projects"
150 );
151
152 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
153 }
154
155 #[test]
156 fn detect_multi_root_workspace_returns_none_for_single_project() {
157 let tmp = tempfile::tempdir().unwrap();
158 let workspace = tmp.path().join("workspace");
159 std::fs::create_dir_all(&workspace).unwrap();
160
161 let proj_a = workspace.join("project-a");
162 std::fs::create_dir_all(proj_a.join(".git")).unwrap();
163
164 let result = detect_multi_root_workspace(&workspace);
165 assert!(
166 result.is_none(),
167 "should not detect workspace with only 1 child project"
168 );
169 }
170
171 #[test]
172 fn derive_project_root_falls_back_to_bare_cwd() {
173 let tmp = tempfile::tempdir().unwrap();
174 let bare = tmp.path().join("bare-dir");
175 std::fs::create_dir_all(&bare).unwrap();
176
177 let result = {
178 let _cwd = crate::test_env::CurrentDirGuard::enter(&bare);
179 derive_project_root_from_cwd()
180 };
181
182 assert!(result.is_some(), "bare dir should produce a project root");
183 let root = result.unwrap();
184 assert!(
185 root.contains("bare-dir"),
186 "fallback should use the bare dir path"
187 );
188 }
189}