agentmux_srv\backend\lsp/
workspace.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Workspace-root detection for LSP. Walks up from a file's directory
5// looking for project-marker files (`.git`, `Cargo.toml`, `package.json`,
6// `go.mod`, `pyproject.toml`). Falls back to the file's parent dir if
7// none found.
8
9use std::path::{Path, PathBuf};
10
11/// Project-marker files we treat as a workspace root.
12/// Order doesn't matter — we return the *closest* ancestor that has any
13/// of these (walking up from the file's dir).
14const MARKERS: &[&str] = &[
15    ".git",
16    "Cargo.toml",
17    "package.json",
18    "go.mod",
19    "pyproject.toml",
20    "deno.json",
21    "deno.jsonc",
22    "tsconfig.json",
23];
24
25pub fn detect_workspace_root(file_path: &Path) -> PathBuf {
26    let mut current = file_path.parent();
27    while let Some(dir) = current {
28        for marker in MARKERS {
29            if dir.join(marker).exists() {
30                return dir.to_path_buf();
31            }
32        }
33        current = dir.parent();
34    }
35    // Fall back to the file's parent (or "." if even that's missing).
36    file_path
37        .parent()
38        .unwrap_or_else(|| Path::new("."))
39        .to_path_buf()
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use std::fs;
46
47    #[test]
48    fn falls_back_to_parent_dir_when_no_marker() {
49        let tmp = tempfile::tempdir().unwrap();
50        let file = tmp.path().join("loose.txt");
51        fs::write(&file, "").unwrap();
52        // No markers anywhere — root is the temp dir
53        assert_eq!(detect_workspace_root(&file), tmp.path());
54    }
55
56    #[test]
57    fn finds_git_root_up_the_tree() {
58        let tmp = tempfile::tempdir().unwrap();
59        let root = tmp.path();
60        fs::create_dir(root.join(".git")).unwrap();
61        let sub = root.join("a/b/c");
62        fs::create_dir_all(&sub).unwrap();
63        let file = sub.join("file.rs");
64        fs::write(&file, "").unwrap();
65        assert_eq!(detect_workspace_root(&file), root);
66    }
67
68    #[test]
69    fn finds_cargo_root() {
70        let tmp = tempfile::tempdir().unwrap();
71        let root = tmp.path();
72        fs::write(root.join("Cargo.toml"), "").unwrap();
73        let sub = root.join("src");
74        fs::create_dir(&sub).unwrap();
75        let file = sub.join("main.rs");
76        fs::write(&file, "").unwrap();
77        assert_eq!(detect_workspace_root(&file), root);
78    }
79
80    #[test]
81    fn closest_ancestor_wins_when_nested_markers() {
82        // Outer Cargo.toml, inner package.json — file inside inner should
83        // resolve to inner.
84        let tmp = tempfile::tempdir().unwrap();
85        let outer = tmp.path();
86        fs::write(outer.join("Cargo.toml"), "").unwrap();
87        let inner = outer.join("frontend");
88        fs::create_dir(&inner).unwrap();
89        fs::write(inner.join("package.json"), "").unwrap();
90        let file = inner.join("src/index.ts");
91        fs::create_dir(inner.join("src")).unwrap();
92        fs::write(&file, "").unwrap();
93        assert_eq!(detect_workspace_root(&file), inner);
94    }
95}