Skip to main content

codelens_engine/
project.rs

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    /// Create a ProjectRoot, auto-detecting the actual root by walking up from
23    /// the given path until a root marker (.git, Cargo.toml, etc.) is found.
24    /// Falls back to the given path if no marker is found.
25    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    /// Create a ProjectRoot at the exact given path without auto-detection.
37    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 the path exists, verify the real (symlink-resolved) path also stays within root
67        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        // Resolve symlinks so the returned path matches what's stored in the index.
79        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
99// ── Shared directory exclusion & file collection ────────────────────────
100
101pub const EXCLUDED_DIRS: &[&str] = &[
102    // VCS & IDE
103    ".git",
104    ".idea",
105    ".vscode",
106    ".cursor",
107    ".claude",
108    ".claire",
109    // Build output
110    ".gradle",
111    "build",
112    "dist",
113    "out",
114    "node_modules",
115    "vendor",
116    "__pycache__",
117    "target",
118    ".next",
119    // Virtual environments
120    ".venv",
121    "venv",
122    ".tox",
123    "env",
124    // Caches (common polluters — can contain 40K+ symbols from deps)
125    ".cache",
126    ".ruff_cache",
127    ".pytest_cache",
128    ".mypy_cache",
129    ".fastembed_cache",
130    // Editor extensions (e.g. Antigravity/Windsurf bundled JS)
131    ".antigravity",
132    ".windsurf",
133    // Cloud & external mounts
134    "Library",
135    // CodeLens runtime
136    ".codelens",
137];
138
139/// Returns `true` if any component of `path` matches an excluded directory.
140pub 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
147/// Walk `root` collecting files that pass `filter`, skipping excluded dirs.
148pub 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
163/// Walk `root` and return the canonical extension tag of the dominant
164/// source language by file count (e.g. `rs`, `py`, `ts`, `go`). Returns
165/// `None` when the project contains fewer than 3 source files in total,
166/// or when no single language holds a clear plurality.
167///
168/// v1.5 Phase 2j MCP follow-up. The engine helper walks the project
169/// once at activation time and hands the result to the MCP tool layer,
170/// which then exports `CODELENS_EMBED_HINT_AUTO_LANG=<lang>` so the
171/// engine's `auto_hint_should_enable` gate can consult
172/// `language_supports_nl_stack` on subsequent embedding calls.
173///
174/// Walk scope is capped (16 k files) to avoid pathological cases on
175/// very large monorepos — the goal is to classify the project by
176/// dominant language, not to enumerate every file. Directories in
177/// `EXCLUDED_DIRS` are skipped (same filter as `collect_files`). Only
178/// files with an extension recognised by the language registry are
179/// counted; build artefacts / README / Markdown are ignored.
180///
181/// The returned tag is the canonical extension string (e.g. `rs`,
182/// `py`) — exactly what `CODELENS_EMBED_HINT_AUTO_LANG` expects and
183/// what `crate::embedding::language_supports_nl_stack` accepts.
184pub 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        // Only count extensions we know are source languages. This uses
212        // the language registry so future language additions stay in
213        // sync automatically. The import is local to avoid a cyclic
214        // module dependency with `lang_config`.
215        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    // Find the extension with the highest count. A strict plurality is
230    // not required (return whichever wins) but the caller can use the
231    // count ratio via `compute_dominant_language_with_count` if they
232    // want to impose a threshold. For v1.5 Phase 2j we accept any
233    // plurality and let the downstream `language_supports_nl_stack`
234    // decide whether the tag maps to an allowed language.
235    counts
236        .into_iter()
237        .max_by_key(|(_, count)| *count)
238        .map(|(ext, _)| ext)
239}
240
241/// Walk up from `start` until a directory containing a root marker is found.
242fn 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        // `~/.codelens` stores global CodeLens state, so treating the home directory as an
248        // inferred project root causes unrelated folders to collapse onto `$HOME`.
249        // If the user really wants to operate on `$HOME`, they can pass it explicitly.
250        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(&current, temp.as_deref())
255            {
256                continue;
257            }
258            if current.join(marker).exists() {
259                return Some(current);
260            }
261        }
262        // Don't go above home directory
263        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
294// ── Framework detection ─────────────────────────────────────────────────
295
296pub fn detect_frameworks(project: &Path) -> Vec<String> {
297    let mut frameworks = Vec::new();
298
299    // Python
300    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    // JavaScript/TypeScript
311    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    // Rust
328    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    // Go
341    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    // Java/Kotlin
349    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// ── Workspace/monorepo detection ────────────────────────────────────────
412
413#[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    // Cargo workspace
424    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                    // Glob pattern: "crates/*" → scan directory
435                    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                    // Explicit path: "crates/codelens-core"
454                    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    // npm workspace (package.json with "workspaces")
472    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    // Go workspace (go.work)
499    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        // Regression guard: agent worktrees are copies of the source tree and
553        // must never appear in walks (dead_code, embedding, symbol indexing).
554        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        // And the usual suspects stay excluded.
562        assert!(is_excluded(Path::new("node_modules/foo/index.js")));
563        assert!(is_excluded(Path::new("target/debug/build.rs")));
564        // Non-excluded paths should pass through.
565        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    /// Unique per-test subdirectory inside `tempfile_dir()` to avoid
686    /// parallel-execution collisions on the nanosecond-timestamp path.
687    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        // 5 Rust files, 1 Python file, 1 unknown extension file
697        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        // 4 Python files, 1 Rust file
713        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        // Only 2 source files (below MIN_FILES = 3)
727        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        // 3 real Rust source files
741        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        // 10 fake JS files inside node_modules that must be skipped
745        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        // 10 fake build artefacts in target/ that must be skipped
753        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        // Only the 3 src/*.rs files should be counted — not the 10
763        // node_modules JS files and not the 10 target build artefacts.
764        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}