1use anyhow::Result;
2use std::path::Path;
3use tracing::debug;
4
5use crate::types::Ecosystem;
6
7pub fn detect_ecosystems(root: &Path) -> Result<Vec<Ecosystem>> {
9 let mut detected = Vec::new();
10
11 let cargo_toml = root.join("Cargo.toml");
13 if cargo_toml.exists() {
14 if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
15 if content.contains("[workspace]") {
16 debug!("Detected Cargo workspace at {}", cargo_toml.display());
17 detected.push(Ecosystem::Cargo);
18 }
19 }
20 }
21
22 let yarnrc = root.join(".yarnrc.yml");
24 if yarnrc.exists() {
25 debug!("Detected Yarn Berry project via .yarnrc.yml");
26 detected.push(Ecosystem::Yarn);
27 } else {
28 let has_bun = root.join("bun.lock").exists()
30 || root.join("bun.lockb").exists()
31 || root.join("bunfig.toml").exists();
32 let pkg_json = root.join("package.json");
33 let pnpm_ws = root.join("pnpm-workspace.yaml");
34
35 let has_workspaces = if pnpm_ws.exists() {
36 true
37 } else if pkg_json.exists() {
38 std::fs::read_to_string(&pkg_json)
39 .map(|c| c.contains("\"workspaces\""))
40 .unwrap_or(false)
41 } else {
42 false
43 };
44
45 if has_bun && has_workspaces {
46 debug!("Detected Bun workspace via bun.lock/bunfig.toml");
47 detected.push(Ecosystem::Bun);
48 } else if pnpm_ws.exists() {
49 debug!("Detected pnpm workspace via pnpm-workspace.yaml");
50 detected.push(Ecosystem::Npm);
51 } else if pkg_json.exists() {
52 if let Ok(content) = std::fs::read_to_string(&pkg_json) {
53 if content.contains("\"workspaces\"") {
54 debug!("Detected npm workspaces in package.json");
55 detected.push(Ecosystem::Npm);
56 }
57 }
58 }
59 }
60
61 if root.join("go.work").exists() || root.join("go.mod").exists() {
63 debug!("Detected Go project");
64 detected.push(Ecosystem::Go);
65 }
66
67 let root_pyproject = root.join("pyproject.toml");
69 if root_pyproject.exists() {
70 if let Ok(content) = std::fs::read_to_string(&root_pyproject) {
71 if content.contains("[tool.poetry]") {
72 debug!("Detected Poetry project via [tool.poetry] in pyproject.toml");
73 detected.push(Ecosystem::Python);
74 } else if content.contains("[tool.uv.workspace]") {
75 debug!("Detected uv workspace via [tool.uv.workspace] in pyproject.toml");
76 detected.push(Ecosystem::Python);
77 } else {
78 debug!("Detected generic Python project via pyproject.toml");
79 detected.push(Ecosystem::Python);
80 }
81 } else {
82 detected.push(Ecosystem::Python);
83 }
84 } else {
85 let pattern = root.join("*/pyproject.toml");
87 if let Ok(paths) = glob::glob(pattern.to_str().unwrap_or("")) {
88 let count = paths
89 .filter_map(|p| match p {
90 Ok(path) => Some(path),
91 Err(e) => {
92 debug!("Glob error during Python detection: {}", e);
93 None
94 }
95 })
96 .count();
97 if count >= 2 {
98 debug!(
99 "Detected Python monorepo ({} pyproject.toml files found)",
100 count
101 );
102 detected.push(Ecosystem::Python);
103 }
104 }
105 }
106
107 let pom_xml = root.join("pom.xml");
109 if pom_xml.exists() {
110 if let Ok(content) = std::fs::read_to_string(&pom_xml) {
111 if content.contains("<modules>") {
112 debug!("Detected Maven multi-module project via pom.xml");
113 detected.push(Ecosystem::Maven);
114 }
115 }
116 }
117
118 if root.join("settings.gradle").exists() || root.join("settings.gradle.kts").exists() {
120 debug!("Detected Gradle project");
121 detected.push(Ecosystem::Gradle);
122 }
123
124 let sln_pattern = root.join("*.sln");
126 if let Ok(mut paths) = glob::glob(sln_pattern.to_str().unwrap_or("")) {
127 if paths.any(|p| match p {
128 Ok(_) => true,
129 Err(e) => {
130 debug!("Glob error during .NET detection: {}", e);
131 false
132 }
133 }) {
134 debug!("Detected .NET solution via *.sln");
135 detected.push(Ecosystem::Dotnet);
136 }
137 }
138
139 let package_swift = root.join("Package.swift");
141 if package_swift.exists() {
142 if let Ok(content) = std::fs::read_to_string(&package_swift) {
143 let target_count = content.matches(".target(").count()
144 + content.matches(".executableTarget(").count()
145 + content.matches(".testTarget(").count();
146 if target_count >= 2 {
147 debug!("Detected Swift package with {} targets", target_count);
148 detected.push(Ecosystem::Swift);
149 }
150 }
151 }
152
153 let root_pubspec = root.join("pubspec.yaml");
155 if root_pubspec.exists() {
156 if let Ok(content) = std::fs::read_to_string(&root_pubspec) {
157 if content.contains("workspace:") {
158 debug!("Detected Dart workspace via pubspec.yaml workspace field");
159 detected.push(Ecosystem::Dart);
160 }
161 }
162 }
163 if !detected.contains(&Ecosystem::Dart) && root.join("melos.yaml").exists() {
164 debug!("Detected Dart/Flutter monorepo via melos.yaml");
165 detected.push(Ecosystem::Dart);
166 }
167 if !detected.contains(&Ecosystem::Dart) {
168 let pattern = root.join("*/pubspec.yaml");
169 if let Ok(paths) = glob::glob(pattern.to_str().unwrap_or("")) {
170 let count = paths
171 .filter_map(|p| match p {
172 Ok(path) => Some(path),
173 Err(e) => {
174 debug!("Glob error during Dart detection: {}", e);
175 None
176 }
177 })
178 .count();
179 if count >= 2 {
180 debug!(
181 "Detected Dart monorepo ({} pubspec.yaml files found)",
182 count
183 );
184 detected.push(Ecosystem::Dart);
185 }
186 }
187 }
188
189 if root.join("mix.exs").exists() && root.join("apps").is_dir() {
191 debug!("Detected Elixir umbrella project via mix.exs + apps/");
192 detected.push(Ecosystem::Elixir);
193 }
194
195 if root.join("build.sbt").exists() {
197 debug!("Detected sbt project via build.sbt");
198 detected.push(Ecosystem::Sbt);
199 }
200
201 if detected.is_empty() {
202 anyhow::bail!(
203 "No supported project type found at {}.\n\
204 Looked for: Cargo.toml (workspace), package.json (workspaces), \
205 go.work/go.mod, pyproject.toml, pom.xml (modules), settings.gradle(.kts), \
206 *.sln (.NET), Package.swift, pubspec.yaml/melos.yaml, mix.exs (umbrella), build.sbt",
207 root.display()
208 );
209 }
210
211 debug!("Detected ecosystems: {:?}", detected);
212 Ok(detected)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_detect_cargo_workspace() {
221 let dir = tempfile::tempdir().unwrap();
222 std::fs::write(
223 dir.path().join("Cargo.toml"),
224 "[workspace]\nmembers = [\"crates/*\"]\n",
225 )
226 .unwrap();
227
228 let ecosystems = detect_ecosystems(dir.path()).unwrap();
229 assert_eq!(ecosystems, vec![Ecosystem::Cargo]);
230 }
231
232 #[test]
233 fn test_detect_cargo_without_workspace_ignored() {
234 let dir = tempfile::tempdir().unwrap();
235 std::fs::write(
236 dir.path().join("Cargo.toml"),
237 "[package]\nname = \"solo\"\n",
238 )
239 .unwrap();
240
241 assert!(detect_ecosystems(dir.path()).is_err());
242 }
243
244 #[test]
245 fn test_detect_npm_workspaces() {
246 let dir = tempfile::tempdir().unwrap();
247 std::fs::write(
248 dir.path().join("package.json"),
249 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
250 )
251 .unwrap();
252
253 let ecosystems = detect_ecosystems(dir.path()).unwrap();
254 assert_eq!(ecosystems, vec![Ecosystem::Npm]);
255 }
256
257 #[test]
258 fn test_detect_pnpm_workspace() {
259 let dir = tempfile::tempdir().unwrap();
260 std::fs::write(
261 dir.path().join("pnpm-workspace.yaml"),
262 "packages:\n - 'packages/*'\n",
263 )
264 .unwrap();
265
266 let ecosystems = detect_ecosystems(dir.path()).unwrap();
267 assert_eq!(ecosystems, vec![Ecosystem::Npm]);
268 }
269
270 #[test]
271 fn test_detect_yarn_workspace() {
272 let dir = tempfile::tempdir().unwrap();
273 std::fs::write(dir.path().join(".yarnrc.yml"), "nodeLinker: pnp\n").unwrap();
274
275 let ecosystems = detect_ecosystems(dir.path()).unwrap();
276 assert_eq!(ecosystems, vec![Ecosystem::Yarn]);
277 }
278
279 #[test]
280 fn test_detect_go_workspace() {
281 let dir = tempfile::tempdir().unwrap();
282 std::fs::write(dir.path().join("go.work"), "go 1.21\n").unwrap();
283
284 let ecosystems = detect_ecosystems(dir.path()).unwrap();
285 assert_eq!(ecosystems, vec![Ecosystem::Go]);
286 }
287
288 #[test]
289 fn test_detect_go_single_module() {
290 let dir = tempfile::tempdir().unwrap();
291 std::fs::write(dir.path().join("go.mod"), "module example.com/foo\n").unwrap();
292
293 let ecosystems = detect_ecosystems(dir.path()).unwrap();
294 assert_eq!(ecosystems, vec![Ecosystem::Go]);
295 }
296
297 #[test]
298 fn test_detect_python_root_pyproject() {
299 let dir = tempfile::tempdir().unwrap();
300 std::fs::write(
301 dir.path().join("pyproject.toml"),
302 "[project]\nname = \"myapp\"\n",
303 )
304 .unwrap();
305
306 let ecosystems = detect_ecosystems(dir.path()).unwrap();
307 assert_eq!(ecosystems, vec![Ecosystem::Python]);
308 }
309
310 #[test]
311 fn test_detect_multiple_ecosystems() {
312 let dir = tempfile::tempdir().unwrap();
313 std::fs::write(dir.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
314 std::fs::write(dir.path().join("go.mod"), "module example.com/x\n").unwrap();
315
316 let ecosystems = detect_ecosystems(dir.path()).unwrap();
317 assert!(ecosystems.contains(&Ecosystem::Cargo));
318 assert!(ecosystems.contains(&Ecosystem::Go));
319 assert_eq!(ecosystems.len(), 2);
320 }
321
322 #[test]
323 fn test_detect_empty_directory_errors() {
324 let dir = tempfile::tempdir().unwrap();
325 assert!(detect_ecosystems(dir.path()).is_err());
326 }
327
328 #[test]
329 fn test_detect_npm_without_workspaces_ignored() {
330 let dir = tempfile::tempdir().unwrap();
331 std::fs::write(
332 dir.path().join("package.json"),
333 r#"{"name": "solo", "version": "1.0.0"}"#,
334 )
335 .unwrap();
336
337 assert!(detect_ecosystems(dir.path()).is_err());
338 }
339
340 #[test]
341 fn test_detect_maven_multi_module() {
342 let dir = tempfile::tempdir().unwrap();
343 std::fs::write(
344 dir.path().join("pom.xml"),
345 r#"<project><modules><module>core</module></modules></project>"#,
346 )
347 .unwrap();
348
349 let ecosystems = detect_ecosystems(dir.path()).unwrap();
350 assert_eq!(ecosystems, vec![Ecosystem::Maven]);
351 }
352
353 #[test]
354 fn test_detect_gradle_groovy() {
355 let dir = tempfile::tempdir().unwrap();
356 std::fs::write(
357 dir.path().join("settings.gradle"),
358 "include ':core', ':app'\n",
359 )
360 .unwrap();
361
362 let ecosystems = detect_ecosystems(dir.path()).unwrap();
363 assert_eq!(ecosystems, vec![Ecosystem::Gradle]);
364 }
365
366 #[test]
367 fn test_detect_gradle_kotlin() {
368 let dir = tempfile::tempdir().unwrap();
369 std::fs::write(
370 dir.path().join("settings.gradle.kts"),
371 "include(\":core\", \":app\")\n",
372 )
373 .unwrap();
374
375 let ecosystems = detect_ecosystems(dir.path()).unwrap();
376 assert_eq!(ecosystems, vec![Ecosystem::Gradle]);
377 }
378
379 #[test]
380 fn test_detect_poetry_project() {
381 let dir = tempfile::tempdir().unwrap();
382 std::fs::write(
383 dir.path().join("pyproject.toml"),
384 "[tool.poetry]\nname = \"myapp\"\n",
385 )
386 .unwrap();
387
388 let ecosystems = detect_ecosystems(dir.path()).unwrap();
389 assert_eq!(ecosystems, vec![Ecosystem::Python]);
390 }
391
392 #[test]
393 fn test_detect_uv_workspace() {
394 let dir = tempfile::tempdir().unwrap();
395 std::fs::write(
396 dir.path().join("pyproject.toml"),
397 "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n",
398 )
399 .unwrap();
400
401 let ecosystems = detect_ecosystems(dir.path()).unwrap();
402 assert_eq!(ecosystems, vec![Ecosystem::Python]);
403 }
404
405 #[test]
406 fn test_detect_bun_workspace() {
407 let dir = tempfile::tempdir().unwrap();
408 std::fs::write(dir.path().join("bun.lock"), "").unwrap();
409 std::fs::write(
410 dir.path().join("package.json"),
411 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
412 )
413 .unwrap();
414
415 let ecosystems = detect_ecosystems(dir.path()).unwrap();
416 assert!(ecosystems.contains(&Ecosystem::Bun));
417 }
418
419 #[test]
420 fn test_detect_bun_lockb() {
421 let dir = tempfile::tempdir().unwrap();
422 std::fs::write(dir.path().join("bun.lockb"), "").unwrap();
423 std::fs::write(
424 dir.path().join("package.json"),
425 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
426 )
427 .unwrap();
428
429 let ecosystems = detect_ecosystems(dir.path()).unwrap();
430 assert!(ecosystems.contains(&Ecosystem::Bun));
431 }
432
433 #[test]
434 fn test_detect_dotnet_solution() {
435 let dir = tempfile::tempdir().unwrap();
436 std::fs::write(
437 dir.path().join("MySolution.sln"),
438 "Microsoft Visual Studio Solution File",
439 )
440 .unwrap();
441
442 let ecosystems = detect_ecosystems(dir.path()).unwrap();
443 assert!(ecosystems.contains(&Ecosystem::Dotnet));
444 }
445
446 #[test]
447 fn test_detect_swift_package() {
448 let dir = tempfile::tempdir().unwrap();
449 std::fs::write(
450 dir.path().join("Package.swift"),
451 r#"let package = Package(
452 name: "MyPkg",
453 targets: [
454 .target(name: "Core", dependencies: []),
455 .target(name: "API", dependencies: ["Core"]),
456 ]
457)"#,
458 )
459 .unwrap();
460
461 let ecosystems = detect_ecosystems(dir.path()).unwrap();
462 assert!(ecosystems.contains(&Ecosystem::Swift));
463 }
464
465 #[test]
466 fn test_detect_dart_workspace() {
467 let dir = tempfile::tempdir().unwrap();
468 std::fs::write(
469 dir.path().join("pubspec.yaml"),
470 "name: root\nworkspace:\n - packages/core\n - packages/api\n",
471 )
472 .unwrap();
473
474 let ecosystems = detect_ecosystems(dir.path()).unwrap();
475 assert!(ecosystems.contains(&Ecosystem::Dart));
476 }
477
478 #[test]
479 fn test_detect_melos() {
480 let dir = tempfile::tempdir().unwrap();
481 std::fs::write(
482 dir.path().join("melos.yaml"),
483 "name: my_project\npackages:\n - packages/*\n",
484 )
485 .unwrap();
486
487 let ecosystems = detect_ecosystems(dir.path()).unwrap();
488 assert!(ecosystems.contains(&Ecosystem::Dart));
489 }
490
491 #[test]
492 fn test_detect_elixir_umbrella() {
493 let dir = tempfile::tempdir().unwrap();
494 std::fs::write(
495 dir.path().join("mix.exs"),
496 "defmodule Root.MixProject do\nend",
497 )
498 .unwrap();
499 std::fs::create_dir_all(dir.path().join("apps")).unwrap();
500
501 let ecosystems = detect_ecosystems(dir.path()).unwrap();
502 assert!(ecosystems.contains(&Ecosystem::Elixir));
503 }
504
505 #[test]
506 fn test_detect_sbt_project() {
507 let dir = tempfile::tempdir().unwrap();
508 std::fs::write(
509 dir.path().join("build.sbt"),
510 "lazy val root = (project in file(\".\"))",
511 )
512 .unwrap();
513
514 let ecosystems = detect_ecosystems(dir.path()).unwrap();
515 assert!(ecosystems.contains(&Ecosystem::Sbt));
516 }
517}