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").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#[must_use]
380pub fn detect_nearest(dir: impl AsRef<Path>) -> Option<ProjectKind> {
381 detect_walk(dir).map(|(kind, _)| kind)
382}
383
384#[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#[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#[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#[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
474fn 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#[derive(Debug, Clone)]
525pub struct WorkspacePackage {
526 pub name: String,
527 pub path: PathBuf,
528 pub dev_script: String,
529}
530
531#[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#[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 #[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 #[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 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 #[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}