1use std::path::{Path, PathBuf};
2
3use super::language::Language;
4
5const MARKERS: &[&str] = &[
7 ".krait",
8 ".git",
9 "Cargo.toml",
10 "package.json",
11 "go.mod",
12 "CMakeLists.txt",
13];
14
15#[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#[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#[must_use]
59pub fn find_package_roots(project_root: &Path) -> Vec<(Language, PathBuf)> {
60 let mut candidates: Vec<(Language, PathBuf)> = Vec::new();
61
62 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 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 candidates.sort_by_key(|(_, p)| p.components().count());
97
98 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 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 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 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 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 std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
199 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 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 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 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 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 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 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 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 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 std::fs::write(dir.path().join("go.mod"), "").unwrap();
322 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}