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 mut current = start.to_path_buf();
245 loop {
246 if current != start && Some(current.as_path()) == home.as_deref() {
250 break;
251 }
252 for marker in ROOT_MARKERS {
253 if current.join(marker).exists() {
254 return Some(current);
255 }
256 }
257 if Some(current.as_path()) == home.as_deref() {
259 break;
260 }
261 if !current.pop() {
262 break;
263 }
264 }
265 None
266}
267
268fn dirs_fallback() -> Option<PathBuf> {
269 std::env::var_os("HOME")
270 .map(PathBuf::from)
271 .map(|path| path.canonicalize().unwrap_or(path))
272}
273
274pub fn detect_frameworks(project: &Path) -> Vec<String> {
277 let mut frameworks = Vec::new();
278
279 if project.join("manage.py").exists() {
281 frameworks.push("django".into());
282 }
283 if has_dependency(project, "fastapi") {
284 frameworks.push("fastapi".into());
285 }
286 if has_dependency(project, "flask") {
287 frameworks.push("flask".into());
288 }
289
290 if project.join("next.config.js").exists()
292 || project.join("next.config.mjs").exists()
293 || project.join("next.config.ts").exists()
294 {
295 frameworks.push("nextjs".into());
296 }
297 if has_node_dependency(project, "express") {
298 frameworks.push("express".into());
299 }
300 if has_node_dependency(project, "@nestjs/core") {
301 frameworks.push("nestjs".into());
302 }
303 if project.join("vite.config.ts").exists() || project.join("vite.config.js").exists() {
304 frameworks.push("vite".into());
305 }
306
307 if project.join("Cargo.toml").exists() {
309 if has_cargo_dependency(project, "actix-web") {
310 frameworks.push("actix-web".into());
311 }
312 if has_cargo_dependency(project, "axum") {
313 frameworks.push("axum".into());
314 }
315 if has_cargo_dependency(project, "rocket") {
316 frameworks.push("rocket".into());
317 }
318 }
319
320 if has_go_dependency(project, "gin-gonic/gin") {
322 frameworks.push("gin".into());
323 }
324 if has_go_dependency(project, "gofiber/fiber") {
325 frameworks.push("fiber".into());
326 }
327
328 if has_gradle_or_maven_dependency(project, "spring-boot") {
330 frameworks.push("spring-boot".into());
331 }
332
333 frameworks
334}
335
336fn read_file_text(path: &Path) -> Option<String> {
337 std::fs::read_to_string(path).ok()
338}
339
340fn has_dependency(project: &Path, name: &str) -> bool {
341 let req = project.join("requirements.txt");
342 if let Some(text) = read_file_text(&req)
343 && text.contains(name)
344 {
345 return true;
346 }
347 let pyproject = project.join("pyproject.toml");
348 if let Some(text) = read_file_text(&pyproject)
349 && text.contains(name)
350 {
351 return true;
352 }
353 false
354}
355
356fn has_node_dependency(project: &Path, name: &str) -> bool {
357 let pkg = project.join("package.json");
358 if let Some(text) = read_file_text(&pkg) {
359 return text.contains(name);
360 }
361 false
362}
363
364fn has_cargo_dependency(project: &Path, name: &str) -> bool {
365 let cargo = project.join("Cargo.toml");
366 if let Some(text) = read_file_text(&cargo) {
367 return text.contains(name);
368 }
369 false
370}
371
372fn has_go_dependency(project: &Path, name: &str) -> bool {
373 let gomod = project.join("go.mod");
374 if let Some(text) = read_file_text(&gomod) {
375 return text.contains(name);
376 }
377 false
378}
379
380fn has_gradle_or_maven_dependency(project: &Path, name: &str) -> bool {
381 for file in &["build.gradle", "build.gradle.kts", "pom.xml"] {
382 if let Some(text) = read_file_text(&project.join(file))
383 && text.contains(name)
384 {
385 return true;
386 }
387 }
388 false
389}
390
391#[derive(Debug, Clone, serde::Serialize)]
394pub struct WorkspacePackage {
395 pub name: String,
396 pub path: String,
397 pub package_type: String,
398}
399
400pub fn detect_workspace_packages(project: &Path) -> Vec<WorkspacePackage> {
401 let mut packages = Vec::new();
402
403 let cargo_toml = project.join("Cargo.toml");
405 if cargo_toml.is_file()
406 && let Ok(content) = std::fs::read_to_string(&cargo_toml)
407 && content.contains("[workspace]")
408 {
409 for line in content.lines() {
410 let trimmed = line.trim().trim_matches('"').trim_matches(',');
411 if trimmed.contains("crates/") || trimmed.contains("packages/") {
412 let pattern = trimmed.trim_matches('"').trim_matches(',').trim();
413 if let Some(stripped) = pattern.strip_suffix("/*") {
414 let dir = project.join(stripped);
416 if dir.is_dir() {
417 for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
418 if entry.path().join("Cargo.toml").is_file() {
419 packages.push(WorkspacePackage {
420 name: entry.file_name().to_string_lossy().to_string(),
421 path: entry
422 .path()
423 .strip_prefix(project)
424 .unwrap_or(&entry.path())
425 .to_string_lossy()
426 .to_string(),
427 package_type: "cargo".to_string(),
428 });
429 }
430 }
431 }
432 } else {
433 let dir = project.join(pattern);
435 if dir.join("Cargo.toml").is_file() {
436 packages.push(WorkspacePackage {
437 name: dir
438 .file_name()
439 .unwrap_or_default()
440 .to_string_lossy()
441 .to_string(),
442 path: pattern.to_string(),
443 package_type: "cargo".to_string(),
444 });
445 }
446 }
447 }
448 }
449 }
450
451 let pkg_json = project.join("package.json");
453 if pkg_json.is_file()
454 && let Ok(content) = std::fs::read_to_string(&pkg_json)
455 && content.contains("\"workspaces\"")
456 {
457 for dir_name in &["packages", "apps", "libs"] {
458 let dir = project.join(dir_name);
459 if dir.is_dir() {
460 for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
461 if entry.path().join("package.json").is_file() {
462 packages.push(WorkspacePackage {
463 name: entry.file_name().to_string_lossy().to_string(),
464 path: entry
465 .path()
466 .strip_prefix(project)
467 .unwrap_or(&entry.path())
468 .to_string_lossy()
469 .to_string(),
470 package_type: "npm".to_string(),
471 });
472 }
473 }
474 }
475 }
476 }
477
478 let go_work = project.join("go.work");
480 if go_work.is_file()
481 && let Ok(content) = std::fs::read_to_string(&go_work)
482 {
483 for line in content.lines() {
484 let trimmed = line.trim();
485 if !trimmed.starts_with("use")
486 && !trimmed.starts_with("go")
487 && !trimmed.starts_with("//")
488 && !trimmed.is_empty()
489 && trimmed != "("
490 && trimmed != ")"
491 {
492 let dir = project.join(trimmed);
493 if dir.join("go.mod").is_file() {
494 packages.push(WorkspacePackage {
495 name: trimmed.to_string(),
496 path: trimmed.to_string(),
497 package_type: "go".to_string(),
498 });
499 }
500 }
501 }
502 }
503
504 packages
505}
506
507fn normalize_path(path: &Path) -> PathBuf {
508 let mut normalized = PathBuf::new();
509 for component in path.components() {
510 match component {
511 std::path::Component::CurDir => {}
512 std::path::Component::ParentDir => {
513 normalized.pop();
514 }
515 _ => normalized.push(component.as_os_str()),
516 }
517 }
518 normalized
519}
520
521#[cfg(test)]
522mod tests {
523 use super::{ProjectRoot, is_excluded};
524 use std::{
525 env, fs,
526 path::Path,
527 sync::{Mutex, OnceLock},
528 };
529
530 #[test]
531 fn excludes_agent_worktree_directories() {
532 assert!(is_excluded(Path::new(
535 ".claire/worktrees/agent-abc/src/lib.rs"
536 )));
537 assert!(is_excluded(Path::new(
538 ".claude/worktrees/agent-xyz/main.rs"
539 )));
540 assert!(is_excluded(Path::new("project/.claire/anything.rs")));
541 assert!(is_excluded(Path::new("node_modules/foo/index.js")));
543 assert!(is_excluded(Path::new("target/debug/build.rs")));
544 assert!(!is_excluded(Path::new("crates/codelens-engine/src/lib.rs")));
546 assert!(!is_excluded(Path::new("src/claire_not_a_dir.rs")));
547 }
548
549 #[test]
550 fn rejects_path_escape() {
551 let dir = tempfile_dir();
552 let project = ProjectRoot::new(&dir).expect("project root");
553 let err = project
554 .resolve("../outside.txt")
555 .expect_err("should reject escape");
556 assert!(err.to_string().contains("escapes project root"));
557 }
558
559 #[test]
560 fn makes_relative_paths() {
561 let dir = tempfile_dir();
562 let nested = dir.join("src/lib.rs");
563 fs::create_dir_all(nested.parent().expect("parent")).expect("mkdir");
564 fs::write(&nested, "fn main() {}\n").expect("write file");
565
566 let project = ProjectRoot::new(&dir).expect("project root");
567 assert_eq!(project.to_relative(&nested), "src/lib.rs");
568 }
569
570 #[test]
571 fn does_not_promote_home_directory_from_global_codelens_marker() {
572 let _guard = env_lock().lock().expect("lock");
573 let home = tempfile_dir();
574 let nested = home.join("Downloads/codelens");
575 fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
576 fs::create_dir_all(&nested).expect("mkdir nested");
577
578 let previous_home = env::var_os("HOME");
579 unsafe {
580 env::set_var("HOME", &home);
581 }
582
583 let project = ProjectRoot::new(&nested).expect("project root");
584
585 match previous_home {
586 Some(value) => unsafe { env::set_var("HOME", value) },
587 None => unsafe { env::remove_var("HOME") },
588 }
589
590 assert_eq!(
591 project.as_path(),
592 nested.canonicalize().expect("canonical nested").as_path()
593 );
594 }
595
596 #[test]
597 fn still_detects_project_root_before_home_directory() {
598 let _guard = env_lock().lock().expect("lock");
599 let home = tempfile_dir();
600 let project_root = home.join("workspace/app");
601 let nested = project_root.join("src/features");
602 fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
603 fs::create_dir_all(&nested).expect("mkdir nested");
604 fs::write(
605 project_root.join("Cargo.toml"),
606 "[package]\nname = \"demo\"\n",
607 )
608 .expect("write cargo");
609
610 let previous_home = env::var_os("HOME");
611 unsafe {
612 env::set_var("HOME", &home);
613 }
614
615 let project = ProjectRoot::new(&nested).expect("project root");
616
617 match previous_home {
618 Some(value) => unsafe { env::set_var("HOME", value) },
619 None => unsafe { env::remove_var("HOME") },
620 }
621
622 assert_eq!(
623 project.as_path(),
624 project_root
625 .canonicalize()
626 .expect("canonical project root")
627 .as_path()
628 );
629 }
630
631 fn fresh_test_dir(label: &str) -> std::path::PathBuf {
634 let dir = tempfile_dir().join(label);
635 fs::create_dir_all(&dir).expect("mkdir fresh test dir");
636 dir
637 }
638
639 #[test]
640 fn compute_dominant_language_picks_rust_for_rust_heavy_project() {
641 let dir = fresh_test_dir("phase2j_rust_heavy");
642 fs::create_dir_all(dir.join("src")).expect("mkdir src");
644 fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("Cargo.toml");
645 for name in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"] {
646 fs::write(dir.join("src").join(name), "pub fn f() {}\n").expect("write rs");
647 }
648 fs::write(dir.join("scripts.py"), "def f():\n pass\n").expect("write py");
649 fs::write(dir.join("README.md"), "# README\n").expect("write md");
650
651 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
652 assert_eq!(lang, "rs", "expected rs dominant, got {lang}");
653 }
654
655 #[test]
656 fn compute_dominant_language_picks_python_for_python_heavy_project() {
657 let dir = fresh_test_dir("phase2j_python_heavy");
658 fs::create_dir_all(dir.join("pkg")).expect("mkdir pkg");
660 for name in ["mod_a.py", "mod_b.py", "mod_c.py", "mod_d.py"] {
661 fs::write(dir.join("pkg").join(name), "def f():\n pass\n").expect("write py");
662 }
663 fs::write(dir.join("build.rs"), "fn main() {}\n").expect("write rs");
664
665 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
666 assert_eq!(lang, "py", "expected py dominant, got {lang}");
667 }
668
669 #[test]
670 fn compute_dominant_language_returns_none_below_min_file_count() {
671 let dir = fresh_test_dir("phase2j_below_min");
672 fs::write(dir.join("only.rs"), "fn x() {}\n").expect("write rs");
674 fs::write(dir.join("other.py"), "def y(): pass\n").expect("write py");
675
676 let lang = super::compute_dominant_language(&dir);
677 assert!(lang.is_none(), "expected None below 3 files, got {lang:?}");
678 }
679
680 #[test]
681 fn compute_dominant_language_skips_excluded_dirs() {
682 let dir = fresh_test_dir("phase2j_excluded_dirs");
683 fs::create_dir_all(dir.join("src")).expect("mkdir src");
684 fs::create_dir_all(dir.join("node_modules/foo")).expect("mkdir node_modules");
685 fs::create_dir_all(dir.join("target")).expect("mkdir target");
686 for name in ["a.rs", "b.rs", "c.rs"] {
688 fs::write(dir.join("src").join(name), "fn f() {}\n").expect("write src rs");
689 }
690 for i in 0..10 {
692 fs::write(
693 dir.join("node_modules/foo").join(format!("x{i}.js")),
694 "module.exports = {};\n",
695 )
696 .expect("write node_modules js");
697 }
698 for i in 0..10 {
700 fs::write(
701 dir.join("target").join(format!("build{i}.rs")),
702 "fn f() {}\n",
703 )
704 .expect("write target rs");
705 }
706
707 let lang = super::compute_dominant_language(&dir).expect("dominant lang");
708 assert_eq!(lang, "rs", "expected rs from src only, got {lang}");
711 }
712
713 fn env_lock() -> &'static Mutex<()> {
714 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
715 LOCK.get_or_init(|| Mutex::new(()))
716 }
717
718 fn tempfile_dir() -> std::path::PathBuf {
719 let dir = std::env::temp_dir().join(format!(
720 "codelens-core-project-{}",
721 std::time::SystemTime::now()
722 .duration_since(std::time::UNIX_EPOCH)
723 .expect("time")
724 .as_nanos()
725 ));
726 fs::create_dir_all(&dir).expect("create tempdir");
727 dir
728 }
729}