1use std::path::{Path, PathBuf};
2
3use globset::Glob;
4
5use crate::lsp::registry::ServerKind;
6
7pub fn find_workspace_root<S>(file_path: &Path, markers: &[S]) -> Option<PathBuf>
8where
9 S: AsRef<str>,
10{
11 find_workspace_root_within(file_path, markers, None)
12}
13
14pub fn find_workspace_root_within<S>(
16 file_path: &Path,
17 markers: &[S],
18 project_root: Option<&Path>,
19) -> Option<PathBuf>
20where
21 S: AsRef<str>,
22{
23 let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
37
38 let start_dir = if resolved_path.is_dir() {
39 resolved_path
40 } else {
41 resolved_path.parent()?.to_path_buf()
42 };
43
44 let project_root = project_root.map(crate::inspect::job::canonicalize_normalized);
45 let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(&start_dir).ok();
49 if project_root
50 .as_ref()
51 .is_some_and(|boundary| !start_dir.starts_with(boundary))
52 {
53 return None;
54 }
55
56 let mut current = Some(start_dir.as_path());
57 while let Some(dir) = current {
58 if device_boundary
59 .as_ref()
60 .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
61 {
62 break;
63 }
64 if project_root
65 .as_ref()
66 .is_some_and(|boundary| !dir.starts_with(boundary))
67 {
68 break;
69 }
70 if markers
71 .iter()
72 .any(|marker| dir.join(marker.as_ref()).exists())
73 {
74 return Some(dir.to_path_buf());
75 }
76
77 if project_root.as_deref() == Some(dir) {
78 break;
79 }
80 current = dir.parent();
81 }
82
83 None
84}
85
86pub fn find_rust_workspace_root(file_path: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
94 let resolved_path = crate::inspect::job::canonicalize_normalized(file_path);
95 let start_dir = if resolved_path.is_dir() {
96 resolved_path
97 } else {
98 resolved_path.parent()?.to_path_buf()
99 };
100 let project_root = project_root.map(crate::inspect::job::canonicalize_normalized);
101 let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(&start_dir).ok();
104
105 if project_root
106 .as_ref()
107 .is_some_and(|boundary| !start_dir.starts_with(boundary))
108 {
109 return None;
110 }
111
112 let crate_root = nearest_cargo_manifest_dir(&start_dir, project_root.as_deref())?;
113 let mut current = Some(crate_root.as_path());
114 while let Some(dir) = current {
115 if device_boundary
116 .as_ref()
117 .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
118 {
119 break;
120 }
121 if project_root
122 .as_ref()
123 .is_some_and(|boundary| !dir.starts_with(boundary))
124 {
125 break;
126 }
127 if cargo_workspace_contains_crate(dir, &crate_root) {
128 return Some(crate::inspect::job::canonicalize_normalized(dir));
129 }
130 if project_root.as_deref() == Some(dir) {
131 break;
132 }
133 current = dir.parent();
134 }
135
136 None
137}
138
139fn nearest_cargo_manifest_dir(start_dir: &Path, project_root: Option<&Path>) -> Option<PathBuf> {
140 let device_boundary = crate::walk_boundary::DeviceBoundary::for_root(start_dir).ok();
141 let mut current = Some(start_dir);
142 while let Some(dir) = current {
143 if device_boundary
144 .as_ref()
145 .is_some_and(|boundary| !boundary.should_descend(dir).unwrap_or(false))
146 {
147 break;
148 }
149 if project_root.is_some_and(|boundary| !dir.starts_with(boundary)) {
150 break;
151 }
152 if dir.join("Cargo.toml").is_file() {
153 return Some(dir.to_path_buf());
154 }
155 if project_root == Some(dir) {
156 break;
157 }
158 current = dir.parent();
159 }
160 None
161}
162
163fn cargo_workspace_contains_crate(workspace_root: &Path, crate_root: &Path) -> bool {
164 let Ok(contents) = std::fs::read_to_string(workspace_root.join("Cargo.toml")) else {
165 return false;
166 };
167 let Ok(manifest) = contents.parse::<toml::Value>() else {
168 return false;
169 };
170 let Some(workspace) = manifest.get("workspace").and_then(toml::Value::as_table) else {
171 return false;
172 };
173
174 if workspace_root == crate_root {
175 return true;
176 }
177
178 let Ok(crate_relative_path) = crate_root.strip_prefix(workspace_root) else {
179 return false;
180 };
181 if workspace
182 .get("exclude")
183 .and_then(toml::Value::as_array)
184 .is_some_and(|patterns| {
185 patterns
186 .iter()
187 .filter_map(toml::Value::as_str)
188 .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path))
189 })
190 {
191 return false;
192 }
193
194 match workspace.get("members").and_then(toml::Value::as_array) {
195 Some(patterns) => patterns
196 .iter()
197 .filter_map(toml::Value::as_str)
198 .any(|pattern| cargo_member_pattern_matches(pattern, crate_relative_path)),
199 None => true,
204 }
205}
206
207fn cargo_member_pattern_matches(pattern: &str, crate_relative_path: &Path) -> bool {
208 Glob::new(pattern.trim())
209 .map(|glob| glob.compile_matcher().is_match(crate_relative_path))
210 .unwrap_or(false)
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub struct ServerKey {
217 pub kind: ServerKind,
218 pub root: PathBuf,
219}
220
221#[cfg(test)]
222mod tests {
223 use std::fs;
224 use std::path::PathBuf;
225
226 use tempfile::tempdir;
227
228 use super::{
229 find_rust_workspace_root, find_workspace_root, find_workspace_root_within, ServerKey,
230 };
231 use crate::inspect::job::canonicalize_normalized;
232 use crate::lsp::registry::ServerKind;
233
234 #[test]
235 fn test_find_root_with_cargo_toml() {
236 let temp_dir = tempdir().unwrap();
237 let root = temp_dir.path().join("workspace");
238 let src_dir = root.join("src");
239 let file = src_dir.join("lib.rs");
240
241 fs::create_dir_all(&src_dir).unwrap();
242 fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
243 fs::write(&file, "fn main() {}\n").unwrap();
244
245 let expected_root = crate::inspect::job::canonicalize_normalized(&root);
249 assert_eq!(
250 find_workspace_root(&file, &["Cargo.toml"]),
251 Some(expected_root)
252 );
253 }
254
255 #[test]
256 fn test_find_root_nested() {
257 let temp_dir = tempdir().unwrap();
258 let repo_root = temp_dir.path().join("repo");
259 let crate_root = repo_root.join("crates").join("foo");
260 let src_dir = crate_root.join("src");
261 let file = src_dir.join("lib.rs");
262
263 fs::create_dir_all(&src_dir).unwrap();
264 fs::write(repo_root.join("Cargo.toml"), "[workspace]\n").unwrap();
265 fs::write(crate_root.join("Cargo.toml"), "[package]\nname = \"foo\"\n").unwrap();
266 fs::write(&file, "fn main() {}\n").unwrap();
267
268 let expected_root = crate::inspect::job::canonicalize_normalized(&crate_root);
269 assert_eq!(
270 find_workspace_root(&file, &["Cargo.toml"]),
271 Some(expected_root)
272 );
273 }
274
275 #[test]
276 fn test_find_root_none() {
277 let temp_dir = tempdir().unwrap();
278 let src_dir = temp_dir.path().join("src");
279 let file = src_dir.join("main.rs");
280
281 fs::create_dir_all(&src_dir).unwrap();
282 fs::write(&file, "fn main() {}\n").unwrap();
283
284 assert_eq!(find_workspace_root(&file, &["Cargo.toml"]), None);
285 }
286
287 #[test]
288 fn test_find_root_multiple_markers() {
289 let temp_dir = tempdir().unwrap();
290 let root = temp_dir.path().join("web");
291 let src_dir = root.join("src");
292 let file = src_dir.join("index.ts");
293
294 fs::create_dir_all(&src_dir).unwrap();
295 fs::write(root.join("tsconfig.json"), "{}\n").unwrap();
296 fs::create_dir(root.join("package.json")).unwrap();
297 fs::write(&file, "export {};\n").unwrap();
298
299 let expected_root = crate::inspect::job::canonicalize_normalized(&root);
300 assert_eq!(
301 find_workspace_root(&file, &["tsconfig.json", "package.json"]),
302 Some(expected_root)
303 );
304 }
305
306 #[test]
307 fn test_server_key_equality() {
308 let root = PathBuf::from("/tmp/workspace");
309 let same = ServerKey {
310 kind: ServerKind::Rust,
311 root: root.clone(),
312 };
313 let equal = ServerKey {
314 kind: ServerKind::Rust,
315 root,
316 };
317 let different = ServerKey {
318 kind: ServerKind::Rust,
319 root: PathBuf::from("/tmp/other"),
320 };
321
322 assert_eq!(same, equal);
323 assert_ne!(same, different);
324 }
325
326 #[test]
343 fn test_find_root_strips_windows_verbatim_prefix() {
344 let temp_dir = tempdir().unwrap();
345 let root = temp_dir.path().join("workspace");
346 let src_dir = root.join("src");
347 let nested = src_dir.join("deep").join("lib.rs");
348
349 fs::create_dir_all(nested.parent().unwrap()).unwrap();
350 fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
351 fs::write(&nested, "fn main() {}\n").unwrap();
352
353 let found = find_workspace_root(&nested, &["Cargo.toml"]).expect("root found");
354
355 let display = found.to_string_lossy();
357 assert!(
358 !display.starts_with("\\\\?\\"),
359 "workspace root must not carry a Windows verbatim prefix: {display}"
360 );
361
362 let expected = canonicalize_normalized(&root);
369 assert_eq!(found, expected);
370 }
371
372 #[test]
373 fn rust_workspace_root_prefers_owning_workspace_over_member_manifest() {
374 let temp_dir = tempdir().unwrap();
375 let workspace = temp_dir.path().join("workspace");
376 let crate_root = workspace.join("crates").join("member");
377 let source = crate_root.join("src").join("lib.rs");
378
379 fs::create_dir_all(source.parent().unwrap()).unwrap();
380 fs::write(
381 workspace.join("Cargo.toml"),
382 "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
383 )
384 .unwrap();
385 fs::write(
386 crate_root.join("Cargo.toml"),
387 "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
388 )
389 .unwrap();
390 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
391
392 assert_eq!(
393 find_rust_workspace_root(&source, Some(&workspace)),
394 Some(canonicalize_normalized(&workspace))
395 );
396 }
397
398 #[test]
399 fn rust_workspace_root_keeps_crate_excluded_from_parent_workspace_standalone() {
400 let temp_dir = tempdir().unwrap();
401 let workspace = temp_dir.path().join("workspace");
402 let member_root = workspace.join("crates").join("member");
403 let standalone_root = workspace.join("tools").join("standalone");
404 let source = standalone_root.join("src").join("lib.rs");
405
406 fs::create_dir_all(member_root.join("src")).unwrap();
407 fs::create_dir_all(source.parent().unwrap()).unwrap();
408 fs::write(
409 workspace.join("Cargo.toml"),
410 "[workspace]\nmembers = [\"crates/member\"]\nresolver = \"2\"\n",
411 )
412 .unwrap();
413 fs::write(
414 member_root.join("Cargo.toml"),
415 "[package]\nname = \"member\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
416 )
417 .unwrap();
418 fs::write(
419 standalone_root.join("Cargo.toml"),
420 "[package]\nname = \"standalone\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
421 )
422 .unwrap();
423 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
424
425 assert_eq!(
426 find_rust_workspace_root(&source, Some(&workspace)),
427 None,
428 "the parent workspace does not list this crate"
429 );
430 }
431
432 #[test]
433 fn rust_workspace_root_does_not_walk_above_project_root() {
434 let temp_dir = tempdir().unwrap();
435 let outer_workspace = temp_dir.path().join("outer-workspace");
436 let project_root = outer_workspace.join("nested-project");
437 let crate_root = project_root.join("crate");
438 let source = crate_root.join("src").join("lib.rs");
439
440 fs::create_dir_all(source.parent().unwrap()).unwrap();
441 fs::write(
442 outer_workspace.join("Cargo.toml"),
443 "[workspace]\nmembers = [\"nested-project/crate\"]\nresolver = \"2\"\n",
444 )
445 .unwrap();
446 fs::write(
447 crate_root.join("Cargo.toml"),
448 "[package]\nname = \"nested\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
449 )
450 .unwrap();
451 fs::write(&source, "pub fn answer() -> u32 { 42 }\n").unwrap();
452 let loose_source = project_root.join("loose.rs");
453 fs::write(&loose_source, "pub fn loose() {}\n").unwrap();
454
455 assert_eq!(
456 find_rust_workspace_root(&source, Some(&project_root)),
457 None,
458 "the enclosing workspace belongs to a different session root"
459 );
460 assert_eq!(
461 find_workspace_root_within(&loose_source, &["Cargo.toml"], Some(&project_root)),
462 None,
463 "marker lookup must not cross the session project root"
464 );
465 }
466}