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