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 ProjectKind {
32 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 Bazel,
61 Meson,
62 CMake,
63 Make,
64}
65
66impl ProjectKind {
67 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 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 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#[must_use]
187pub fn detect(dir: impl AsRef<Path>) -> Option<ProjectKind> {
188 detect_in(dir.as_ref())
189}
190
191#[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(¤t) {
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 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 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 if dir.join("package.json").exists() {
235 return Some(ProjectKind::Node {
236 manager: detect_node_pm(dir),
237 });
238 }
239
240 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 if dir.join("Gemfile").exists() {
255 return Some(ProjectKind::Ruby);
256 }
257
258 if dir.join("Package.swift").exists() {
260 return Some(ProjectKind::Swift);
261 }
262
263 if dir.join("build.zig").exists() {
265 return Some(ProjectKind::Zig);
266 }
267
268 {
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 if dir.join("composer.json").exists() {
279 return Some(ProjectKind::Php);
280 }
281
282 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 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 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 if dir.join("rebar.config").exists() {
310 return Some(ProjectKind::Rebar);
311 }
312
313 if dir.join("dune-project").exists() {
315 return Some(ProjectKind::Dune);
316 }
317
318 if dir.join("cpanfile").exists() || dir.join("Makefile.PL").exists() {
320 return Some(ProjectKind::Perl);
321 }
322
323 if dir.join("Project.toml").exists() {
325 return Some(ProjectKind::Julia);
326 }
327
328 if has_extension_in_dir(dir, "nimble") {
330 return Some(ProjectKind::Nim);
331 }
332
333 if dir.join("shard.yml").exists() {
335 return Some(ProjectKind::Crystal);
336 }
337
338 if dir.join("v.mod").exists() {
340 return Some(ProjectKind::Vlang);
341 }
342
343 if dir.join("gleam.toml").exists() {
345 return Some(ProjectKind::Gleam);
346 }
347
348 if has_extension_in_dir(dir, "rockspec") {
350 return Some(ProjectKind::Lua);
351 }
352
353 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#[must_use]
382pub fn detect_nearest(dir: impl AsRef<Path>) -> Option<ProjectKind> {
383 detect_walk(dir).map(|(kind, _)| kind)
384}
385
386#[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#[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#[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#[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
476fn 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#[derive(Debug, Clone)]
527pub struct WorkspacePackage {
528 pub name: String,
529 pub path: PathBuf,
530 pub dev_script: String,
531}
532
533#[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#[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 #[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 #[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 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 #[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}