Skip to main content

krait/detect/
project.rs

1use std::path::{Path, PathBuf};
2
3use super::language::Language;
4
5/// Markers checked at each directory level, in priority order.
6const MARKERS: &[&str] = &[
7    ".krait",
8    ".git",
9    "Cargo.toml",
10    "package.json",
11    "go.mod",
12    "CMakeLists.txt",
13];
14
15/// Walk up from `from` to find the project root by looking for known markers.
16/// Returns the directory containing the first marker found, or `from` as fallback.
17#[must_use]
18pub fn detect_project_root(from: &Path) -> PathBuf {
19    let mut current = from.to_path_buf();
20
21    loop {
22        for marker in MARKERS {
23            if current.join(marker).exists() {
24                return current;
25            }
26        }
27
28        if !current.pop() {
29            return from.to_path_buf();
30        }
31    }
32}
33
34/// Compute deterministic socket path for a project root.
35/// Format: `<tmpdir>/krait-<16-hex-chars>.sock`
36#[must_use]
37pub fn socket_path(project_root: &Path) -> PathBuf {
38    let canonical = project_root
39        .canonicalize()
40        .unwrap_or_else(|_| project_root.to_path_buf());
41
42    let hash = blake3::hash(canonical.to_string_lossy().as_bytes());
43    let hex = hash.to_hex();
44    let short = &hex[..16];
45
46    std::env::temp_dir().join(format!("krait-{short}.sock"))
47}
48
49/// Find all LSP workspace roots within a project for monorepo support.
50///
51/// Recursively walks the entire project tree (respecting `.gitignore`) to find
52/// all manifest files. Each manifest's parent directory becomes a workspace
53/// candidate. De-nests: if a parent directory already has the same language
54/// marker, child directories are skipped (e.g., Rust workspace crates don't
55/// each get their own LSP root — the workspace root covers them).
56///
57/// Returns `(Language, PathBuf)` pairs sorted by path depth (shallowest first).
58#[must_use]
59pub fn find_package_roots(project_root: &Path) -> Vec<(Language, PathBuf)> {
60    let mut candidates: Vec<(Language, PathBuf)> = Vec::new();
61
62    // Walk the entire project tree, respecting .gitignore
63    let mut builder = ignore::WalkBuilder::new(project_root);
64    builder
65        .hidden(true)
66        .git_ignore(true)
67        .git_global(false)
68        .git_exclude(true);
69
70    for entry in builder.build() {
71        let Ok(entry) = entry else { continue };
72        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
73            continue;
74        }
75        let Some(filename) = entry.path().file_name().and_then(|n| n.to_str()) else {
76            continue;
77        };
78        let Some(parent) = entry.path().parent() else {
79            continue;
80        };
81
82        // Derive workspace markers from Language::workspace_markers() — single source of truth.
83        for &lang in Language::ALL {
84            for &marker in lang.workspace_markers() {
85                if filename == marker {
86                    let pair = (lang, parent.to_path_buf());
87                    if !candidates.contains(&pair) {
88                        candidates.push(pair);
89                    }
90                }
91            }
92        }
93    }
94
95    // Sort by path depth (shallowest first) for correct de-nesting
96    candidates.sort_by_key(|(_, p)| p.components().count());
97
98    // De-nest: remove roots that are subdirectories of an existing root
99    // for the same language.
100    let mut result: Vec<(Language, PathBuf)> = Vec::new();
101    for (lang, dir) in &candidates {
102        let covered = result
103            .iter()
104            .any(|(l, r)| *l == *lang && dir.starts_with(r) && dir != r);
105        if !covered {
106            result.push((*lang, dir.clone()));
107        }
108    }
109
110    // Fix "." root problem: if root has package.json but no tsconfig.json,
111    // and sub-packages DO have tsconfig.json, skip the root JS server.
112    // The root package.json is a meta-package, not a JS/TS project.
113    let has_sub_tsconfigs = result
114        .iter()
115        .any(|(l, r)| *l == Language::TypeScript && r != project_root);
116    if has_sub_tsconfigs {
117        result.retain(|(lang, root)| {
118            if root == project_root && *lang == Language::JavaScript {
119                // Only keep root JS if it has its own tsconfig/jsconfig
120                root.join("tsconfig.json").exists() || root.join("jsconfig.json").exists()
121            } else {
122                true
123            }
124        });
125    }
126
127    result
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn detects_git_root() {
136        let dir = tempfile::tempdir().unwrap();
137        std::fs::create_dir(dir.path().join(".git")).unwrap();
138
139        let root = detect_project_root(dir.path());
140        assert_eq!(root, dir.path());
141    }
142
143    #[test]
144    fn detects_cargo_root() {
145        let dir = tempfile::tempdir().unwrap();
146        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
147
148        let root = detect_project_root(dir.path());
149        assert_eq!(root, dir.path());
150    }
151
152    #[test]
153    fn detects_krait_root() {
154        let dir = tempfile::tempdir().unwrap();
155        // Both .krait and .git exist — .krait should win (higher priority)
156        std::fs::create_dir(dir.path().join(".krait")).unwrap();
157        std::fs::create_dir(dir.path().join(".git")).unwrap();
158
159        let root = detect_project_root(dir.path());
160        assert_eq!(root, dir.path());
161    }
162
163    #[test]
164    fn nested_dir_walks_up() {
165        let dir = tempfile::tempdir().unwrap();
166        std::fs::create_dir(dir.path().join(".git")).unwrap();
167
168        let nested = dir.path().join("a").join("b").join("c");
169        std::fs::create_dir_all(&nested).unwrap();
170
171        let root = detect_project_root(&nested);
172        assert_eq!(root, dir.path());
173    }
174
175    #[test]
176    fn no_marker_returns_cwd() {
177        let dir = tempfile::tempdir().unwrap();
178        let root = detect_project_root(dir.path());
179        // Should return the input path when no marker found
180        assert!(root.exists());
181    }
182
183    #[test]
184    fn find_package_roots_simple_rust() {
185        let dir = tempfile::tempdir().unwrap();
186        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
187
188        let roots = find_package_roots(dir.path());
189        assert_eq!(roots.len(), 1);
190        assert_eq!(roots[0].0, Language::Rust);
191        assert_eq!(roots[0].1, dir.path());
192    }
193
194    #[test]
195    fn find_package_roots_monorepo_with_denesting() {
196        let dir = tempfile::tempdir().unwrap();
197        // Root: Cargo.toml (Rust workspace)
198        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
199        // Nested crate under crates/ — should be de-nested
200        let crate_dir = dir.path().join("crates/mylib");
201        std::fs::create_dir_all(&crate_dir).unwrap();
202        std::fs::write(crate_dir.join("Cargo.toml"), "").unwrap();
203        // TypeScript package — separate scope
204        let api = dir.path().join("packages/api");
205        std::fs::create_dir_all(&api).unwrap();
206        std::fs::write(api.join("tsconfig.json"), "").unwrap();
207
208        let roots = find_package_roots(dir.path());
209        let rust_roots: Vec<_> = roots.iter().filter(|(l, _)| *l == Language::Rust).collect();
210        let ts_roots: Vec<_> = roots
211            .iter()
212            .filter(|(l, _)| *l == Language::TypeScript)
213            .collect();
214
215        assert_eq!(
216            rust_roots.len(),
217            1,
218            "one Rust root (workspace covers crates)"
219        );
220        assert_eq!(ts_roots.len(), 1, "one TypeScript root");
221    }
222
223    #[test]
224    fn find_package_roots_ts_monorepo_multiple_packages() {
225        let dir = tempfile::tempdir().unwrap();
226        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
227
228        for pkg in &["api", "web", "common"] {
229            let p = dir.path().join("packages").join(pkg);
230            std::fs::create_dir_all(&p).unwrap();
231            std::fs::write(p.join("tsconfig.json"), "{}").unwrap();
232        }
233
234        let roots = find_package_roots(dir.path());
235        let ts_roots: Vec<_> = roots
236            .iter()
237            .filter(|(l, _)| *l == Language::TypeScript)
238            .collect();
239        // 3 separate TypeScript workspaces (different tsconfigs)
240        assert_eq!(ts_roots.len(), 3);
241    }
242
243    #[test]
244    fn find_package_roots_skips_root_js_when_sub_packages_have_tsconfig() {
245        let dir = tempfile::tempdir().unwrap();
246        // Root has package.json but NO tsconfig
247        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
248
249        for pkg in &["api", "web"] {
250            let p = dir.path().join("packages").join(pkg);
251            std::fs::create_dir_all(&p).unwrap();
252            std::fs::write(p.join("tsconfig.json"), "{}").unwrap();
253        }
254
255        let roots = find_package_roots(dir.path());
256        // Root JS entry should be filtered out
257        let js_at_root: Vec<_> = roots
258            .iter()
259            .filter(|(l, r)| *l == Language::JavaScript && *r == dir.path())
260            .collect();
261        assert!(
262            js_at_root.is_empty(),
263            "root package.json should be skipped when sub-packages have tsconfig"
264        );
265        // But TypeScript sub-packages should remain
266        let ts_roots: Vec<_> = roots
267            .iter()
268            .filter(|(l, _)| *l == Language::TypeScript)
269            .collect();
270        assert_eq!(ts_roots.len(), 2);
271    }
272
273    #[test]
274    fn find_package_roots_keeps_root_when_it_has_tsconfig() {
275        let dir = tempfile::tempdir().unwrap();
276        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
277        std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
278
279        let p = dir.path().join("packages/api");
280        std::fs::create_dir_all(&p).unwrap();
281        std::fs::write(p.join("tsconfig.json"), "{}").unwrap();
282
283        let roots = find_package_roots(dir.path());
284        // Root should be kept as TypeScript (has tsconfig), JS may be present too
285        let ts_at_root: Vec<_> = roots
286            .iter()
287            .filter(|(l, r)| *l == Language::TypeScript && *r == dir.path())
288            .collect();
289        assert_eq!(ts_at_root.len(), 1, "root with tsconfig should be kept");
290    }
291
292    #[test]
293    fn find_package_roots_deeply_nested_manifests() {
294        let dir = tempfile::tempdir().unwrap();
295        // Simulate a project like medusa with deeply nested packages
296        // packages/modules/providers/package.json
297        let deep = dir.path().join("packages/modules/providers");
298        std::fs::create_dir_all(&deep).unwrap();
299        std::fs::write(deep.join("package.json"), "{}").unwrap();
300        std::fs::write(deep.join("tsconfig.json"), "{}").unwrap();
301
302        // src/frontend/package.json (like meet project)
303        let frontend = dir.path().join("src/frontend");
304        std::fs::create_dir_all(&frontend).unwrap();
305        std::fs::write(frontend.join("package.json"), "{}").unwrap();
306        std::fs::write(frontend.join("tsconfig.json"), "{}").unwrap();
307
308        let roots = find_package_roots(dir.path());
309        let ts_roots: Vec<_> = roots
310            .iter()
311            .filter(|(l, _)| *l == Language::TypeScript)
312            .collect();
313
314        assert_eq!(ts_roots.len(), 2, "should find both deeply nested TS roots");
315    }
316
317    #[test]
318    fn find_package_roots_arbitrary_directory_structure() {
319        let dir = tempfile::tempdir().unwrap();
320        // Root go.mod
321        std::fs::write(dir.path().join("go.mod"), "").unwrap();
322        // Frontend at non-standard location
323        let frontend = dir.path().join("frontend");
324        std::fs::create_dir_all(&frontend).unwrap();
325        std::fs::write(frontend.join("package.json"), "{}").unwrap();
326        std::fs::write(frontend.join("tsconfig.json"), "{}").unwrap();
327
328        let roots = find_package_roots(dir.path());
329        let go_roots: Vec<_> = roots.iter().filter(|(l, _)| *l == Language::Go).collect();
330        let ts_roots: Vec<_> = roots
331            .iter()
332            .filter(|(l, _)| *l == Language::TypeScript)
333            .collect();
334
335        assert_eq!(go_roots.len(), 1, "should find Go root");
336        assert_eq!(ts_roots.len(), 1, "should find TS in frontend/");
337    }
338
339    #[test]
340    fn socket_path_is_deterministic() {
341        let dir = tempfile::tempdir().unwrap();
342        let p1 = socket_path(dir.path());
343        let p2 = socket_path(dir.path());
344        assert_eq!(p1, p2);
345    }
346
347    #[test]
348    fn socket_path_differs_per_project() {
349        let dir1 = tempfile::tempdir().unwrap();
350        let dir2 = tempfile::tempdir().unwrap();
351        let p1 = socket_path(dir1.path());
352        let p2 = socket_path(dir2.path());
353        assert_ne!(p1, p2);
354    }
355
356    #[test]
357    fn socket_path_format() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = socket_path(dir.path());
360        let name = path.file_name().unwrap().to_str().unwrap();
361        assert!(name.starts_with("krait-"));
362        assert!(Path::new(name)
363            .extension()
364            .is_some_and(|ext| ext.eq_ignore_ascii_case("sock")));
365    }
366}