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    if dir.join("MODULE.bazel").exists() || dir.join("WORKSPACE").exists() {
357        return Some(ProjectKind::Bazel);
358    }
359
360    if dir.join("meson.build").exists() {
361        return Some(ProjectKind::Meson);
362    }
363    if dir.join("CMakeLists.txt").exists() {
364        return Some(ProjectKind::CMake);
365    }
366    if dir.join("Makefile").exists()
367        || dir.join("makefile").exists()
368        || dir.join("GNUmakefile").exists()
369    {
370        return Some(ProjectKind::Make);
371    }
372
373    None
374}
375
376/// Detect the project kind, walking up from `dir` if nothing is found.
377///
378/// Convenience wrapper over [`detect_walk`] that returns only the kind.
379#[must_use]
380pub fn detect_nearest(dir: impl AsRef<Path>) -> Option<ProjectKind> {
381    detect_walk(dir).map(|(kind, _)| kind)
382}
383
384/// Check whether a command exists on `$PATH`.
385#[must_use]
386pub fn command_on_path(name: &str) -> bool {
387    Command::new("which")
388        .arg(name)
389        .stdout(Stdio::null())
390        .stderr(Stdio::null())
391        .status()
392        .map(|s| s.success())
393        .unwrap_or(false)
394}
395
396/// Formatted table of all supported project types for error messages.
397#[must_use]
398pub fn supported_table() -> String {
399    let entries = [
400        ("Cargo.toml", "cargo build"),
401        ("go.mod", "go build ./..."),
402        ("mix.exs", "mix compile"),
403        ("pyproject.toml", "pip install . (or uv)"),
404        ("setup.py", "pip install ."),
405        ("package.json", "npm/yarn/pnpm/bun install"),
406        ("build.gradle", "./gradlew build"),
407        ("pom.xml", "mvn package"),
408        ("build.sbt", "sbt compile"),
409        ("Gemfile", "bundle install"),
410        ("Package.swift", "swift build"),
411        ("build.zig", "zig build"),
412        ("*.csproj", "dotnet build"),
413        ("composer.json", "composer install"),
414        ("pubspec.yaml", "dart pub get / flutter pub get"),
415        ("stack.yaml", "stack build"),
416        ("*.cabal", "cabal build"),
417        ("project.clj", "lein compile"),
418        ("deps.edn", "clj -M:build"),
419        ("rebar.config", "rebar3 compile"),
420        ("dune-project", "dune build"),
421        ("cpanfile", "cpanm --installdeps ."),
422        ("Project.toml", "julia -e 'using Pkg; Pkg.instantiate()'"),
423        ("*.nimble", "nimble build"),
424        ("shard.yml", "shards build"),
425        ("v.mod", "v ."),
426        ("gleam.toml", "gleam build"),
427        ("*.rockspec", "luarocks make"),
428        ("MODULE.bazel", "bazel build //..."),
429        ("meson.build", "meson setup + compile"),
430        ("CMakeLists.txt", "cmake -B build && cmake --build build"),
431        ("Makefile", "make"),
432    ];
433
434    let mut out = String::from("  supported project files:\n");
435    for (file, cmd) in entries {
436        out.push_str(&format!("    {file:<18} → {cmd}\n"));
437    }
438    out
439}
440
441// -- Node.js package.json helpers --------------------------------------------
442
443/// Check whether a Node project's `package.json` contains a specific script.
444#[must_use]
445pub fn node_has_script(dir: &Path, name: &str) -> bool {
446    let Ok(content) = std::fs::read_to_string(dir.join("package.json")) else {
447        return false;
448    };
449    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
450        return false;
451    };
452    json.get("scripts")
453        .and_then(|s| s.get(name))
454        .and_then(|v| v.as_str())
455        .is_some_and(|s| !s.is_empty())
456}
457
458/// Check whether a Node project's `package.json` has a `"bin"` field.
459#[must_use]
460pub fn node_has_bin(dir: &Path) -> bool {
461    let Ok(content) = std::fs::read_to_string(dir.join("package.json")) else {
462        return false;
463    };
464    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
465        return false;
466    };
467    match json.get("bin") {
468        Some(serde_json::Value::String(s)) => !s.is_empty(),
469        Some(serde_json::Value::Object(m)) => !m.is_empty(),
470        _ => false,
471    }
472}
473
474// -- Private helpers ---------------------------------------------------------
475
476fn elixir_has_escript(dir: &Path) -> bool {
477    if let Ok(content) = std::fs::read_to_string(dir.join("mix.exs")) {
478        if content.contains("escript:") {
479            return true;
480        }
481    }
482    let apps_dir = dir.join("apps");
483    if apps_dir.is_dir() {
484        if let Ok(entries) = std::fs::read_dir(&apps_dir) {
485            for entry in entries.flatten() {
486                let child_mix = entry.path().join("mix.exs");
487                if let Ok(content) = std::fs::read_to_string(&child_mix) {
488                    if content.contains("escript:") {
489                        return true;
490                    }
491                }
492            }
493        }
494    }
495    false
496}
497
498fn has_extension_in_dir(dir: &Path, ext: &str) -> bool {
499    std::fs::read_dir(dir)
500        .ok()
501        .map(|entries| {
502            entries
503                .flatten()
504                .any(|e| e.path().extension().is_some_and(|x| x == ext))
505        })
506        .unwrap_or(false)
507}
508
509fn detect_node_pm(dir: &Path) -> NodePM {
510    if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
511        NodePM::Bun
512    } else if dir.join("pnpm-lock.yaml").exists() {
513        NodePM::Pnpm
514    } else if dir.join("yarn.lock").exists() {
515        NodePM::Yarn
516    } else {
517        NodePM::Npm
518    }
519}
520
521// -- Workspace detection -----------------------------------------------------
522
523/// A package in a workspace that has a "dev" script.
524#[derive(Debug, Clone)]
525pub struct WorkspacePackage {
526    pub name: String,
527    pub path: PathBuf,
528    pub dev_script: String,
529}
530
531/// Detect Node.js workspace packages that have a "dev" script.
532#[must_use]
533pub fn detect_node_workspace(dir: &Path) -> Option<Vec<WorkspacePackage>> {
534    let patterns =
535        read_pnpm_workspace_patterns(dir).or_else(|| read_npm_workspace_patterns(dir))?;
536    let mut packages = Vec::new();
537    for pattern in &patterns {
538        collect_workspace_packages(dir, pattern, &mut packages);
539    }
540    packages.sort_by(|a, b| a.name.cmp(&b.name));
541    Some(packages)
542}
543
544fn read_pnpm_workspace_patterns(dir: &Path) -> Option<Vec<String>> {
545    let content = std::fs::read_to_string(dir.join("pnpm-workspace.yaml")).ok()?;
546    let mut patterns = Vec::new();
547    let mut in_packages = false;
548    for line in content.lines() {
549        let trimmed = line.trim();
550        if trimmed == "packages:" {
551            in_packages = true;
552            continue;
553        }
554        if in_packages {
555            if !trimmed.starts_with('-') {
556                if !trimmed.is_empty() {
557                    break;
558                }
559                continue;
560            }
561            let value = trimmed
562                .trim_start_matches('-')
563                .trim()
564                .trim_matches('"')
565                .trim_matches('\'');
566            if value.starts_with('!') {
567                continue;
568            }
569            if !value.is_empty() {
570                patterns.push(value.to_string());
571            }
572        }
573    }
574    if patterns.is_empty() {
575        return None;
576    }
577    Some(patterns)
578}
579
580fn read_npm_workspace_patterns(dir: &Path) -> Option<Vec<String>> {
581    let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
582    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
583    let arr = json.get("workspaces")?.as_array()?;
584    let patterns: Vec<String> = arr
585        .iter()
586        .filter_map(|v| v.as_str())
587        .filter(|s| !s.starts_with('!'))
588        .map(|s| s.to_string())
589        .collect();
590    if patterns.is_empty() {
591        return None;
592    }
593    Some(patterns)
594}
595
596fn collect_workspace_packages(root: &Path, pattern: &str, out: &mut Vec<WorkspacePackage>) {
597    let prefix = match pattern.strip_suffix("/*") {
598        Some(p) => p,
599        None => pattern,
600    };
601    let search_dir = root.join(prefix);
602    let entries = match std::fs::read_dir(&search_dir) {
603        Ok(e) => e,
604        Err(_) => return,
605    };
606    for entry in entries.flatten() {
607        let pkg_dir = entry.path();
608        if !pkg_dir.is_dir() {
609            continue;
610        }
611        let pkg_json_path = pkg_dir.join("package.json");
612        let content = match std::fs::read_to_string(&pkg_json_path) {
613            Ok(c) => c,
614            Err(_) => continue,
615        };
616        let json: serde_json::Value = match serde_json::from_str(&content) {
617            Ok(v) => v,
618            Err(_) => continue,
619        };
620        if let Some(dev) = json
621            .get("scripts")
622            .and_then(|s| s.get("dev"))
623            .and_then(|d| d.as_str())
624        {
625            let name = pkg_dir
626                .file_name()
627                .map(|n| n.to_string_lossy().into_owned())
628                .unwrap_or_default();
629            let abs_path = match pkg_dir.canonicalize() {
630                Ok(p) => p,
631                Err(_) => pkg_dir,
632            };
633            out.push(WorkspacePackage {
634                name,
635                path: abs_path,
636                dev_script: dev.to_string(),
637            });
638        }
639    }
640}
641
642// -- Tests -------------------------------------------------------------------
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use std::fs;
648    use tempfile::tempdir;
649
650    #[test]
651    fn detect_cargo() {
652        let dir = tempdir().unwrap();
653        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
654        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
655    }
656
657    #[test]
658    fn detect_go() {
659        let dir = tempdir().unwrap();
660        fs::write(dir.path().join("go.mod"), "").unwrap();
661        assert_eq!(detect(dir.path()), Some(ProjectKind::Go));
662    }
663
664    #[test]
665    fn detect_elixir() {
666        let dir = tempdir().unwrap();
667        fs::write(dir.path().join("mix.exs"), "").unwrap();
668        assert!(matches!(
669            detect(dir.path()),
670            Some(ProjectKind::Elixir { .. })
671        ));
672    }
673
674    #[test]
675    fn detect_python_pyproject() {
676        let dir = tempdir().unwrap();
677        fs::write(dir.path().join("pyproject.toml"), "").unwrap();
678        assert!(matches!(
679            detect(dir.path()),
680            Some(ProjectKind::Python { .. })
681        ));
682    }
683
684    #[test]
685    fn detect_python_setup_py() {
686        let dir = tempdir().unwrap();
687        fs::write(dir.path().join("setup.py"), "").unwrap();
688        assert!(matches!(
689            detect(dir.path()),
690            Some(ProjectKind::Python { .. })
691        ));
692    }
693
694    #[test]
695    fn detect_python_setup_cfg() {
696        let dir = tempdir().unwrap();
697        fs::write(dir.path().join("setup.cfg"), "").unwrap();
698        assert!(matches!(
699            detect(dir.path()),
700            Some(ProjectKind::Python { .. })
701        ));
702    }
703
704    #[test]
705    fn detect_node_npm_default() {
706        let dir = tempdir().unwrap();
707        fs::write(dir.path().join("package.json"), "{}").unwrap();
708        assert_eq!(
709            detect(dir.path()),
710            Some(ProjectKind::Node {
711                manager: NodePM::Npm
712            })
713        );
714    }
715
716    #[test]
717    fn detect_node_yarn() {
718        let dir = tempdir().unwrap();
719        fs::write(dir.path().join("package.json"), "{}").unwrap();
720        fs::write(dir.path().join("yarn.lock"), "").unwrap();
721        assert_eq!(
722            detect(dir.path()),
723            Some(ProjectKind::Node {
724                manager: NodePM::Yarn
725            })
726        );
727    }
728
729    #[test]
730    fn detect_node_pnpm() {
731        let dir = tempdir().unwrap();
732        fs::write(dir.path().join("package.json"), "{}").unwrap();
733        fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
734        assert_eq!(
735            detect(dir.path()),
736            Some(ProjectKind::Node {
737                manager: NodePM::Pnpm
738            })
739        );
740    }
741
742    #[test]
743    fn detect_node_bun() {
744        let dir = tempdir().unwrap();
745        fs::write(dir.path().join("package.json"), "{}").unwrap();
746        fs::write(dir.path().join("bun.lockb"), "").unwrap();
747        assert_eq!(
748            detect(dir.path()),
749            Some(ProjectKind::Node {
750                manager: NodePM::Bun
751            })
752        );
753    }
754
755    #[test]
756    fn detect_gradle_with_wrapper() {
757        let dir = tempdir().unwrap();
758        fs::write(dir.path().join("build.gradle"), "").unwrap();
759        fs::write(dir.path().join("gradlew"), "").unwrap();
760        assert_eq!(
761            detect(dir.path()),
762            Some(ProjectKind::Gradle { wrapper: true })
763        );
764    }
765
766    #[test]
767    fn detect_gradle_kts_no_wrapper() {
768        let dir = tempdir().unwrap();
769        fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
770        assert_eq!(
771            detect(dir.path()),
772            Some(ProjectKind::Gradle { wrapper: false })
773        );
774    }
775
776    #[test]
777    fn detect_maven() {
778        let dir = tempdir().unwrap();
779        fs::write(dir.path().join("pom.xml"), "").unwrap();
780        assert_eq!(detect(dir.path()), Some(ProjectKind::Maven));
781    }
782
783    #[test]
784    fn detect_sbt() {
785        let dir = tempdir().unwrap();
786        fs::write(dir.path().join("build.sbt"), "").unwrap();
787        assert_eq!(detect(dir.path()), Some(ProjectKind::Sbt));
788    }
789
790    #[test]
791    fn detect_ruby() {
792        let dir = tempdir().unwrap();
793        fs::write(dir.path().join("Gemfile"), "").unwrap();
794        assert_eq!(detect(dir.path()), Some(ProjectKind::Ruby));
795    }
796
797    #[test]
798    fn detect_swift() {
799        let dir = tempdir().unwrap();
800        fs::write(dir.path().join("Package.swift"), "").unwrap();
801        assert_eq!(detect(dir.path()), Some(ProjectKind::Swift));
802    }
803
804    #[test]
805    fn detect_zig() {
806        let dir = tempdir().unwrap();
807        fs::write(dir.path().join("build.zig"), "").unwrap();
808        assert_eq!(detect(dir.path()), Some(ProjectKind::Zig));
809    }
810
811    #[test]
812    fn detect_dotnet_csproj() {
813        let dir = tempdir().unwrap();
814        fs::write(dir.path().join("MyApp.csproj"), "").unwrap();
815        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: false }));
816    }
817
818    #[test]
819    fn detect_dotnet_sln() {
820        let dir = tempdir().unwrap();
821        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
822        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: true }));
823    }
824
825    #[test]
826    fn detect_dotnet_sln_preferred_over_csproj() {
827        let dir = tempdir().unwrap();
828        fs::write(dir.path().join("MyApp.sln"), "").unwrap();
829        fs::write(dir.path().join("MyApp.csproj"), "").unwrap();
830        assert_eq!(detect(dir.path()), Some(ProjectKind::DotNet { sln: true }));
831    }
832
833    #[test]
834    fn detect_php() {
835        let dir = tempdir().unwrap();
836        fs::write(dir.path().join("composer.json"), "{}").unwrap();
837        assert_eq!(detect(dir.path()), Some(ProjectKind::Php));
838    }
839
840    #[test]
841    fn detect_dart() {
842        let dir = tempdir().unwrap();
843        fs::write(dir.path().join("pubspec.yaml"), "name: myapp").unwrap();
844        assert_eq!(
845            detect(dir.path()),
846            Some(ProjectKind::Dart { flutter: false })
847        );
848    }
849
850    #[test]
851    fn detect_flutter() {
852        let dir = tempdir().unwrap();
853        fs::write(
854            dir.path().join("pubspec.yaml"),
855            "name: myapp\nflutter:\n  sdk: flutter",
856        )
857        .unwrap();
858        assert_eq!(
859            detect(dir.path()),
860            Some(ProjectKind::Dart { flutter: true })
861        );
862    }
863
864    #[test]
865    fn detect_haskell_stack() {
866        let dir = tempdir().unwrap();
867        fs::write(dir.path().join("stack.yaml"), "").unwrap();
868        assert_eq!(
869            detect(dir.path()),
870            Some(ProjectKind::Haskell { stack: true })
871        );
872    }
873
874    #[test]
875    fn detect_haskell_cabal() {
876        let dir = tempdir().unwrap();
877        fs::write(dir.path().join("mylib.cabal"), "").unwrap();
878        assert_eq!(
879            detect(dir.path()),
880            Some(ProjectKind::Haskell { stack: false })
881        );
882    }
883
884    #[test]
885    fn detect_haskell_stack_preferred_over_cabal() {
886        let dir = tempdir().unwrap();
887        fs::write(dir.path().join("stack.yaml"), "").unwrap();
888        fs::write(dir.path().join("mylib.cabal"), "").unwrap();
889        assert_eq!(
890            detect(dir.path()),
891            Some(ProjectKind::Haskell { stack: true })
892        );
893    }
894
895    #[test]
896    fn detect_clojure_lein() {
897        let dir = tempdir().unwrap();
898        fs::write(dir.path().join("project.clj"), "").unwrap();
899        assert_eq!(
900            detect(dir.path()),
901            Some(ProjectKind::Clojure { lein: true })
902        );
903    }
904
905    #[test]
906    fn detect_clojure_deps() {
907        let dir = tempdir().unwrap();
908        fs::write(dir.path().join("deps.edn"), "").unwrap();
909        assert_eq!(
910            detect(dir.path()),
911            Some(ProjectKind::Clojure { lein: false })
912        );
913    }
914
915    #[test]
916    fn detect_rebar() {
917        let dir = tempdir().unwrap();
918        fs::write(dir.path().join("rebar.config"), "").unwrap();
919        assert_eq!(detect(dir.path()), Some(ProjectKind::Rebar));
920    }
921
922    #[test]
923    fn detect_dune() {
924        let dir = tempdir().unwrap();
925        fs::write(dir.path().join("dune-project"), "").unwrap();
926        assert_eq!(detect(dir.path()), Some(ProjectKind::Dune));
927    }
928
929    #[test]
930    fn detect_perl_cpanfile() {
931        let dir = tempdir().unwrap();
932        fs::write(dir.path().join("cpanfile"), "").unwrap();
933        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
934    }
935
936    #[test]
937    fn detect_perl_makefile_pl() {
938        let dir = tempdir().unwrap();
939        fs::write(dir.path().join("Makefile.PL"), "").unwrap();
940        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
941    }
942
943    #[test]
944    fn detect_julia() {
945        let dir = tempdir().unwrap();
946        fs::write(dir.path().join("Project.toml"), "").unwrap();
947        assert_eq!(detect(dir.path()), Some(ProjectKind::Julia));
948    }
949
950    #[test]
951    fn detect_nim() {
952        let dir = tempdir().unwrap();
953        fs::write(dir.path().join("myapp.nimble"), "").unwrap();
954        assert_eq!(detect(dir.path()), Some(ProjectKind::Nim));
955    }
956
957    #[test]
958    fn detect_crystal() {
959        let dir = tempdir().unwrap();
960        fs::write(dir.path().join("shard.yml"), "").unwrap();
961        assert_eq!(detect(dir.path()), Some(ProjectKind::Crystal));
962    }
963
964    #[test]
965    fn detect_vlang() {
966        let dir = tempdir().unwrap();
967        fs::write(dir.path().join("v.mod"), "").unwrap();
968        assert_eq!(detect(dir.path()), Some(ProjectKind::Vlang));
969    }
970
971    #[test]
972    fn detect_gleam() {
973        let dir = tempdir().unwrap();
974        fs::write(dir.path().join("gleam.toml"), "").unwrap();
975        assert_eq!(detect(dir.path()), Some(ProjectKind::Gleam));
976    }
977
978    #[test]
979    fn detect_lua() {
980        let dir = tempdir().unwrap();
981        fs::write(dir.path().join("mylib-1.0-1.rockspec"), "").unwrap();
982        assert_eq!(detect(dir.path()), Some(ProjectKind::Lua));
983    }
984
985    #[test]
986    fn detect_bazel_module() {
987        let dir = tempdir().unwrap();
988        fs::write(dir.path().join("MODULE.bazel"), "").unwrap();
989        assert_eq!(detect(dir.path()), Some(ProjectKind::Bazel));
990    }
991
992    #[test]
993    fn detect_bazel_workspace() {
994        let dir = tempdir().unwrap();
995        fs::write(dir.path().join("WORKSPACE"), "").unwrap();
996        assert_eq!(detect(dir.path()), Some(ProjectKind::Bazel));
997    }
998
999    #[test]
1000    fn detect_meson() {
1001        let dir = tempdir().unwrap();
1002        fs::write(dir.path().join("meson.build"), "").unwrap();
1003        assert_eq!(detect(dir.path()), Some(ProjectKind::Meson));
1004    }
1005
1006    #[test]
1007    fn detect_cmake() {
1008        let dir = tempdir().unwrap();
1009        fs::write(dir.path().join("CMakeLists.txt"), "").unwrap();
1010        assert_eq!(detect(dir.path()), Some(ProjectKind::CMake));
1011    }
1012
1013    #[test]
1014    fn detect_makefile() {
1015        let dir = tempdir().unwrap();
1016        fs::write(dir.path().join("Makefile"), "").unwrap();
1017        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1018    }
1019
1020    #[test]
1021    fn detect_makefile_lowercase() {
1022        let dir = tempdir().unwrap();
1023        fs::write(dir.path().join("makefile"), "").unwrap();
1024        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1025    }
1026
1027    #[test]
1028    fn detect_gnumakefile() {
1029        let dir = tempdir().unwrap();
1030        fs::write(dir.path().join("GNUmakefile"), "").unwrap();
1031        assert_eq!(detect(dir.path()), Some(ProjectKind::Make));
1032    }
1033
1034    #[test]
1035    fn detect_empty_dir_returns_none() {
1036        let dir = tempdir().unwrap();
1037        assert_eq!(detect(dir.path()), None);
1038    }
1039
1040    // -- Priority: language-specific wins over generic -----------------------
1041
1042    #[test]
1043    fn cargo_wins_over_makefile() {
1044        let dir = tempdir().unwrap();
1045        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1046        fs::write(dir.path().join("Makefile"), "").unwrap();
1047        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
1048    }
1049
1050    #[test]
1051    fn go_wins_over_makefile() {
1052        let dir = tempdir().unwrap();
1053        fs::write(dir.path().join("go.mod"), "").unwrap();
1054        fs::write(dir.path().join("Makefile"), "").unwrap();
1055        assert_eq!(detect(dir.path()), Some(ProjectKind::Go));
1056    }
1057
1058    #[test]
1059    fn node_wins_over_cmake() {
1060        let dir = tempdir().unwrap();
1061        fs::write(dir.path().join("package.json"), "{}").unwrap();
1062        fs::write(dir.path().join("CMakeLists.txt"), "").unwrap();
1063        assert_eq!(
1064            detect(dir.path()),
1065            Some(ProjectKind::Node {
1066                manager: NodePM::Npm
1067            })
1068        );
1069    }
1070
1071    #[test]
1072    fn cargo_wins_over_node() {
1073        let dir = tempdir().unwrap();
1074        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1075        fs::write(dir.path().join("package.json"), "{}").unwrap();
1076        assert_eq!(detect(dir.path()), Some(ProjectKind::Cargo));
1077    }
1078
1079    #[test]
1080    fn perl_makefile_pl_does_not_trigger_make() {
1081        let dir = tempdir().unwrap();
1082        fs::write(dir.path().join("Makefile.PL"), "").unwrap();
1083        assert_eq!(detect(dir.path()), Some(ProjectKind::Perl));
1084    }
1085
1086    #[test]
1087    fn gleam_wins_over_bazel() {
1088        let dir = tempdir().unwrap();
1089        fs::write(dir.path().join("gleam.toml"), "").unwrap();
1090        fs::write(dir.path().join("WORKSPACE"), "").unwrap();
1091        assert_eq!(detect(dir.path()), Some(ProjectKind::Gleam));
1092    }
1093
1094    // -- artifact_dirs -------------------------------------------------------
1095
1096    #[test]
1097    fn cargo_artifacts_include_target() {
1098        assert!(ProjectKind::Cargo.artifact_dirs().contains(&"target"));
1099    }
1100
1101    #[test]
1102    fn swift_artifacts_include_build() {
1103        assert!(ProjectKind::Swift.artifact_dirs().contains(&".build"));
1104    }
1105
1106    #[test]
1107    fn dotnet_artifacts_include_bin_obj() {
1108        let kind = ProjectKind::DotNet { sln: false };
1109        assert!(kind.artifact_dirs().contains(&"bin"));
1110        assert!(kind.artifact_dirs().contains(&"obj"));
1111    }
1112
1113    #[test]
1114    fn zig_artifacts_include_zig_out() {
1115        let dirs = ProjectKind::Zig.artifact_dirs();
1116        assert!(dirs.contains(&"zig-out"));
1117        assert!(dirs.contains(&".zig-cache"));
1118    }
1119
1120    #[test]
1121    fn node_artifacts_include_node_modules() {
1122        let kind = ProjectKind::Node {
1123            manager: NodePM::Npm,
1124        };
1125        assert!(kind.artifact_dirs().contains(&"node_modules"));
1126    }
1127
1128    #[test]
1129    fn php_artifacts_include_vendor() {
1130        assert!(ProjectKind::Php.artifact_dirs().contains(&"vendor"));
1131    }
1132
1133    #[test]
1134    fn dart_artifacts_include_dart_tool() {
1135        assert!(ProjectKind::Dart { flutter: false }
1136            .artifact_dirs()
1137            .contains(&".dart_tool"));
1138    }
1139
1140    #[test]
1141    fn haskell_stack_artifacts() {
1142        assert!(ProjectKind::Haskell { stack: true }
1143            .artifact_dirs()
1144            .contains(&".stack-work"));
1145    }
1146
1147    #[test]
1148    fn haskell_cabal_artifacts() {
1149        assert!(ProjectKind::Haskell { stack: false }
1150            .artifact_dirs()
1151            .contains(&"dist-newstyle"));
1152    }
1153
1154    #[test]
1155    fn bazel_artifacts() {
1156        let dirs = ProjectKind::Bazel.artifact_dirs();
1157        assert!(dirs.contains(&"bazel-bin"));
1158        assert!(dirs.contains(&"bazel-out"));
1159    }
1160
1161    // -- Workspace detection -------------------------------------------------
1162
1163    fn create_pkg(parent: &Path, name: &str, dev_script: Option<&str>) {
1164        let pkg = parent.join(name);
1165        fs::create_dir_all(&pkg).unwrap();
1166        let scripts = match dev_script {
1167            Some(s) => format!(r#", "scripts": {{ "dev": "{s}" }}"#),
1168            None => String::new(),
1169        };
1170        fs::write(
1171            pkg.join("package.json"),
1172            format!(r#"{{ "name": "{name}"{scripts} }}"#),
1173        )
1174        .unwrap();
1175    }
1176
1177    #[test]
1178    fn detect_pnpm_workspace() {
1179        let dir = tempdir().unwrap();
1180        let root = dir.path();
1181        fs::write(root.join("package.json"), r#"{ "name": "root" }"#).unwrap();
1182        fs::write(
1183            root.join("pnpm-workspace.yaml"),
1184            "packages:\n  - \"apps/*\"\n  - \"!apps/ignored\"\n",
1185        )
1186        .unwrap();
1187        fs::create_dir_all(root.join("apps")).unwrap();
1188        create_pkg(&root.join("apps"), "web", Some("next dev"));
1189        create_pkg(&root.join("apps"), "api", None);
1190        let result = detect_node_workspace(root).unwrap();
1191        assert_eq!(result.len(), 1);
1192        assert_eq!(result[0].name, "web");
1193    }
1194
1195    #[test]
1196    fn detect_npm_workspace() {
1197        let dir = tempdir().unwrap();
1198        let root = dir.path();
1199        fs::write(
1200            root.join("package.json"),
1201            r#"{ "name": "root", "workspaces": ["packages/*"] }"#,
1202        )
1203        .unwrap();
1204        fs::create_dir_all(root.join("packages")).unwrap();
1205        create_pkg(&root.join("packages"), "alpha", Some("vite dev"));
1206        create_pkg(&root.join("packages"), "beta", Some("node server.js"));
1207        let result = detect_node_workspace(root).unwrap();
1208        assert_eq!(result.len(), 2);
1209        assert_eq!(result[0].name, "alpha");
1210        assert_eq!(result[1].name, "beta");
1211    }
1212
1213    #[test]
1214    fn no_workspace_returns_none() {
1215        let dir = tempdir().unwrap();
1216        fs::write(dir.path().join("package.json"), r#"{ "name": "solo" }"#).unwrap();
1217        assert!(detect_node_workspace(dir.path()).is_none());
1218    }
1219
1220    #[test]
1221    fn node_has_script_finds_build() {
1222        let dir = tempdir().unwrap();
1223        fs::write(
1224            dir.path().join("package.json"),
1225            r#"{ "scripts": { "build": "tsc", "test": "jest" } }"#,
1226        )
1227        .unwrap();
1228        assert!(node_has_script(dir.path(), "build"));
1229        assert!(node_has_script(dir.path(), "test"));
1230        assert!(!node_has_script(dir.path(), "lint"));
1231    }
1232
1233    #[test]
1234    fn node_has_script_returns_false_when_no_scripts() {
1235        let dir = tempdir().unwrap();
1236        fs::write(dir.path().join("package.json"), r#"{ "name": "foo" }"#).unwrap();
1237        assert!(!node_has_script(dir.path(), "build"));
1238    }
1239
1240    #[test]
1241    fn node_has_script_returns_false_for_empty_script() {
1242        let dir = tempdir().unwrap();
1243        fs::write(
1244            dir.path().join("package.json"),
1245            r#"{ "scripts": { "build": "" } }"#,
1246        )
1247        .unwrap();
1248        assert!(!node_has_script(dir.path(), "build"));
1249    }
1250
1251    #[test]
1252    fn node_has_bin_string_form() {
1253        let dir = tempdir().unwrap();
1254        fs::write(
1255            dir.path().join("package.json"),
1256            r#"{ "bin": "src/cli.js" }"#,
1257        )
1258        .unwrap();
1259        assert!(node_has_bin(dir.path()));
1260    }
1261
1262    #[test]
1263    fn node_has_bin_object_form() {
1264        let dir = tempdir().unwrap();
1265        fs::write(
1266            dir.path().join("package.json"),
1267            r#"{ "bin": { "mycli": "src/cli.js" } }"#,
1268        )
1269        .unwrap();
1270        assert!(node_has_bin(dir.path()));
1271    }
1272
1273    #[test]
1274    fn node_has_bin_returns_false_when_absent() {
1275        let dir = tempdir().unwrap();
1276        fs::write(dir.path().join("package.json"), r#"{ "name": "foo" }"#).unwrap();
1277        assert!(!node_has_bin(dir.path()));
1278    }
1279
1280    #[test]
1281    fn workspace_no_dev_scripts() {
1282        let dir = tempdir().unwrap();
1283        let root = dir.path();
1284        fs::write(
1285            root.join("package.json"),
1286            r#"{ "name": "root", "workspaces": ["libs/*"] }"#,
1287        )
1288        .unwrap();
1289        fs::create_dir_all(root.join("libs")).unwrap();
1290        create_pkg(&root.join("libs"), "utils", None);
1291        let result = detect_node_workspace(root).unwrap();
1292        assert!(result.is_empty());
1293    }
1294
1295    // -- detect_walk ---------------------------------------------------------
1296
1297    #[test]
1298    fn detect_walk_finds_project_in_current_dir() {
1299        let dir = tempdir().unwrap();
1300        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1301        let (kind, found_dir) = detect_walk(dir.path()).unwrap();
1302        assert_eq!(kind, ProjectKind::Cargo);
1303        assert_eq!(found_dir, dir.path());
1304    }
1305
1306    #[test]
1307    fn detect_walk_finds_project_in_parent() {
1308        let dir = tempdir().unwrap();
1309        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1310        let child = dir.path().join("subdir");
1311        fs::create_dir(&child).unwrap();
1312        let (kind, found_dir) = detect_walk(&child).unwrap();
1313        assert_eq!(kind, ProjectKind::Cargo);
1314        assert_eq!(found_dir, dir.path().to_path_buf());
1315    }
1316
1317    #[test]
1318    fn detect_walk_finds_project_in_grandparent() {
1319        let dir = tempdir().unwrap();
1320        fs::write(dir.path().join("go.mod"), "").unwrap();
1321        let deep = dir.path().join("a").join("b").join("c");
1322        fs::create_dir_all(&deep).unwrap();
1323        let (kind, _) = detect_walk(&deep).unwrap();
1324        assert_eq!(kind, ProjectKind::Go);
1325    }
1326
1327    #[test]
1328    fn detect_walk_prefers_closest_project() {
1329        let dir = tempdir().unwrap();
1330        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1331        let child = dir.path().join("frontend");
1332        fs::create_dir(&child).unwrap();
1333        fs::write(child.join("package.json"), "{}").unwrap();
1334        let (kind, found_dir) = detect_walk(&child).unwrap();
1335        assert_eq!(
1336            kind,
1337            ProjectKind::Node {
1338                manager: NodePM::Npm
1339            }
1340        );
1341        assert_eq!(found_dir, child);
1342    }
1343
1344    #[test]
1345    fn detect_nearest_returns_kind_only() {
1346        let dir = tempdir().unwrap();
1347        fs::write(dir.path().join("Cargo.toml"), "").unwrap();
1348        let child = dir.path().join("src");
1349        fs::create_dir(&child).unwrap();
1350        assert_eq!(detect_nearest(&child), Some(ProjectKind::Cargo));
1351    }
1352}