Skip to main content

project_detect/
lib.rs

1//! Zero-config project type detection.
2//!
3//! Scans a directory for build system files (Cargo.toml, package.json, go.mod,
4//! etc.) and returns the detected [`ProjectKind`] with ecosystem-specific
5//! metadata. Supports 32 ecosystems out of the box.
6//!
7//! ```
8//! use project_detect::{detect, ProjectKind};
9//!
10//! if let Some(kind) = detect(".") {
11//!     println!("Detected: {} ({})", kind.label(), kind.detected_file());
12//! }
13//! ```
14
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18// -- Public types ------------------------------------------------------------
19
20/// Node.js package manager, detected from lockfile presence.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum NodePM {
23    Bun,
24    Pnpm,
25    Yarn,
26    Npm,
27}
28
29/// Kotlin build tool metadata.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum KotlinBuild {
32    Gradle { wrapper: bool },
33    Maven,
34}
35
36/// A detected project type with ecosystem-specific metadata.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ProjectKind {
39    // -- Language-specific (highest confidence) --
40    Cargo,
41    Go,
42    Elixir { escript: bool },
43    Python { uv: bool },
44    Node { manager: NodePM },
45    Kotlin { build: KotlinBuild },
46    Gradle { wrapper: bool },
47    Maven,
48    Ruby,
49    Swift,
50    Xcode { workspace: bool },
51    Zig,
52    DotNet { sln: bool },
53    Php,
54    Dart { flutter: bool },
55    Sbt,
56    Haskell { stack: bool },
57    Clojure { lein: bool },
58    Rebar,
59    Dune,
60    Perl,
61    Julia,
62    R { renv: bool },
63    Nim,
64    Crystal,
65    Vlang,
66    Gleam,
67    Lua,
68
69    // -- Build systems (lower confidence) --
70    Bazel,
71    Meson,
72    CMake,
73    Make,
74}
75
76impl ProjectKind {
77    /// Human-readable ecosystem name (e.g. "Rust", "Node.js").
78    pub fn label(&self) -> &'static str {
79        match self {
80            Self::Cargo => "Rust",
81            Self::Go => "Go",
82            Self::Elixir { .. } => "Elixir",
83            Self::Python { .. } => "Python",
84            Self::Node { .. } => "Node.js",
85            Self::Kotlin { .. } => "Kotlin",
86            Self::Gradle { .. } => "Gradle",
87            Self::Maven => "Maven",
88            Self::Ruby => "Ruby",
89            Self::Swift => "Swift",
90            Self::Xcode { .. } => "Xcode",
91            Self::Zig => "Zig",
92            Self::DotNet { .. } => ".NET",
93            Self::Php => "PHP",
94            Self::Dart { flutter: true } => "Flutter",
95            Self::Dart { flutter: false } => "Dart",
96            Self::Sbt => "Scala",
97            Self::Haskell { .. } => "Haskell",
98            Self::Clojure { .. } => "Clojure",
99            Self::Rebar => "Erlang",
100            Self::Dune => "OCaml",
101            Self::Perl => "Perl",
102            Self::Julia => "Julia",
103            Self::R { .. } => "R",
104            Self::Nim => "Nim",
105            Self::Crystal => "Crystal",
106            Self::Vlang => "V",
107            Self::Gleam => "Gleam",
108            Self::Lua => "Lua",
109            Self::Bazel => "Bazel",
110            Self::Meson => "Meson",
111            Self::CMake => "CMake",
112            Self::Make => "Make",
113        }
114    }
115
116    /// The file that triggered detection (e.g. "Cargo.toml", "package.json").
117    pub fn detected_file(&self) -> &'static str {
118        match self {
119            Self::Cargo => "Cargo.toml",
120            Self::Go => "go.mod",
121            Self::Elixir { .. } => "mix.exs",
122            Self::Python { .. } => "pyproject.toml",
123            Self::Node { .. } => "package.json",
124            Self::Kotlin {
125                build: KotlinBuild::Gradle { .. },
126            } => "build.gradle(.kts) + *.kt",
127            Self::Kotlin {
128                build: KotlinBuild::Maven,
129            } => "pom.xml + *.kt",
130            Self::Gradle { .. } => "build.gradle",
131            Self::Maven => "pom.xml",
132            Self::Ruby => "Gemfile",
133            Self::Swift => "Package.swift",
134            Self::Xcode { workspace: true } => "*.xcworkspace",
135            Self::Xcode { workspace: false } => "*.xcodeproj",
136            Self::Zig => "build.zig",
137            Self::DotNet { sln: true } => "*.sln",
138            Self::DotNet { sln: false } => "*.csproj",
139            Self::Php => "composer.json",
140            Self::Dart { .. } => "pubspec.yaml",
141            Self::Sbt => "build.sbt",
142            Self::Haskell { stack: true } => "stack.yaml",
143            Self::Haskell { stack: false } => "*.cabal",
144            Self::Clojure { lein: true } => "project.clj",
145            Self::Clojure { lein: false } => "deps.edn",
146            Self::Rebar => "rebar.config",
147            Self::Dune => "dune-project",
148            Self::Perl => "cpanfile",
149            Self::Julia => "Project.toml",
150            Self::R { renv: true } => "renv.lock",
151            Self::R { renv: false } => "DESCRIPTION",
152            Self::Nim => "*.nimble",
153            Self::Crystal => "shard.yml",
154            Self::Vlang => "v.mod",
155            Self::Gleam => "gleam.toml",
156            Self::Lua => "*.rockspec",
157            Self::Bazel => "MODULE.bazel",
158            Self::Meson => "meson.build",
159            Self::CMake => "CMakeLists.txt",
160            Self::Make => "Makefile",
161        }
162    }
163
164    /// Directories containing build artifacts for this project type.
165    pub fn artifact_dirs(&self) -> &'static [&'static str] {
166        match self {
167            Self::Cargo => &["target"],
168            Self::Go => &[],
169            Self::Elixir { .. } => &["_build", "deps"],
170            Self::Python { .. } => &["__pycache__", ".pytest_cache", "build", "dist", ".venv"],
171            Self::Node { .. } => &["node_modules", ".next", ".nuxt", ".turbo"],
172            Self::Kotlin {
173                build: KotlinBuild::Gradle { .. },
174            } => &["build", ".gradle"],
175            Self::Kotlin {
176                build: KotlinBuild::Maven,
177            } => &["target"],
178            Self::Gradle { .. } => &["build", ".gradle"],
179            Self::Maven => &["target"],
180            Self::Ruby => &[".bundle"],
181            Self::Swift => &[".build"],
182            Self::Xcode { .. } => &["build", "DerivedData"],
183            Self::Zig => &["zig-out", ".zig-cache"],
184            Self::DotNet { .. } => &["bin", "obj"],
185            Self::Php => &["vendor"],
186            Self::Dart { .. } => &[".dart_tool", "build"],
187            Self::Sbt => &["target", "project/target"],
188            Self::Haskell { stack: true } => &[".stack-work"],
189            Self::Haskell { stack: false } => &["dist-newstyle"],
190            Self::Clojure { lein: true } => &["target"],
191            Self::Clojure { lein: false } => &[".cpcache"],
192            Self::Rebar => &["_build"],
193            Self::Dune => &["_build"],
194            Self::Perl => &["blib", "_build"],
195            Self::Julia => &[],
196            Self::R { .. } => &["renv/library", ".Rproj.user"],
197            Self::Nim => &["nimcache"],
198            Self::Crystal => &["lib", ".shards"],
199            Self::Vlang => &[],
200            Self::Gleam => &["build"],
201            Self::Lua => &[],
202            Self::Bazel => &["bazel-bin", "bazel-out", "bazel-testlogs"],
203            Self::Meson => &["builddir"],
204            Self::CMake => &["build"],
205            Self::Make => &[],
206        }
207    }
208}
209
210// -- Detection ---------------------------------------------------------------
211
212/// Detect the project kind from files in `dir`.
213///
214/// Checks language-specific files first (high confidence), then falls back
215/// to generic build systems (lower confidence). Returns `None` if no
216/// recognized project files are found.
217#[must_use]
218pub fn detect(dir: impl AsRef<Path>) -> Option<ProjectKind> {
219    detect_in(dir.as_ref())
220}
221
222/// Like [`detect`], but walks up the directory tree if no project is found
223/// in `dir`. Returns the detected kind and the directory it was found in.
224///
225/// This handles the common case of running from a subdirectory inside a
226/// workspace (e.g. `tower/imp/` inside a Cargo workspace rooted at `tower/`).
227#[must_use]
228pub fn detect_walk(dir: impl AsRef<Path>) -> Option<(ProjectKind, PathBuf)> {
229    let mut current = dir.as_ref().to_path_buf();
230    loop {
231        if let Some(kind) = detect_in(&current) {
232            return Some((kind, current));
233        }
234        if !current.pop() {
235            return None;
236        }
237    }
238}
239
240fn detect_in(dir: &Path) -> Option<ProjectKind> {
241    // Language-specific build files — highest confidence
242    if dir.join("Cargo.toml").exists() {
243        return Some(ProjectKind::Cargo);
244    }
245    if dir.join("go.mod").exists() {
246        return Some(ProjectKind::Go);
247    }
248    if dir.join("mix.exs").exists() {
249        return Some(ProjectKind::Elixir {
250            escript: elixir_has_escript(dir),
251        });
252    }
253
254    // Python
255    if dir.join("pyproject.toml").exists()
256        || dir.join("setup.py").exists()
257        || dir.join("setup.cfg").exists()
258    {
259        return Some(ProjectKind::Python {
260            uv: command_on_path("uv"),
261        });
262    }
263
264    // Node.js
265    if dir.join("package.json").exists() {
266        return Some(ProjectKind::Node {
267            manager: detect_node_pm(dir),
268        });
269    }
270
271    // JVM
272    let has_gradle = dir.join("build.gradle").exists() || dir.join("build.gradle.kts").exists();
273    let has_maven = dir.join("pom.xml").exists();
274    let has_kotlin = has_kotlin_sources(dir);
275    if has_gradle && has_kotlin {
276        return Some(ProjectKind::Kotlin {
277            build: KotlinBuild::Gradle {
278                wrapper: dir.join("gradlew").exists(),
279            },
280        });
281    }
282    if has_maven && has_kotlin {
283        return Some(ProjectKind::Kotlin {
284            build: KotlinBuild::Maven,
285        });
286    }
287    if has_gradle {
288        return Some(ProjectKind::Gradle {
289            wrapper: dir.join("gradlew").exists(),
290        });
291    }
292    if has_maven {
293        return Some(ProjectKind::Maven);
294    }
295    if dir.join("build.sbt").exists() {
296        return Some(ProjectKind::Sbt);
297    }
298
299    // Ruby
300    if dir.join("Gemfile").exists() {
301        return Some(ProjectKind::Ruby);
302    }
303
304    // Swift
305    if dir.join("Package.swift").exists() {
306        return Some(ProjectKind::Swift);
307    }
308
309    // Xcode (Objective-C / Objective-C++ / Apple platform projects)
310    {
311        let has_workspace = has_extension_in_dir(dir, "xcworkspace");
312        let has_project = !has_workspace && has_extension_in_dir(dir, "xcodeproj");
313        if has_workspace || has_project {
314            return Some(ProjectKind::Xcode {
315                workspace: has_workspace,
316            });
317        }
318    }
319
320    // Zig
321    if dir.join("build.zig").exists() {
322        return Some(ProjectKind::Zig);
323    }
324
325    // .NET
326    {
327        let has_sln = has_extension_in_dir(dir, "sln");
328        let has_csproj = !has_sln && has_extension_in_dir(dir, "csproj");
329        if has_sln || has_csproj {
330            return Some(ProjectKind::DotNet { sln: has_sln });
331        }
332    }
333
334    // PHP
335    if dir.join("composer.json").exists() {
336        return Some(ProjectKind::Php);
337    }
338
339    // Dart / Flutter
340    if dir.join("pubspec.yaml").exists() {
341        let is_flutter = std::fs::read_to_string(dir.join("pubspec.yaml"))
342            .map(|c| c.contains("flutter:"))
343            .unwrap_or(false);
344        return Some(ProjectKind::Dart {
345            flutter: is_flutter,
346        });
347    }
348
349    // Haskell (stack.yaml preferred over *.cabal)
350    if dir.join("stack.yaml").exists() {
351        return Some(ProjectKind::Haskell { stack: true });
352    }
353    if has_extension_in_dir(dir, "cabal") {
354        return Some(ProjectKind::Haskell { stack: false });
355    }
356
357    // Clojure (project.clj = Leiningen, deps.edn = CLI/tools.deps)
358    if dir.join("project.clj").exists() {
359        return Some(ProjectKind::Clojure { lein: true });
360    }
361    if dir.join("deps.edn").exists() {
362        return Some(ProjectKind::Clojure { lein: false });
363    }
364
365    // Erlang
366    if dir.join("rebar.config").exists() {
367        return Some(ProjectKind::Rebar);
368    }
369
370    // OCaml
371    if dir.join("dune-project").exists() {
372        return Some(ProjectKind::Dune);
373    }
374
375    // Perl (cpanfile or Makefile.PL — checked before generic Make)
376    if dir.join("cpanfile").exists() || dir.join("Makefile.PL").exists() {
377        return Some(ProjectKind::Perl);
378    }
379
380    // Julia
381    if dir.join("Project.toml").exists() {
382        return Some(ProjectKind::Julia);
383    }
384
385    // R
386    if dir.join("DESCRIPTION").exists() || dir.join("renv.lock").exists() {
387        return Some(ProjectKind::R {
388            renv: dir.join("renv.lock").exists(),
389        });
390    }
391
392    // Nim
393    if has_extension_in_dir(dir, "nimble") {
394        return Some(ProjectKind::Nim);
395    }
396
397    // Crystal
398    if dir.join("shard.yml").exists() {
399        return Some(ProjectKind::Crystal);
400    }
401
402    // V
403    if dir.join("v.mod").exists() {
404        return Some(ProjectKind::Vlang);
405    }
406
407    // Gleam
408    if dir.join("gleam.toml").exists() {
409        return Some(ProjectKind::Gleam);
410    }
411
412    // Lua (LuaRocks)
413    if has_extension_in_dir(dir, "rockspec") {
414        return Some(ProjectKind::Lua);
415    }
416
417    // -- Build systems (lower confidence) ------------------------------------
418
419    // Bazel (MODULE.bazel = Bzlmod, WORKSPACE = legacy)
420    // Use is_file() for WORKSPACE — a directory named WORKSPACE is common
421    // (e.g. ~/WORKSPACE) and should not trigger detection.
422    if dir.join("MODULE.bazel").is_file() || dir.join("WORKSPACE").is_file() {
423        return Some(ProjectKind::Bazel);
424    }
425
426    if dir.join("meson.build").exists() {
427        return Some(ProjectKind::Meson);
428    }
429    if dir.join("CMakeLists.txt").exists() {
430        return Some(ProjectKind::CMake);
431    }
432    if dir.join("Makefile").exists()
433        || dir.join("makefile").exists()
434        || dir.join("GNUmakefile").exists()
435    {
436        return Some(ProjectKind::Make);
437    }
438
439    None
440}
441
442/// Detect the project kind, walking up from `dir` if nothing is found.
443///
444/// Convenience wrapper over [`detect_walk`] that returns only the kind.
445#[must_use]
446pub fn detect_nearest(dir: impl AsRef<Path>) -> Option<ProjectKind> {
447    detect_walk(dir).map(|(kind, _)| kind)
448}
449
450/// Check whether a command exists on `$PATH`.
451#[must_use]
452pub fn command_on_path(name: &str) -> bool {
453    Command::new("which")
454        .arg(name)
455        .stdout(Stdio::null())
456        .stderr(Stdio::null())
457        .status()
458        .map(|s| s.success())
459        .unwrap_or(false)
460}
461
462/// Formatted table of all supported project types for error messages.
463#[must_use]
464pub fn supported_table() -> String {
465    let entries = [
466        ("Cargo.toml", "cargo build"),
467        ("go.mod", "go build ./..."),
468        ("mix.exs", "mix compile"),
469        ("pyproject.toml", "pip install . (or uv)"),
470        ("setup.py", "pip install ."),
471        ("setup.cfg", "pip install ."),
472        ("package.json", "npm/yarn/pnpm/bun install"),
473        ("build.gradle(.kts) + *.kt", "./gradlew build / mvn package"),
474        ("build.gradle", "./gradlew build"),
475        ("pom.xml", "mvn package"),
476        ("build.sbt", "sbt compile"),
477        ("Gemfile", "bundle install"),
478        ("Package.swift", "swift build"),
479        ("*.xcworkspace / *.xcodeproj", "xcodebuild build"),
480        ("build.zig", "zig build"),
481        ("*.csproj", "dotnet build"),
482        ("composer.json", "composer install"),
483        ("pubspec.yaml", "dart pub get / flutter pub get"),
484        ("stack.yaml", "stack build"),
485        ("*.cabal", "cabal build"),
486        ("project.clj", "lein compile"),
487        ("deps.edn", "clj -M:build"),
488        ("rebar.config", "rebar3 compile"),
489        ("dune-project", "dune build"),
490        ("cpanfile", "cpanm --installdeps ."),
491        ("Project.toml", "julia -e 'using Pkg; Pkg.instantiate()'"),
492        ("DESCRIPTION / renv.lock", "R CMD build . / renv::restore()"),
493        ("*.nimble", "nimble build"),
494        ("shard.yml", "shards build"),
495        ("v.mod", "v ."),
496        ("gleam.toml", "gleam build"),
497        ("*.rockspec", "luarocks make"),
498        ("MODULE.bazel", "bazel build //..."),
499        ("meson.build", "meson setup + compile"),
500        ("CMakeLists.txt", "cmake -B build && cmake --build build"),
501        ("Makefile", "make"),
502    ];
503
504    let mut out = String::from("  supported project files:\n");
505    for (file, cmd) in entries {
506        out.push_str(&format!("    {file:<18} → {cmd}\n"));
507    }
508    out
509}
510
511// -- Node.js package.json helpers --------------------------------------------
512
513/// Check whether a Node project's `package.json` contains a specific script.
514#[must_use]
515pub fn node_has_script(dir: &Path, name: &str) -> bool {
516    let Ok(content) = std::fs::read_to_string(dir.join("package.json")) else {
517        return false;
518    };
519    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
520        return false;
521    };
522    json.get("scripts")
523        .and_then(|s| s.get(name))
524        .and_then(|v| v.as_str())
525        .is_some_and(|s| !s.is_empty())
526}
527
528/// Check whether a Node project's `package.json` has a `"bin"` field.
529#[must_use]
530pub fn node_has_bin(dir: &Path) -> bool {
531    let Ok(content) = std::fs::read_to_string(dir.join("package.json")) else {
532        return false;
533    };
534    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
535        return false;
536    };
537    match json.get("bin") {
538        Some(serde_json::Value::String(s)) => !s.is_empty(),
539        Some(serde_json::Value::Object(m)) => !m.is_empty(),
540        _ => false,
541    }
542}
543
544// -- Private helpers ---------------------------------------------------------
545
546fn elixir_has_escript(dir: &Path) -> bool {
547    if let Ok(content) = std::fs::read_to_string(dir.join("mix.exs")) {
548        if content.contains("escript:") {
549            return true;
550        }
551    }
552    let apps_dir = dir.join("apps");
553    if apps_dir.is_dir() {
554        if let Ok(entries) = std::fs::read_dir(&apps_dir) {
555            for entry in entries.flatten() {
556                let child_mix = entry.path().join("mix.exs");
557                if let Ok(content) = std::fs::read_to_string(&child_mix) {
558                    if content.contains("escript:") {
559                        return true;
560                    }
561                }
562            }
563        }
564    }
565    false
566}
567
568fn has_extension_in_dir(dir: &Path, ext: &str) -> bool {
569    std::fs::read_dir(dir)
570        .ok()
571        .map(|entries| {
572            entries
573                .flatten()
574                .any(|e| e.path().extension().is_some_and(|x| x == ext))
575        })
576        .unwrap_or(false)
577}
578
579fn has_extension_in_tree(dir: &Path, exts: &[&str]) -> bool {
580    if !dir.is_dir() {
581        return false;
582    }
583
584    let mut stack = vec![dir.to_path_buf()];
585    while let Some(current) = stack.pop() {
586        let Ok(entries) = std::fs::read_dir(&current) else {
587            continue;
588        };
589        for entry in entries.flatten() {
590            let path = entry.path();
591            if path.is_dir() {
592                let skip = path
593                    .file_name()
594                    .and_then(|name| name.to_str())
595                    .is_some_and(|name| {
596                        matches!(
597                            name,
598                            ".git" | ".gradle" | "build" | "target" | "node_modules"
599                        )
600                    });
601                if !skip {
602                    stack.push(path);
603                }
604                continue;
605            }
606
607            let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
608                continue;
609            };
610            if exts.contains(&ext) {
611                return true;
612            }
613        }
614    }
615
616    false
617}
618
619fn has_file_suffix_in_dir(dir: &Path, suffix: &str) -> bool {
620    std::fs::read_dir(dir)
621        .ok()
622        .map(|entries| {
623            entries.flatten().any(|entry| {
624                entry
625                    .file_name()
626                    .to_str()
627                    .is_some_and(|name| name.ends_with(suffix))
628            })
629        })
630        .unwrap_or(false)
631}
632
633fn has_kotlin_sources(dir: &Path) -> bool {
634    has_extension_in_tree(&dir.join("src"), &["kt", "kts"])
635        || has_extension_in_tree(&dir.join("app"), &["kt", "kts"])
636        || has_extension_in_dir(dir, "kt")
637        || has_file_suffix_in_dir(dir, ".main.kts")
638}
639
640fn detect_node_pm(dir: &Path) -> NodePM {
641    if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
642        NodePM::Bun
643    } else if dir.join("pnpm-lock.yaml").exists() {
644        NodePM::Pnpm
645    } else if dir.join("yarn.lock").exists() {
646        NodePM::Yarn
647    } else {
648        NodePM::Npm
649    }
650}
651
652// -- Workspace detection -----------------------------------------------------
653
654/// A package in a workspace that has a "dev" script.
655#[derive(Debug, Clone)]
656pub struct WorkspacePackage {
657    pub name: String,
658    pub path: PathBuf,
659    pub dev_script: String,
660}
661
662/// Detect Node.js workspace packages that have a "dev" script.
663#[must_use]
664pub fn detect_node_workspace(dir: &Path) -> Option<Vec<WorkspacePackage>> {
665    let patterns =
666        read_pnpm_workspace_patterns(dir).or_else(|| read_npm_workspace_patterns(dir))?;
667    let mut packages = Vec::new();
668    for pattern in &patterns {
669        collect_workspace_packages(dir, pattern, &mut packages);
670    }
671    packages.sort_by(|a, b| a.name.cmp(&b.name));
672    Some(packages)
673}
674
675fn read_pnpm_workspace_patterns(dir: &Path) -> Option<Vec<String>> {
676    let content = std::fs::read_to_string(dir.join("pnpm-workspace.yaml")).ok()?;
677    let mut patterns = Vec::new();
678    let mut in_packages = false;
679    for line in content.lines() {
680        let trimmed = line.trim();
681        if trimmed == "packages:" {
682            in_packages = true;
683            continue;
684        }
685        if in_packages {
686            if !trimmed.starts_with('-') {
687                if !trimmed.is_empty() {
688                    break;
689                }
690                continue;
691            }
692            let value = trimmed
693                .trim_start_matches('-')
694                .trim()
695                .trim_matches('"')
696                .trim_matches('\'');
697            if value.starts_with('!') {
698                continue;
699            }
700            if !value.is_empty() {
701                patterns.push(value.to_string());
702            }
703        }
704    }
705    if patterns.is_empty() {
706        return None;
707    }
708    Some(patterns)
709}
710
711fn read_npm_workspace_patterns(dir: &Path) -> Option<Vec<String>> {
712    let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
713    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
714    let arr = json.get("workspaces")?.as_array()?;
715    let patterns: Vec<String> = arr
716        .iter()
717        .filter_map(|v| v.as_str())
718        .filter(|s| !s.starts_with('!'))
719        .map(|s| s.to_string())
720        .collect();
721    if patterns.is_empty() {
722        return None;
723    }
724    Some(patterns)
725}
726
727fn collect_workspace_packages(root: &Path, pattern: &str, out: &mut Vec<WorkspacePackage>) {
728    let prefix = match pattern.strip_suffix("/*") {
729        Some(p) => p,
730        None => pattern,
731    };
732    let search_dir = root.join(prefix);
733    let entries = match std::fs::read_dir(&search_dir) {
734        Ok(e) => e,
735        Err(_) => return,
736    };
737    for entry in entries.flatten() {
738        let pkg_dir = entry.path();
739        if !pkg_dir.is_dir() {
740            continue;
741        }
742        let pkg_json_path = pkg_dir.join("package.json");
743        let content = match std::fs::read_to_string(&pkg_json_path) {
744            Ok(c) => c,
745            Err(_) => continue,
746        };
747        let json: serde_json::Value = match serde_json::from_str(&content) {
748            Ok(v) => v,
749            Err(_) => continue,
750        };
751        if let Some(dev) = json
752            .get("scripts")
753            .and_then(|s| s.get("dev"))
754            .and_then(|d| d.as_str())
755        {
756            let name = pkg_dir
757                .file_name()
758                .map(|n| n.to_string_lossy().into_owned())
759                .unwrap_or_default();
760            let abs_path = match pkg_dir.canonicalize() {
761                Ok(p) => p,
762                Err(_) => pkg_dir,
763            };
764            out.push(WorkspacePackage {
765                name,
766                path: abs_path,
767                dev_script: dev.to_string(),
768            });
769        }
770    }
771}
772
773// -- Tests -------------------------------------------------------------------
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use std::fs;
779    use tempfile::tempdir;
780
781    #[test]
782    fn detect_cargo() {
783        let dir = tempdir().unwrap();
784        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
785        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
786    }
787
788    #[test]
789    fn detect_go() {
790        let dir = tempdir().unwrap();
791        fs::write(dir.path().join("go.mod"), "").unwrap();
792        assert_eq!(detect(dir.path()), Some(ProjectKind::Go));
793    }
794
795    #[test]
796    fn detect_elixir() {
797        let dir = tempdir().unwrap();
798        fs::write(dir.path().join("mix.exs"), "").unwrap();
799        assert!(matches!(
800            detect(dir.path()),
801            Some(ProjectKind::Elixir { .. })
802        ));
803    }
804
805    #[test]
806    fn detect_python_pyproject() {
807        let dir = tempdir().unwrap();
808        fs::write(dir.path().join("pyproject.toml"), "").unwrap();
809        assert!(matches!(
810            detect(dir.path()),
811            Some(ProjectKind::Python { .. })
812        ));
813    }
814
815    #[test]
816    fn detect_python_setup_py() {
817        let dir = tempdir().unwrap();
818        fs::write(dir.path().join("setup.py"), "").unwrap();
819        assert!(matches!(
820            detect(dir.path()),
821            Some(ProjectKind::Python { .. })
822        ));
823    }
824
825    #[test]
826    fn detect_python_setup_cfg() {
827        let dir = tempdir().unwrap();
828        fs::write(dir.path().join("setup.cfg"), "").unwrap();
829        assert!(matches!(
830            detect(dir.path()),
831            Some(ProjectKind::Python { .. })
832        ));
833    }
834
835    #[test]
836    fn detect_node_npm_default() {
837        let dir = tempdir().unwrap();
838        fs::write(dir.path().join("package.json"), "{}").unwrap();
839        assert_eq!(
840            detect(dir.path()),
841            Some(ProjectKind::Node {
842                manager: NodePM::Npm
843            })
844        );
845    }
846
847    #[test]
848    fn detect_node_yarn() {
849        let dir = tempdir().unwrap();
850        fs::write(dir.path().join("package.json"), "{}").unwrap();
851        fs::write(dir.path().join("yarn.lock"), "").unwrap();
852        assert_eq!(
853            detect(dir.path()),
854            Some(ProjectKind::Node {
855                manager: NodePM::Yarn
856            })
857        );
858    }
859
860    #[test]
861    fn detect_node_pnpm() {
862        let dir = tempdir().unwrap();
863        fs::write(dir.path().join("package.json"), "{}").unwrap();
864        fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
865        assert_eq!(
866            detect(dir.path()),
867            Some(ProjectKind::Node {
868                manager: NodePM::Pnpm
869            })
870        );
871    }
872
873    #[test]
874    fn detect_node_bun() {
875        let dir = tempdir().unwrap();
876        fs::write(dir.path().join("package.json"), "{}").unwrap();
877        fs::write(dir.path().join("bun.lockb"), "").unwrap();
878        assert_eq!(
879            detect(dir.path()),
880            Some(ProjectKind::Node {
881                manager: NodePM::Bun
882            })
883        );
884    }
885
886    #[test]
887    fn detect_gradle_with_wrapper() {
888        let dir = tempdir().unwrap();
889        fs::write(dir.path().join("build.gradle"), "").unwrap();
890        fs::write(dir.path().join("gradlew"), "").unwrap();
891        assert_eq!(
892            detect(dir.path()),
893            Some(ProjectKind::Gradle { wrapper: true })
894        );
895    }
896
897    #[test]
898    fn detect_gradle_kts_no_wrapper() {
899        let dir = tempdir().unwrap();
900        fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
901        assert_eq!(
902            detect(dir.path()),
903            Some(ProjectKind::Gradle { wrapper: false })
904        );
905    }
906
907    #[test]
908    fn detect_kotlin_gradle() {
909        let dir = tempdir().unwrap();
910        let src = dir.path().join("src/main/kotlin");
911        fs::create_dir_all(&src).unwrap();
912        fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
913        fs::write(dir.path().join("gradlew"), "").unwrap();
914        fs::write(src.join("App.kt"), "fun main() = println(\"hi\")").unwrap();
915        assert_eq!(
916            detect(dir.path()),
917            Some(ProjectKind::Kotlin {
918                build: KotlinBuild::Gradle { wrapper: true }
919            })
920        );
921    }
922
923    #[test]
924    fn detect_kotlin_maven() {
925        let dir = tempdir().unwrap();
926        let src = dir.path().join("src/main/kotlin");
927        fs::create_dir_all(&src).unwrap();
928        fs::write(dir.path().join("pom.xml"), "").unwrap();
929        fs::write(src.join("App.kt"), "fun main() = println(\"hi\")").unwrap();
930        assert_eq!(
931            detect(dir.path()),
932            Some(ProjectKind::Kotlin {
933                build: KotlinBuild::Maven
934            })
935        );
936    }
937
938    #[test]
939    fn detect_maven() {
940        let dir = tempdir().unwrap();
941        fs::write(dir.path().join("pom.xml"), "").unwrap();
942        assert_eq!(detect(dir.path()), Some(ProjectKind::Maven));
943    }
944
945    #[test]
946    fn detect_sbt() {
947        let dir = tempdir().unwrap();
948        fs::write(dir.path().join("build.sbt"), "").unwrap();
949        assert_eq!(detect(dir.path()), Some(ProjectKind::Sbt));
950    }
951
952    #[test]
953    fn detect_ruby() {
954        let dir = tempdir().unwrap();
955        fs::write(dir.path().join("Gemfile"), "").unwrap();
956        assert_eq!(detect(dir.path()), Some(ProjectKind::Ruby));
957    }
958
959    #[test]
960    fn detect_swift() {
961        let dir = tempdir().unwrap();
962        fs::write(dir.path().join("Package.swift"), "").unwrap();
963        assert_eq!(detect(dir.path()), Some(ProjectKind::Swift));
964    }
965
966    #[test]
967    fn detect_xcodeproj() {
968        let dir = tempdir().unwrap();
969        fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap();
970        assert_eq!(
971            detect(dir.path()),
972            Some(ProjectKind::Xcode { workspace: false })
973        );
974    }
975
976    #[test]
977    fn detect_xcworkspace_preferred_over_xcodeproj() {
978        let dir = tempdir().unwrap();
979        fs::create_dir(dir.path().join("MyApp.xcworkspace")).unwrap();
980        fs::create_dir(dir.path().join("MyApp.xcodeproj")).unwrap();
981        assert_eq!(
982            detect(dir.path()),
983            Some(ProjectKind::Xcode { workspace: true })
984        );
985    }
986
987    #[test]
988    fn detect_zig() {
989        let dir = tempdir().unwrap();
990        fs::write(dir.path().join("build.zig"), "").unwrap();
991        assert_eq!(detect(dir.path()), Some(ProjectKind::Zig));
992    }
993
994    #[test]
995    fn detect_dotnet_csproj() {
996        let dir = tempdir().unwrap();
997        fs::write(dir.path().join("MyApp.csproj"), "").unwrap();
998        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: false }));
999    }
1000
1001    #[test]
1002    fn detect_dotnet_sln() {
1003        let dir = tempdir().unwrap();
1004        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
1005        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: true }));
1006    }
1007
1008    #[test]
1009    fn detect_dotnet_sln_preferred_over_csproj() {
1010        let dir = tempdir().unwrap();
1011        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
1012        fs::write(dir.path().join("MyApp.csproj"), "").unwrap();
1013        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: true }));
1014    }
1015
1016    #[test]
1017    fn detect_php() {
1018        let dir = tempdir().unwrap();
1019        fs::write(dir.path().join("composer.json"), "{}").unwrap();
1020        assert_eq!(detect(dir.path()), Some(ProjectKind::Php));
1021    }
1022
1023    #[test]
1024    fn detect_dart() {
1025        let dir = tempdir().unwrap();
1026        fs::write(dir.path().join("pubspec.yaml"), "name: myapp").unwrap();
1027        assert_eq!(
1028            detect(dir.path()),
1029            Some(ProjectKind::Dart { flutter: false })
1030        );
1031    }
1032
1033    #[test]
1034    fn detect_flutter() {
1035        let dir = tempdir().unwrap();
1036        fs::write(
1037            dir.path().join("pubspec.yaml"),
1038            "name: myapp\nflutter:\n  sdk: flutter",
1039        )
1040        .unwrap();
1041        assert_eq!(
1042            detect(dir.path()),
1043            Some(ProjectKind::Dart { flutter: true })
1044        );
1045    }
1046
1047    #[test]
1048    fn detect_haskell_stack() {
1049        let dir = tempdir().unwrap();
1050        fs::write(dir.path().join("stack.yaml"), "").unwrap();
1051        assert_eq!(
1052            detect(dir.path()),
1053            Some(ProjectKind::Haskell { stack: true })
1054        );
1055    }
1056
1057    #[test]
1058    fn detect_haskell_cabal() {
1059        let dir = tempdir().unwrap();
1060        fs::write(dir.path().join("mylib.cabal"), "").unwrap();
1061        assert_eq!(
1062            detect(dir.path()),
1063            Some(ProjectKind::Haskell { stack: false })
1064        );
1065    }
1066
1067    #[test]
1068    fn detect_haskell_stack_preferred_over_cabal() {
1069        let dir = tempdir().unwrap();
1070        fs::write(dir.path().join("stack.yaml"), "").unwrap();
1071        fs::write(dir.path().join("mylib.cabal"), "").unwrap();
1072        assert_eq!(
1073            detect(dir.path()),
1074            Some(ProjectKind::Haskell { stack: true })
1075        );
1076    }
1077
1078    #[test]
1079    fn detect_clojure_lein() {
1080        let dir = tempdir().unwrap();
1081        fs::write(dir.path().join("project.clj"), "").unwrap();
1082        assert_eq!(
1083            detect(dir.path()),
1084            Some(ProjectKind::Clojure { lein: true })
1085        );
1086    }
1087
1088    #[test]
1089    fn detect_clojure_deps() {
1090        let dir = tempdir().unwrap();
1091        fs::write(dir.path().join("deps.edn"), "").unwrap();
1092        assert_eq!(
1093            detect(dir.path()),
1094            Some(ProjectKind::Clojure { lein: false })
1095        );
1096    }
1097
1098    #[test]
1099    fn detect_rebar() {
1100        let dir = tempdir().unwrap();
1101        fs::write(dir.path().join("rebar.config"), "").unwrap();
1102        assert_eq!(detect(dir.path()), Some(ProjectKind::Rebar));
1103    }
1104
1105    #[test]
1106    fn detect_dune() {
1107        let dir = tempdir().unwrap();
1108        fs::write(dir.path().join("dune-project"), "").unwrap();
1109        assert_eq!(detect(dir.path()), Some(ProjectKind::Dune));
1110    }
1111
1112    #[test]
1113    fn detect_perl_cpanfile() {
1114        let dir = tempdir().unwrap();
1115        fs::write(dir.path().join("cpanfile"), "").unwrap();
1116        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
1117    }
1118
1119    #[test]
1120    fn detect_perl_makefile_pl() {
1121        let dir = tempdir().unwrap();
1122        fs::write(dir.path().join("Makefile.PL"), "").unwrap();
1123        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
1124    }
1125
1126    #[test]
1127    fn detect_julia() {
1128        let dir = tempdir().unwrap();
1129        fs::write(dir.path().join("Project.toml"), "").unwrap();
1130        assert_eq!(detect(dir.path()), Some(ProjectKind::Julia));
1131    }
1132
1133    #[test]
1134    fn detect_r_description() {
1135        let dir = tempdir().unwrap();
1136        fs::write(dir.path().join("DESCRIPTION"), "Package: demo").unwrap();
1137        assert_eq!(detect(dir.path()), Some(ProjectKind::R { renv: false }));
1138    }
1139
1140    #[test]
1141    fn detect_r_renv_lock() {
1142        let dir = tempdir().unwrap();
1143        fs::write(dir.path().join("renv.lock"), "{}").unwrap();
1144        assert_eq!(detect(dir.path()), Some(ProjectKind::R { renv: true }));
1145    }
1146
1147    #[test]
1148    fn detect_nim() {
1149        let dir = tempdir().unwrap();
1150        fs::write(dir.path().join("myapp.nimble"), "").unwrap();
1151        assert_eq!(detect(dir.path()), Some(ProjectKind::Nim));
1152    }
1153
1154    #[test]
1155    fn detect_crystal() {
1156        let dir = tempdir().unwrap();
1157        fs::write(dir.path().join("shard.yml"), "").unwrap();
1158        assert_eq!(detect(dir.path()), Some(ProjectKind::Crystal));
1159    }
1160
1161    #[test]
1162    fn detect_vlang() {
1163        let dir = tempdir().unwrap();
1164        fs::write(dir.path().join("v.mod"), "").unwrap();
1165        assert_eq!(detect(dir.path()), Some(ProjectKind::Vlang));
1166    }
1167
1168    #[test]
1169    fn detect_gleam() {
1170        let dir = tempdir().unwrap();
1171        fs::write(dir.path().join("gleam.toml"), "").unwrap();
1172        assert_eq!(detect(dir.path()), Some(ProjectKind::Gleam));
1173    }
1174
1175    #[test]
1176    fn detect_lua() {
1177        let dir = tempdir().unwrap();
1178        fs::write(dir.path().join("mylib-1.0-1.rockspec"), "").unwrap();
1179        assert_eq!(detect(dir.path()), Some(ProjectKind::Lua));
1180    }
1181
1182    #[test]
1183    fn detect_bazel_module() {
1184        let dir = tempdir().unwrap();
1185        fs::write(dir.path().join("MODULE.bazel"), "").unwrap();
1186        assert_eq!(detect(dir.path()), Some(ProjectKind::Bazel));
1187    }
1188
1189    #[test]
1190    fn detect_bazel_workspace() {
1191        let dir = tempdir().unwrap();
1192        fs::write(dir.path().join("WORKSPACE"), "").unwrap();
1193        assert_eq!(detect(dir.path()), Some(ProjectKind::Bazel));
1194    }
1195
1196    #[test]
1197    fn workspace_directory_does_not_trigger_bazel() {
1198        let dir = tempdir().unwrap();
1199        fs::create_dir(dir.path().join("WORKSPACE")).unwrap();
1200        assert_eq!(detect(dir.path()), None);
1201    }
1202
1203    #[test]
1204    fn detect_meson() {
1205        let dir = tempdir().unwrap();
1206        fs::write(dir.path().join("meson.build"), "").unwrap();
1207        assert_eq!(detect(dir.path()), Some(ProjectKind::Meson));
1208    }
1209
1210    #[test]
1211    fn detect_cmake() {
1212        let dir = tempdir().unwrap();
1213        fs::write(dir.path().join("CMakeLists.txt"), "").unwrap();
1214        assert_eq!(detect(dir.path()), Some(ProjectKind::CMake));
1215    }
1216
1217    #[test]
1218    fn detect_makefile() {
1219        let dir = tempdir().unwrap();
1220        fs::write(dir.path().join("Makefile"), "").unwrap();
1221        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1222    }
1223
1224    #[test]
1225    fn detect_makefile_lowercase() {
1226        let dir = tempdir().unwrap();
1227        fs::write(dir.path().join("makefile"), "").unwrap();
1228        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1229    }
1230
1231    #[test]
1232    fn detect_gnumakefile() {
1233        let dir = tempdir().unwrap();
1234        fs::write(dir.path().join("GNUmakefile"), "").unwrap();
1235        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1236    }
1237
1238    #[test]
1239    fn detect_empty_dir_returns_none() {
1240        let dir = tempdir().unwrap();
1241        assert_eq!(detect(dir.path()), None);
1242    }
1243
1244    // -- Priority: language-specific wins over generic -----------------------
1245
1246    #[test]
1247    fn cargo_wins_over_makefile() {
1248        let dir = tempdir().unwrap();
1249        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1250        fs::write(dir.path().join("Makefile"), "").unwrap();
1251        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
1252    }
1253
1254    #[test]
1255    fn go_wins_over_makefile() {
1256        let dir = tempdir().unwrap();
1257        fs::write(dir.path().join("go.mod"), "").unwrap();
1258        fs::write(dir.path().join("Makefile"), "").unwrap();
1259        assert_eq!(detect(dir.path()), Some(ProjectKind::Go));
1260    }
1261
1262    #[test]
1263    fn node_wins_over_cmake() {
1264        let dir = tempdir().unwrap();
1265        fs::write(dir.path().join("package.json"), "{}").unwrap();
1266        fs::write(dir.path().join("CMakeLists.txt"), "").unwrap();
1267        assert_eq!(
1268            detect(dir.path()),
1269            Some(ProjectKind::Node {
1270                manager: NodePM::Npm
1271            })
1272        );
1273    }
1274
1275    #[test]
1276    fn cargo_wins_over_node() {
1277        let dir = tempdir().unwrap();
1278        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1279        fs::write(dir.path().join("package.json"), "{}").unwrap();
1280        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
1281    }
1282
1283    #[test]
1284    fn perl_makefile_pl_does_not_trigger_make() {
1285        let dir = tempdir().unwrap();
1286        fs::write(dir.path().join("Makefile.PL"), "").unwrap();
1287        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
1288    }
1289
1290    #[test]
1291    fn gleam_wins_over_bazel() {
1292        let dir = tempdir().unwrap();
1293        fs::write(dir.path().join("gleam.toml"), "").unwrap();
1294        fs::write(dir.path().join("WORKSPACE"), "").unwrap();
1295        assert_eq!(detect(dir.path()), Some(ProjectKind::Gleam));
1296    }
1297
1298    #[test]
1299    fn kotlin_wins_over_gradle() {
1300        let dir = tempdir().unwrap();
1301        let src = dir.path().join("src/main/kotlin");
1302        fs::create_dir_all(&src).unwrap();
1303        fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
1304        fs::write(src.join("App.kt"), "fun main() = println(\"hi\")").unwrap();
1305        assert_eq!(
1306            detect(dir.path()),
1307            Some(ProjectKind::Kotlin {
1308                build: KotlinBuild::Gradle { wrapper: false }
1309            })
1310        );
1311    }
1312
1313    // -- artifact_dirs -------------------------------------------------------
1314
1315    #[test]
1316    fn cargo_artifacts_include_target() {
1317        assert!(ProjectKind::Cargo.artifact_dirs().contains(&"target"));
1318    }
1319
1320    #[test]
1321    fn swift_artifacts_include_build() {
1322        assert!(ProjectKind::Swift.artifact_dirs().contains(&".build"));
1323    }
1324
1325    #[test]
1326    fn kotlin_gradle_artifacts_include_build() {
1327        let dirs = ProjectKind::Kotlin {
1328            build: KotlinBuild::Gradle { wrapper: false },
1329        }
1330        .artifact_dirs();
1331        assert!(dirs.contains(&"build"));
1332        assert!(dirs.contains(&".gradle"));
1333    }
1334
1335    #[test]
1336    fn dotnet_artifacts_include_bin_obj() {
1337        let kind = ProjectKind::DotNet { sln: false };
1338        assert!(kind.artifact_dirs().contains(&"bin"));
1339        assert!(kind.artifact_dirs().contains(&"obj"));
1340    }
1341
1342    #[test]
1343    fn zig_artifacts_include_zig_out() {
1344        let dirs = ProjectKind::Zig.artifact_dirs();
1345        assert!(dirs.contains(&"zig-out"));
1346        assert!(dirs.contains(&".zig-cache"));
1347    }
1348
1349    #[test]
1350    fn node_artifacts_include_node_modules() {
1351        let kind = ProjectKind::Node {
1352            manager: NodePM::Npm,
1353        };
1354        assert!(kind.artifact_dirs().contains(&"node_modules"));
1355    }
1356
1357    #[test]
1358    fn php_artifacts_include_vendor() {
1359        assert!(ProjectKind::Php.artifact_dirs().contains(&"vendor"));
1360    }
1361
1362    #[test]
1363    fn dart_artifacts_include_dart_tool() {
1364        assert!(ProjectKind::Dart { flutter: false }
1365            .artifact_dirs()
1366            .contains(&".dart_tool"));
1367    }
1368
1369    #[test]
1370    fn haskell_stack_artifacts() {
1371        assert!(ProjectKind::Haskell { stack: true }
1372            .artifact_dirs()
1373            .contains(&".stack-work"));
1374    }
1375
1376    #[test]
1377    fn haskell_cabal_artifacts() {
1378        assert!(ProjectKind::Haskell { stack: false }
1379            .artifact_dirs()
1380            .contains(&"dist-newstyle"));
1381    }
1382
1383    #[test]
1384    fn bazel_artifacts() {
1385        let dirs = ProjectKind::Bazel.artifact_dirs();
1386        assert!(dirs.contains(&"bazel-bin"));
1387        assert!(dirs.contains(&"bazel-out"));
1388    }
1389
1390    #[test]
1391    fn r_artifacts_include_renv_library() {
1392        assert!(ProjectKind::R { renv: true }
1393            .artifact_dirs()
1394            .contains(&"renv/library"));
1395    }
1396
1397    // -- Workspace detection -------------------------------------------------
1398
1399    fn create_pkg(parent: &Path, name: &str, dev_script: Option<&str>) {
1400        let pkg = parent.join(name);
1401        fs::create_dir_all(&pkg).unwrap();
1402        let scripts = match dev_script {
1403            Some(s) => format!(r#", "scripts": {{ "dev": "{s}" }}"#),
1404            None => String::new(),
1405        };
1406        fs::write(
1407            pkg.join("package.json"),
1408            format!(r#"{{ "name": "{name}"{scripts} }}"#),
1409        )
1410        .unwrap();
1411    }
1412
1413    #[test]
1414    fn detect_pnpm_workspace() {
1415        let dir = tempdir().unwrap();
1416        let root = dir.path();
1417        fs::write(root.join("package.json"), r#"{ "name": "root" }"#).unwrap();
1418        fs::write(
1419            root.join("pnpm-workspace.yaml"),
1420            "packages:\n  - \"apps/*\"\n  - \"!apps/ignored\"\n",
1421        )
1422        .unwrap();
1423        fs::create_dir_all(root.join("apps")).unwrap();
1424        create_pkg(&root.join("apps"), "web", Some("next dev"));
1425        create_pkg(&root.join("apps"), "api", None);
1426        let result = detect_node_workspace(root).unwrap();
1427        assert_eq!(result.len(), 1);
1428        assert_eq!(result[0].name, "web");
1429    }
1430
1431    #[test]
1432    fn detect_npm_workspace() {
1433        let dir = tempdir().unwrap();
1434        let root = dir.path();
1435        fs::write(
1436            root.join("package.json"),
1437            r#"{ "name": "root", "workspaces": ["packages/*"] }"#,
1438        )
1439        .unwrap();
1440        fs::create_dir_all(root.join("packages")).unwrap();
1441        create_pkg(&root.join("packages"), "alpha", Some("vite dev"));
1442        create_pkg(&root.join("packages"), "beta", Some("node server.js"));
1443        let result = detect_node_workspace(root).unwrap();
1444        assert_eq!(result.len(), 2);
1445        assert_eq!(result[0].name, "alpha");
1446        assert_eq!(result[1].name, "beta");
1447    }
1448
1449    #[test]
1450    fn no_workspace_returns_none() {
1451        let dir = tempdir().unwrap();
1452        fs::write(dir.path().join("package.json"), r#"{ "name": "solo" }"#).unwrap();
1453        assert!(detect_node_workspace(dir.path()).is_none());
1454    }
1455
1456    #[test]
1457    fn node_has_script_finds_build() {
1458        let dir = tempdir().unwrap();
1459        fs::write(
1460            dir.path().join("package.json"),
1461            r#"{ "scripts": { "build": "tsc", "test": "jest" } }"#,
1462        )
1463        .unwrap();
1464        assert!(node_has_script(dir.path(), "build"));
1465        assert!(node_has_script(dir.path(), "test"));
1466        assert!(!node_has_script(dir.path(), "lint"));
1467    }
1468
1469    #[test]
1470    fn node_has_script_returns_false_when_no_scripts() {
1471        let dir = tempdir().unwrap();
1472        fs::write(dir.path().join("package.json"), r#"{ "name": "foo" }"#).unwrap();
1473        assert!(!node_has_script(dir.path(), "build"));
1474    }
1475
1476    #[test]
1477    fn node_has_script_returns_false_for_empty_script() {
1478        let dir = tempdir().unwrap();
1479        fs::write(
1480            dir.path().join("package.json"),
1481            r#"{ "scripts": { "build": "" } }"#,
1482        )
1483        .unwrap();
1484        assert!(!node_has_script(dir.path(), "build"));
1485    }
1486
1487    #[test]
1488    fn node_has_bin_string_form() {
1489        let dir = tempdir().unwrap();
1490        fs::write(
1491            dir.path().join("package.json"),
1492            r#"{ "bin": "src/cli.js" }"#,
1493        )
1494        .unwrap();
1495        assert!(node_has_bin(dir.path()));
1496    }
1497
1498    #[test]
1499    fn node_has_bin_object_form() {
1500        let dir = tempdir().unwrap();
1501        fs::write(
1502            dir.path().join("package.json"),
1503            r#"{ "bin": { "mycli": "src/cli.js" } }"#,
1504        )
1505        .unwrap();
1506        assert!(node_has_bin(dir.path()));
1507    }
1508
1509    #[test]
1510    fn node_has_bin_returns_false_when_absent() {
1511        let dir = tempdir().unwrap();
1512        fs::write(dir.path().join("package.json"), r#"{ "name": "foo" }"#).unwrap();
1513        assert!(!node_has_bin(dir.path()));
1514    }
1515
1516    #[test]
1517    fn workspace_no_dev_scripts() {
1518        let dir = tempdir().unwrap();
1519        let root = dir.path();
1520        fs::write(
1521            root.join("package.json"),
1522            r#"{ "name": "root", "workspaces": ["libs/*"] }"#,
1523        )
1524        .unwrap();
1525        fs::create_dir_all(root.join("libs")).unwrap();
1526        create_pkg(&root.join("libs"), "utils", None);
1527        let result = detect_node_workspace(root).unwrap();
1528        assert!(result.is_empty());
1529    }
1530
1531    // -- detect_walk ---------------------------------------------------------
1532
1533    #[test]
1534    fn detect_walk_finds_project_in_current_dir() {
1535        let dir = tempdir().unwrap();
1536        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1537        let (kind, found_dir) = detect_walk(dir.path()).unwrap();
1538        assert_eq!(kind, ProjectKind::Cargo);
1539        assert_eq!(found_dir, dir.path());
1540    }
1541
1542    #[test]
1543    fn detect_walk_finds_project_in_parent() {
1544        let dir = tempdir().unwrap();
1545        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1546        let child = dir.path().join("subdir");
1547        fs::create_dir(&child).unwrap();
1548        let (kind, found_dir) = detect_walk(&child).unwrap();
1549        assert_eq!(kind, ProjectKind::Cargo);
1550        assert_eq!(found_dir, dir.path().to_path_buf());
1551    }
1552
1553    #[test]
1554    fn detect_walk_finds_project_in_grandparent() {
1555        let dir = tempdir().unwrap();
1556        fs::write(dir.path().join("go.mod"), "").unwrap();
1557        let deep = dir.path().join("a").join("b").join("c");
1558        fs::create_dir_all(&deep).unwrap();
1559        let (kind, _) = detect_walk(&deep).unwrap();
1560        assert_eq!(kind, ProjectKind::Go);
1561    }
1562
1563    #[test]
1564    fn detect_walk_prefers_closest_project() {
1565        let dir = tempdir().unwrap();
1566        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1567        let child = dir.path().join("frontend");
1568        fs::create_dir(&child).unwrap();
1569        fs::write(child.join("package.json"), "{}").unwrap();
1570        let (kind, found_dir) = detect_walk(&child).unwrap();
1571        assert_eq!(
1572            kind,
1573            ProjectKind::Node {
1574                manager: NodePM::Npm
1575            }
1576        );
1577        assert_eq!(found_dir, child);
1578    }
1579
1580    #[test]
1581    fn detect_nearest_returns_kind_only() {
1582        let dir = tempdir().unwrap();
1583        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1584        let child = dir.path().join("src");
1585        fs::create_dir(&child).unwrap();
1586        assert_eq!(detect_nearest(&child), Some(ProjectKind::Cargo));
1587    }
1588}