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