1use anyhow::{Context, Result, bail};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone)]
5pub struct ProjectRoot {
6 root: PathBuf,
7}
8
9const ROOT_MARKERS: &[&str] = &[
10 ".git",
11 ".codelens",
12 "build.gradle.kts",
13 "build.gradle",
14 "package.json",
15 "pyproject.toml",
16 "Cargo.toml",
17 "pom.xml",
18 "go.mod",
19];
20
21impl ProjectRoot {
22 pub fn new(path: impl AsRef<Path>) -> Result<Self> {
26 let start = path.as_ref().canonicalize().with_context(|| {
27 format!("failed to resolve project root {}", path.as_ref().display())
28 })?;
29 if !start.is_dir() {
30 bail!("project root is not a directory: {}", start.display());
31 }
32 let root = detect_root(&start).unwrap_or_else(|| start.clone());
33 Ok(Self { root })
34 }
35
36 pub fn new_exact(path: impl AsRef<Path>) -> Result<Self> {
38 let root = path.as_ref().canonicalize().with_context(|| {
39 format!("failed to resolve project root {}", path.as_ref().display())
40 })?;
41 if !root.is_dir() {
42 bail!("project root is not a directory: {}", root.display());
43 }
44 Ok(Self { root })
45 }
46
47 pub fn as_path(&self) -> &Path {
48 &self.root
49 }
50
51 pub fn resolve(&self, relative_or_absolute: impl AsRef<Path>) -> Result<PathBuf> {
52 let path = relative_or_absolute.as_ref();
53 let candidate = if path.is_absolute() {
54 path.to_path_buf()
55 } else {
56 self.root.join(path)
57 };
58 let normalized = normalize_path(&candidate);
59 if !normalized.starts_with(&self.root) {
60 bail!(
61 "path escapes project root: {} (root: {})",
62 normalized.display(),
63 self.root.display()
64 );
65 }
66 if normalized.exists()
68 && let Ok(real) = normalized.canonicalize()
69 && !real.starts_with(&self.root)
70 {
71 bail!(
72 "symlink escapes project root: {} → {} (root: {})",
73 normalized.display(),
74 real.display(),
75 self.root.display()
76 );
77 }
78 if normalized.exists()
80 && let Ok(real) = normalized.canonicalize()
81 && real.starts_with(&self.root)
82 {
83 return Ok(real);
84 }
85 Ok(normalized)
86 }
87
88 pub fn to_relative(&self, path: impl AsRef<Path>) -> String {
89 let path = path.as_ref();
90 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
91 canonical
92 .strip_prefix(&self.root)
93 .unwrap_or(&canonical)
94 .to_string_lossy()
95 .replace('\\', "/")
96 }
97}
98
99pub const EXCLUDED_DIRS: &[&str] = &[
102 ".git",
104 ".idea",
105 ".vscode",
106 ".cursor",
107 ".claude",
108 ".claire",
109 ".gradle",
111 "build",
112 "dist",
113 "out",
114 "node_modules",
115 "vendor",
116 "__pycache__",
117 "target",
118 ".next",
119 ".venv",
121 "venv",
122 ".tox",
123 "env",
124 ".cache",
126 ".ruff_cache",
127 ".pytest_cache",
128 ".mypy_cache",
129 ".fastembed_cache",
130 ".antigravity",
132 ".windsurf",
133 "Library",
135 ".codelens",
137];
138
139pub fn is_excluded(path: &Path) -> bool {
141 path.components().any(|component| {
142 let value = component.as_os_str().to_string_lossy();
143 EXCLUDED_DIRS.contains(&value.as_ref())
144 })
145}
146
147pub fn collect_files(root: &Path, filter: impl Fn(&Path) -> bool) -> Result<Vec<PathBuf>> {
149 use walkdir::WalkDir;
150 let mut files = Vec::new();
151 for entry in WalkDir::new(root)
152 .into_iter()
153 .filter_entry(|entry| !is_excluded(entry.path()))
154 {
155 let entry = entry?;
156 if entry.file_type().is_file() && filter(entry.path()) {
157 files.push(entry.path().to_path_buf());
158 }
159 }
160 Ok(files)
161}
162
163pub fn compute_dominant_language(root: &Path) -> Option<String> {
185 use std::collections::HashMap;
186 use walkdir::WalkDir;
187
188 const WALK_CAP: usize = 16_384;
189 const MIN_FILES: usize = 3;
190
191 let mut counts: HashMap<String, usize> = HashMap::new();
192 let mut total = 0usize;
193
194 for entry in WalkDir::new(root)
195 .into_iter()
196 .filter_entry(|entry| !is_excluded(entry.path()))
197 {
198 let Ok(entry) = entry else {
199 continue;
200 };
201 if !entry.file_type().is_file() {
202 continue;
203 }
204 let Some(ext) = entry.path().extension() else {
205 continue;
206 };
207 let Some(ext_str) = ext.to_str() else {
208 continue;
209 };
210 let ext_lower = ext_str.to_ascii_lowercase();
211 if crate::lang_registry::for_extension(&ext_lower).is_none() {
216 continue;
217 }
218 *counts.entry(ext_lower).or_insert(0) += 1;
219 total += 1;
220 if total >= WALK_CAP {
221 break;
222 }
223 }
224
225 if total < MIN_FILES {
226 return None;
227 }
228
229 counts
236 .into_iter()
237 .max_by_key(|(_, count)| *count)
238 .map(|(ext, _)| ext)
239}
240
241fn detect_root(start: &Path) -> Option<PathBuf> {
243 let home = dirs_fallback();
244 let temp = temp_dir_fallback();
245 let mut current = start.to_path_buf();
246 loop {
247 if current != start && Some(current.as_path()) == home.as_deref() {
251 break;
252 }
253 for marker in ROOT_MARKERS {
254 if marker == &".codelens" && current != start && is_temp_root(¤t, temp.as_deref())
255 {
256 continue;
257 }
258 if current.join(marker).exists() {
259 return Some(current);
260 }
261 }
262 if Some(current.as_path()) == home.as_deref() {
264 break;
265 }
266 if !current.pop() {
267 break;
268 }
269 }
270 None
271}
272
273fn dirs_fallback() -> Option<PathBuf> {
274 std::env::var_os("HOME")
275 .map(PathBuf::from)
276 .map(|path| path.canonicalize().unwrap_or(path))
277}
278
279fn temp_dir_fallback() -> Option<PathBuf> {
280 let path = std::env::temp_dir();
281 path.canonicalize().ok().or(Some(path))
282}
283
284fn is_temp_root(path: &Path, configured_temp: Option<&Path>) -> bool {
285 if Some(path) == configured_temp {
286 return true;
287 }
288 ["/tmp", "/private/tmp", "/var/tmp"]
289 .iter()
290 .filter_map(|candidate| Path::new(candidate).canonicalize().ok())
291 .any(|candidate| candidate == path)
292}
293
294pub fn detect_frameworks(project: &Path) -> Vec<String> {
297 let mut frameworks = Vec::new();
298
299 if project.join("manage.py").exists() {
301 frameworks.push("django".into());
302 }
303 if has_dependency(project, "fastapi") {
304 frameworks.push("fastapi".into());
305 }
306 if has_dependency(project, "flask") {
307 frameworks.push("flask".into());
308 }
309
310 if project.join("next.config.js").exists()
312 || project.join("next.config.mjs").exists()
313 || project.join("next.config.ts").exists()
314 {
315 frameworks.push("nextjs".into());
316 }
317 if has_node_dependency(project, "express") {
318 frameworks.push("express".into());
319 }
320 if has_node_dependency(project, "@nestjs/core") {
321 frameworks.push("nestjs".into());
322 }
323 if project.join("vite.config.ts").exists() || project.join("vite.config.js").exists() {
324 frameworks.push("vite".into());
325 }
326
327 if project.join("Cargo.toml").exists() {
329 if has_cargo_dependency(project, "actix-web") {
330 frameworks.push("actix-web".into());
331 }
332 if has_cargo_dependency(project, "axum") {
333 frameworks.push("axum".into());
334 }
335 if has_cargo_dependency(project, "rocket") {
336 frameworks.push("rocket".into());
337 }
338 }
339
340 if has_go_dependency(project, "gin-gonic/gin") {
342 frameworks.push("gin".into());
343 }
344 if has_go_dependency(project, "gofiber/fiber") {
345 frameworks.push("fiber".into());
346 }
347
348 if has_gradle_or_maven_dependency(project, "spring-boot") {
350 frameworks.push("spring-boot".into());
351 }
352
353 frameworks
354}
355
356fn read_file_text(path: &Path) -> Option<String> {
357 std::fs::read_to_string(path).ok()
358}
359
360fn has_dependency(project: &Path, name: &str) -> bool {
361 let req = project.join("requirements.txt");
362 if let Some(text) = read_file_text(&req)
363 && text.contains(name)
364 {
365 return true;
366 }
367 let pyproject = project.join("pyproject.toml");
368 if let Some(text) = read_file_text(&pyproject)
369 && text.contains(name)
370 {
371 return true;
372 }
373 false
374}
375
376fn has_node_dependency(project: &Path, name: &str) -> bool {
377 let pkg = project.join("package.json");
378 if let Some(text) = read_file_text(&pkg) {
379 return text.contains(name);
380 }
381 false
382}
383
384fn has_cargo_dependency(project: &Path, name: &str) -> bool {
385 let cargo = project.join("Cargo.toml");
386 if let Some(text) = read_file_text(&cargo) {
387 return text.contains(name);
388 }
389 false
390}
391
392fn has_go_dependency(project: &Path, name: &str) -> bool {
393 let gomod = project.join("go.mod");
394 if let Some(text) = read_file_text(&gomod) {
395 return text.contains(name);
396 }
397 false
398}
399
400fn has_gradle_or_maven_dependency(project: &Path, name: &str) -> bool {
401 for file in &["build.gradle", "build.gradle.kts", "pom.xml"] {
402 if let Some(text) = read_file_text(&project.join(file))
403 && text.contains(name)
404 {
405 return true;
406 }
407 }
408 false
409}
410
411#[derive(Debug, Clone, serde::Serialize)]
414pub struct WorkspacePackage {
415 pub name: String,
416 pub path: String,
417 pub package_type: String,
418}
419
420pub fn detect_workspace_packages(project: &Path) -> Vec<WorkspacePackage> {
421 let mut packages = Vec::new();
422
423 let cargo_toml = project.join("Cargo.toml");
425 if cargo_toml.is_file()
426 && let Ok(content) = std::fs::read_to_string(&cargo_toml)
427 && content.contains("[workspace]")
428 {
429 for line in content.lines() {
430 let trimmed = line.trim().trim_matches('"').trim_matches(',');
431 if trimmed.contains("crates/") || trimmed.contains("packages/") {
432 let pattern = trimmed.trim_matches('"').trim_matches(',').trim();
433 if let Some(stripped) = pattern.strip_suffix("/*") {
434 let dir = project.join(stripped);
436 if dir.is_dir() {
437 for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
438 if entry.path().join("Cargo.toml").is_file() {
439 packages.push(WorkspacePackage {
440 name: entry.file_name().to_string_lossy().to_string(),
441 path: entry
442 .path()
443 .strip_prefix(project)
444 .unwrap_or(&entry.path())
445 .to_string_lossy()
446 .to_string(),
447 package_type: "cargo".to_string(),
448 });
449 }
450 }
451 }
452 } else {
453 let dir = project.join(pattern);
455 if dir.join("Cargo.toml").is_file() {
456 packages.push(WorkspacePackage {
457 name: dir
458 .file_name()
459 .unwrap_or_default()
460 .to_string_lossy()
461 .to_string(),
462 path: pattern.to_string(),
463 package_type: "cargo".to_string(),
464 });
465 }
466 }
467 }
468 }
469 }
470
471 let pkg_json = project.join("package.json");
473 if pkg_json.is_file()
474 && let Ok(content) = std::fs::read_to_string(&pkg_json)
475 && content.contains("\"workspaces\"")
476 {
477 for dir_name in &["packages", "apps", "libs"] {
478 let dir = project.join(dir_name);
479 if dir.is_dir() {
480 for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
481 if entry.path().join("package.json").is_file() {
482 packages.push(WorkspacePackage {
483 name: entry.file_name().to_string_lossy().to_string(),
484 path: entry
485 .path()
486 .strip_prefix(project)
487 .unwrap_or(&entry.path())
488 .to_string_lossy()
489 .to_string(),
490 package_type: "npm".to_string(),
491 });
492 }
493 }
494 }
495 }
496 }
497
498 let go_work = project.join("go.work");
500 if go_work.is_file()
501 && let Ok(content) = std::fs::read_to_string(&go_work)
502 {
503 for line in content.lines() {
504 let trimmed = line.trim();
505 if !trimmed.starts_with("use")
506 && !trimmed.starts_with("go")
507 && !trimmed.starts_with("//")
508 && !trimmed.is_empty()
509 && trimmed != "("
510 && trimmed != ")"
511 {
512 let dir = project.join(trimmed);
513 if dir.join("go.mod").is_file() {
514 packages.push(WorkspacePackage {
515 name: trimmed.to_string(),
516 path: trimmed.to_string(),
517 package_type: "go".to_string(),
518 });
519 }
520 }
521 }
522 }
523
524 packages
525}
526
527fn normalize_path(path: &Path) -> PathBuf {
528 let mut normalized = PathBuf::new();
529 for component in path.components() {
530 match component {
531 std::path::Component::CurDir => {}
532 std::path::Component::ParentDir => {
533 normalized.pop();
534 }
535 _ => normalized.push(component.as_os_str()),
536 }
537 }
538 normalized
539}
540
541#[cfg(test)]
542mod tests {
543 use super::{ProjectRoot, is_excluded};
544 use std::{
545 env, fs,
546 path::Path,
547 sync::{Mutex, OnceLock},
548 };
549
550 #[test]
551 fn excludes_agent_worktree_directories() {
552 assert!(is_excluded(Path::new(
555 ".claire/worktrees/agent-abc/src/lib.rs"
556 )));
557 assert!(is_excluded(Path::new(
558 ".claude/worktrees/agent-xyz/main.rs"
559 )));
560 assert!(is_excluded(Path::new("project/.claire/anything.rs")));
561 assert!(is_excluded(Path::new("node_modules/foo/index.js")));
563 assert!(is_excluded(Path::new("target/debug/build.rs")));
564 assert!(!is_excluded(Path::new("crates/codelens-engine/src/lib.rs")));
566 assert!(!is_excluded(Path::new("src/claire_not_a_dir.rs")));
567 }
568
569 #[test]
570 fn rejects_path_escape() {
571 let dir = tempfile_dir();
572 let project = ProjectRoot::new(&dir).expect("project root");
573 let err = project
574 .resolve("../outside.txt")
575 .expect_err("should reject escape");
576 assert!(err.to_string().contains("escapes project root"));
577 }
578
579 #[test]
580 fn makes_relative_paths() {
581 let dir = tempfile_dir();
582 let nested = dir.join("src/lib.rs");
583 fs::create_dir_all(nested.parent().expect("parent")).expect("mkdir");
584 fs::write(&nested, "fn main() {}\n").expect("write file");
585
586 let project = ProjectRoot::new(&dir).expect("project root");
587 assert_eq!(project.to_relative(&nested), "src/lib.rs");
588 }
589
590 #[test]
591 fn does_not_promote_home_directory_from_global_codelens_marker() {
592 let _guard = env_lock().lock().expect("lock");
593 let home = tempfile_dir();
594 let nested = home.join("Downloads/codelens");
595 fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
596 fs::create_dir_all(&nested).expect("mkdir nested");
597
598 let previous_home = env::var_os("HOME");
599 unsafe {
600 env::set_var("HOME", &home);
601 }
602
603 let project = ProjectRoot::new(&nested).expect("project root");
604
605 match previous_home {
606 Some(value) => unsafe { env::set_var("HOME", value) },
607 None => unsafe { env::remove_var("HOME") },
608 }
609
610 assert_eq!(
611 project.as_path(),
612 nested.canonicalize().expect("canonical nested").as_path()
613 );
614 }
615
616 #[test]
617 fn does_not_promote_temp_directory_from_global_codelens_marker() {
618 let _guard = env_lock().lock().expect("lock");
619 let temp_root = tempfile_dir();
620 let nested = temp_root.join("projectless-fixture");
621 fs::create_dir_all(temp_root.join(".codelens")).expect("mkdir temp codelens");
622 fs::create_dir_all(&nested).expect("mkdir nested");
623
624 let previous_tmpdir = env::var_os("TMPDIR");
625 unsafe {
626 env::set_var("TMPDIR", &temp_root);
627 }
628
629 let project = ProjectRoot::new(&nested).expect("project root");
630
631 match previous_tmpdir {
632 Some(value) => unsafe { env::set_var("TMPDIR", value) },
633 None => unsafe { env::remove_var("TMPDIR") },
634 }
635
636 assert_eq!(
637 project.as_path(),
638 nested.canonicalize().expect("canonical nested").as_path()
639 );
640 }
641
642 #[test]
643 fn standard_tmp_paths_are_treated_as_global_temp_roots() {
644 let tmp = Path::new("/tmp")
645 .canonicalize()
646 .expect("standard /tmp should exist");
647 assert!(super::is_temp_root(&tmp, None));
648 }
649
650 #[test]
651 fn still_detects_project_root_before_home_directory() {
652 let _guard = env_lock().lock().expect("lock");
653 let home = tempfile_dir();
654 let project_root = home.join("workspace/app");
655 let nested = project_root.join("src/features");
656 fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
657 fs::create_dir_all(&nested).expect("mkdir nested");
658 fs::write(
659 project_root.join("Cargo.toml"),
660 "[package]\nname = \"demo\"\n",
661 )
662 .expect("write cargo");
663
664 let previous_home = env::var_os("HOME");
665 unsafe {
666 env::set_var("HOME", &home);
667 }
668
669 let project = ProjectRoot::new(&nested).expect("project root");
670
671 match previous_home {
672 Some(value) => unsafe { env::set_var("HOME", value) },
673 None => unsafe { env::remove_var("HOME") },
674 }
675
676 assert_eq!(
677 project.as_path(),
678 project_root
679 .canonicalize()
680 .expect("canonical project root")
681 .as_path()
682 );
683 }
684
685 fn fresh_test_dir(label: &str) -> std::path::PathBuf {
688 let dir = tempfile_dir().join(label);
689 fs::create_dir_all(&dir).expect("mkdir fresh test dir");
690 dir
691 }
692
693 #[test]
694 fn compute_dominant_language_picks_rust_for_rust_heavy_project() {
695 let dir = fresh_test_dir("phase2j_rust_heavy");
696 fs::create_dir_all(dir.join("src")).expect("mkdir src");
698 fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("Cargo.toml");
699 for name in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"] {
700 fs::write(dir.join("src").join(name), "pub fn f() {}\n").expect("write rs");
701 }
702 fs::write(dir.join("scripts.py"), "def f():\n pass\n").expect("write py");
703 fs::write(dir.join("README.md"), "# README\n").expect("write md");
704
705 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
706 assert_eq!(lang, "rs", "expected rs dominant, got {lang}");
707 }
708
709 #[test]
710 fn compute_dominant_language_picks_python_for_python_heavy_project() {
711 let dir = fresh_test_dir("phase2j_python_heavy");
712 fs::create_dir_all(dir.join("pkg")).expect("mkdir pkg");
714 for name in ["mod_a.py", "mod_b.py", "mod_c.py", "mod_d.py"] {
715 fs::write(dir.join("pkg").join(name), "def f():\n pass\n").expect("write py");
716 }
717 fs::write(dir.join("build.rs"), "fn main() {}\n").expect("write rs");
718
719 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
720 assert_eq!(lang, "py", "expected py dominant, got {lang}");
721 }
722
723 #[test]
724 fn compute_dominant_language_returns_none_below_min_file_count() {
725 let dir = fresh_test_dir("phase2j_below_min");
726 fs::write(dir.join("only.rs"), "fn x() {}\n").expect("write rs");
728 fs::write(dir.join("other.py"), "def y(): pass\n").expect("write py");
729
730 let lang = super::compute_dominant_language(&dir);
731 assert!(lang.is_none(), "expected None below 3 files, got {lang:?}");
732 }
733
734 #[test]
735 fn compute_dominant_language_skips_excluded_dirs() {
736 let dir = fresh_test_dir("phase2j_excluded_dirs");
737 fs::create_dir_all(dir.join("src")).expect("mkdir src");
738 fs::create_dir_all(dir.join("node_modules/foo")).expect("mkdir node_modules");
739 fs::create_dir_all(dir.join("target")).expect("mkdir target");
740 for name in ["a.rs", "b.rs", "c.rs"] {
742 fs::write(dir.join("src").join(name), "fn f() {}\n").expect("write src rs");
743 }
744 for i in 0..10 {
746 fs::write(
747 dir.join("node_modules/foo").join(format!("x{i}.js")),
748 "module.exports = {};\n",
749 )
750 .expect("write node_modules js");
751 }
752 for i in 0..10 {
754 fs::write(
755 dir.join("target").join(format!("build{i}.rs")),
756 "fn f() {}\n",
757 )
758 .expect("write target rs");
759 }
760
761 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
762 assert_eq!(lang, "rs", "expected rs from src only, got {lang}");
765 }
766
767 fn env_lock() -> &'static Mutex<()> {
768 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
769 LOCK.get_or_init(|| Mutex::new(()))
770 }
771
772 fn tempfile_dir() -> std::path::PathBuf {
773 let dir = std::env::temp_dir().join(format!(
774 "codelens-core-project-{}",
775 std::time::SystemTime::now()
776 .duration_since(std::time::UNIX_EPOCH)
777 .expect("time")
778 .as_nanos()
779 ));
780 fs::create_dir_all(&dir).expect("create tempdir");
781 dir
782 }
783}