use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ProjectRoot {
root: PathBuf,
}
const ROOT_MARKERS: &[&str] = &[
".git",
".codelens",
"build.gradle.kts",
"build.gradle",
"package.json",
"pyproject.toml",
"Cargo.toml",
"pom.xml",
"go.mod",
];
impl ProjectRoot {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
let start = path.as_ref().canonicalize().with_context(|| {
format!("failed to resolve project root {}", path.as_ref().display())
})?;
if !start.is_dir() {
bail!("project root is not a directory: {}", start.display());
}
let root = detect_root(&start).unwrap_or_else(|| start.clone());
Ok(Self { root })
}
pub fn new_exact(path: impl AsRef<Path>) -> Result<Self> {
let root = path.as_ref().canonicalize().with_context(|| {
format!("failed to resolve project root {}", path.as_ref().display())
})?;
if !root.is_dir() {
bail!("project root is not a directory: {}", root.display());
}
Ok(Self { root })
}
pub fn as_path(&self) -> &Path {
&self.root
}
pub fn resolve(&self, relative_or_absolute: impl AsRef<Path>) -> Result<PathBuf> {
let path = relative_or_absolute.as_ref();
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
self.root.join(path)
};
let normalized = normalize_path(&candidate);
if !normalized.starts_with(&self.root) {
bail!(
"path escapes project root: {} (root: {})",
normalized.display(),
self.root.display()
);
}
if normalized.exists()
&& let Ok(real) = normalized.canonicalize()
&& !real.starts_with(&self.root)
{
bail!(
"symlink escapes project root: {} → {} (root: {})",
normalized.display(),
real.display(),
self.root.display()
);
}
if normalized.exists()
&& let Ok(real) = normalized.canonicalize()
&& real.starts_with(&self.root)
{
return Ok(real);
}
Ok(normalized)
}
pub fn to_relative(&self, path: impl AsRef<Path>) -> String {
let path = path.as_ref();
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
canonical
.strip_prefix(&self.root)
.unwrap_or(&canonical)
.to_string_lossy()
.replace('\\', "/")
}
}
pub const EXCLUDED_DIRS: &[&str] = &[
".git",
".idea",
".vscode",
".cursor",
".claude",
".gradle",
"build",
"dist",
"out",
"node_modules",
"vendor",
"__pycache__",
"target",
".next",
".venv",
"venv",
".tox",
"env",
".cache",
".ruff_cache",
".pytest_cache",
".mypy_cache",
".fastembed_cache",
".antigravity",
".windsurf",
"Library",
".codelens",
];
pub fn is_excluded(path: &Path) -> bool {
path.components().any(|component| {
let value = component.as_os_str().to_string_lossy();
EXCLUDED_DIRS.contains(&value.as_ref())
})
}
pub fn collect_files(root: &Path, filter: impl Fn(&Path) -> bool) -> Result<Vec<PathBuf>> {
use walkdir::WalkDir;
let mut files = Vec::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|entry| !is_excluded(entry.path()))
{
let entry = entry?;
if entry.file_type().is_file() && filter(entry.path()) {
files.push(entry.path().to_path_buf());
}
}
Ok(files)
}
pub fn compute_dominant_language(root: &Path) -> Option<String> {
use std::collections::HashMap;
use walkdir::WalkDir;
const WALK_CAP: usize = 16_384;
const MIN_FILES: usize = 3;
let mut counts: HashMap<String, usize> = HashMap::new();
let mut total = 0usize;
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|entry| !is_excluded(entry.path()))
{
let Ok(entry) = entry else {
continue;
};
if !entry.file_type().is_file() {
continue;
}
let Some(ext) = entry.path().extension() else {
continue;
};
let Some(ext_str) = ext.to_str() else {
continue;
};
let ext_lower = ext_str.to_ascii_lowercase();
if crate::lang_registry::for_extension(&ext_lower).is_none() {
continue;
}
*counts.entry(ext_lower).or_insert(0) += 1;
total += 1;
if total >= WALK_CAP {
break;
}
}
if total < MIN_FILES {
return None;
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(ext, _)| ext)
}
fn detect_root(start: &Path) -> Option<PathBuf> {
let home = dirs_fallback();
let mut current = start.to_path_buf();
loop {
if current != start && Some(current.as_path()) == home.as_deref() {
break;
}
for marker in ROOT_MARKERS {
if current.join(marker).exists() {
return Some(current);
}
}
if Some(current.as_path()) == home.as_deref() {
break;
}
if !current.pop() {
break;
}
}
None
}
fn dirs_fallback() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|path| path.canonicalize().unwrap_or(path))
}
pub fn detect_frameworks(project: &Path) -> Vec<String> {
let mut frameworks = Vec::new();
if project.join("manage.py").exists() {
frameworks.push("django".into());
}
if has_dependency(project, "fastapi") {
frameworks.push("fastapi".into());
}
if has_dependency(project, "flask") {
frameworks.push("flask".into());
}
if project.join("next.config.js").exists()
|| project.join("next.config.mjs").exists()
|| project.join("next.config.ts").exists()
{
frameworks.push("nextjs".into());
}
if has_node_dependency(project, "express") {
frameworks.push("express".into());
}
if has_node_dependency(project, "@nestjs/core") {
frameworks.push("nestjs".into());
}
if project.join("vite.config.ts").exists() || project.join("vite.config.js").exists() {
frameworks.push("vite".into());
}
if project.join("Cargo.toml").exists() {
if has_cargo_dependency(project, "actix-web") {
frameworks.push("actix-web".into());
}
if has_cargo_dependency(project, "axum") {
frameworks.push("axum".into());
}
if has_cargo_dependency(project, "rocket") {
frameworks.push("rocket".into());
}
}
if has_go_dependency(project, "gin-gonic/gin") {
frameworks.push("gin".into());
}
if has_go_dependency(project, "gofiber/fiber") {
frameworks.push("fiber".into());
}
if has_gradle_or_maven_dependency(project, "spring-boot") {
frameworks.push("spring-boot".into());
}
frameworks
}
fn read_file_text(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn has_dependency(project: &Path, name: &str) -> bool {
let req = project.join("requirements.txt");
if let Some(text) = read_file_text(&req)
&& text.contains(name)
{
return true;
}
let pyproject = project.join("pyproject.toml");
if let Some(text) = read_file_text(&pyproject)
&& text.contains(name)
{
return true;
}
false
}
fn has_node_dependency(project: &Path, name: &str) -> bool {
let pkg = project.join("package.json");
if let Some(text) = read_file_text(&pkg) {
return text.contains(name);
}
false
}
fn has_cargo_dependency(project: &Path, name: &str) -> bool {
let cargo = project.join("Cargo.toml");
if let Some(text) = read_file_text(&cargo) {
return text.contains(name);
}
false
}
fn has_go_dependency(project: &Path, name: &str) -> bool {
let gomod = project.join("go.mod");
if let Some(text) = read_file_text(&gomod) {
return text.contains(name);
}
false
}
fn has_gradle_or_maven_dependency(project: &Path, name: &str) -> bool {
for file in &["build.gradle", "build.gradle.kts", "pom.xml"] {
if let Some(text) = read_file_text(&project.join(file))
&& text.contains(name)
{
return true;
}
}
false
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WorkspacePackage {
pub name: String,
pub path: String,
pub package_type: String,
}
pub fn detect_workspace_packages(project: &Path) -> Vec<WorkspacePackage> {
let mut packages = Vec::new();
let cargo_toml = project.join("Cargo.toml");
if cargo_toml.is_file()
&& let Ok(content) = std::fs::read_to_string(&cargo_toml)
&& content.contains("[workspace]")
{
for line in content.lines() {
let trimmed = line.trim().trim_matches('"').trim_matches(',');
if trimmed.contains("crates/") || trimmed.contains("packages/") {
let pattern = trimmed.trim_matches('"').trim_matches(',').trim();
if let Some(stripped) = pattern.strip_suffix("/*") {
let dir = project.join(stripped);
if dir.is_dir() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
if entry.path().join("Cargo.toml").is_file() {
packages.push(WorkspacePackage {
name: entry.file_name().to_string_lossy().to_string(),
path: entry
.path()
.strip_prefix(project)
.unwrap_or(&entry.path())
.to_string_lossy()
.to_string(),
package_type: "cargo".to_string(),
});
}
}
}
} else {
let dir = project.join(pattern);
if dir.join("Cargo.toml").is_file() {
packages.push(WorkspacePackage {
name: dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: pattern.to_string(),
package_type: "cargo".to_string(),
});
}
}
}
}
}
let pkg_json = project.join("package.json");
if pkg_json.is_file()
&& let Ok(content) = std::fs::read_to_string(&pkg_json)
&& content.contains("\"workspaces\"")
{
for dir_name in &["packages", "apps", "libs"] {
let dir = project.join(dir_name);
if dir.is_dir() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
if entry.path().join("package.json").is_file() {
packages.push(WorkspacePackage {
name: entry.file_name().to_string_lossy().to_string(),
path: entry
.path()
.strip_prefix(project)
.unwrap_or(&entry.path())
.to_string_lossy()
.to_string(),
package_type: "npm".to_string(),
});
}
}
}
}
}
let go_work = project.join("go.work");
if go_work.is_file()
&& let Ok(content) = std::fs::read_to_string(&go_work)
{
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("use")
&& !trimmed.starts_with("go")
&& !trimmed.starts_with("//")
&& !trimmed.is_empty()
&& trimmed != "("
&& trimmed != ")"
{
let dir = project.join(trimmed);
if dir.join("go.mod").is_file() {
packages.push(WorkspacePackage {
name: trimmed.to_string(),
path: trimmed.to_string(),
package_type: "go".to_string(),
});
}
}
}
}
packages
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::ProjectRoot;
use std::{
env, fs,
sync::{Mutex, OnceLock},
};
#[test]
fn rejects_path_escape() {
let dir = tempfile_dir();
let project = ProjectRoot::new(&dir).expect("project root");
let err = project
.resolve("../outside.txt")
.expect_err("should reject escape");
assert!(err.to_string().contains("escapes project root"));
}
#[test]
fn makes_relative_paths() {
let dir = tempfile_dir();
let nested = dir.join("src/lib.rs");
fs::create_dir_all(nested.parent().expect("parent")).expect("mkdir");
fs::write(&nested, "fn main() {}\n").expect("write file");
let project = ProjectRoot::new(&dir).expect("project root");
assert_eq!(project.to_relative(&nested), "src/lib.rs");
}
#[test]
fn does_not_promote_home_directory_from_global_codelens_marker() {
let _guard = env_lock().lock().expect("lock");
let home = tempfile_dir();
let nested = home.join("Downloads/codelens");
fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
fs::create_dir_all(&nested).expect("mkdir nested");
let previous_home = env::var_os("HOME");
unsafe {
env::set_var("HOME", &home);
}
let project = ProjectRoot::new(&nested).expect("project root");
match previous_home {
Some(value) => unsafe { env::set_var("HOME", value) },
None => unsafe { env::remove_var("HOME") },
}
assert_eq!(
project.as_path(),
nested.canonicalize().expect("canonical nested").as_path()
);
}
#[test]
fn still_detects_project_root_before_home_directory() {
let _guard = env_lock().lock().expect("lock");
let home = tempfile_dir();
let project_root = home.join("workspace/app");
let nested = project_root.join("src/features");
fs::create_dir_all(home.join(".codelens")).expect("mkdir global codelens");
fs::create_dir_all(&nested).expect("mkdir nested");
fs::write(
project_root.join("Cargo.toml"),
"[package]\nname = \"demo\"\n",
)
.expect("write cargo");
let previous_home = env::var_os("HOME");
unsafe {
env::set_var("HOME", &home);
}
let project = ProjectRoot::new(&nested).expect("project root");
match previous_home {
Some(value) => unsafe { env::set_var("HOME", value) },
None => unsafe { env::remove_var("HOME") },
}
assert_eq!(
project.as_path(),
project_root
.canonicalize()
.expect("canonical project root")
.as_path()
);
}
fn fresh_test_dir(label: &str) -> std::path::PathBuf {
let dir = tempfile_dir().join(label);
fs::create_dir_all(&dir).expect("mkdir fresh test dir");
dir
}
#[test]
fn compute_dominant_language_picks_rust_for_rust_heavy_project() {
let dir = fresh_test_dir("phase2j_rust_heavy");
fs::create_dir_all(dir.join("src")).expect("mkdir src");
fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("Cargo.toml");
for name in ["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"] {
fs::write(dir.join("src").join(name), "pub fn f() {}\n").expect("write rs");
}
fs::write(dir.join("scripts.py"), "def f():\n pass\n").expect("write py");
fs::write(dir.join("README.md"), "# README\n").expect("write md");
let lang = super::compute_dominant_language(&dir).expect("dominant lang");
assert_eq!(lang, "rs", "expected rs dominant, got {lang}");
}
#[test]
fn compute_dominant_language_picks_python_for_python_heavy_project() {
let dir = fresh_test_dir("phase2j_python_heavy");
fs::create_dir_all(dir.join("pkg")).expect("mkdir pkg");
for name in ["mod_a.py", "mod_b.py", "mod_c.py", "mod_d.py"] {
fs::write(dir.join("pkg").join(name), "def f():\n pass\n").expect("write py");
}
fs::write(dir.join("build.rs"), "fn main() {}\n").expect("write rs");
let lang = super::compute_dominant_language(&dir).expect("dominant lang");
assert_eq!(lang, "py", "expected py dominant, got {lang}");
}
#[test]
fn compute_dominant_language_returns_none_below_min_file_count() {
let dir = fresh_test_dir("phase2j_below_min");
fs::write(dir.join("only.rs"), "fn x() {}\n").expect("write rs");
fs::write(dir.join("other.py"), "def y(): pass\n").expect("write py");
let lang = super::compute_dominant_language(&dir);
assert!(lang.is_none(), "expected None below 3 files, got {lang:?}");
}
#[test]
fn compute_dominant_language_skips_excluded_dirs() {
let dir = fresh_test_dir("phase2j_excluded_dirs");
fs::create_dir_all(dir.join("src")).expect("mkdir src");
fs::create_dir_all(dir.join("node_modules/foo")).expect("mkdir node_modules");
fs::create_dir_all(dir.join("target")).expect("mkdir target");
for name in ["a.rs", "b.rs", "c.rs"] {
fs::write(dir.join("src").join(name), "fn f() {}\n").expect("write src rs");
}
for i in 0..10 {
fs::write(
dir.join("node_modules/foo").join(format!("x{i}.js")),
"module.exports = {};\n",
)
.expect("write node_modules js");
}
for i in 0..10 {
fs::write(
dir.join("target").join(format!("build{i}.rs")),
"fn f() {}\n",
)
.expect("write target rs");
}
let lang = super::compute_dominant_language(&dir).expect("dominant lang");
assert_eq!(lang, "rs", "expected rs from src only, got {lang}");
}
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn tempfile_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"codelens-core-project-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
fs::create_dir_all(&dir).expect("create tempdir");
dir
}
}