Skip to main content

affected_core/
detect.rs

1use anyhow::Result;
2use std::path::Path;
3use tracing::debug;
4
5use crate::types::Ecosystem;
6
7/// Detect which ecosystem(s) a project uses by scanning for marker files.
8pub fn detect_ecosystems(root: &Path) -> Result<Vec<Ecosystem>> {
9    let mut detected = Vec::new();
10
11    // Cargo: Cargo.toml with [workspace]
12    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    // Yarn: .yarnrc.yml exists → Ecosystem::Yarn (takes priority over Bun/npm)
23    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        // Bun: bun.lock/bun.lockb/bunfig.toml (takes priority over npm)
29        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    // Go: go.work (workspace) or go.mod (single module)
62    if root.join("go.work").exists() || root.join("go.mod").exists() {
63        debug!("Detected Go project");
64        detected.push(Ecosystem::Go);
65    }
66
67    // Python: check for Poetry, uv, or generic pyproject.toml
68    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        // Scan one level deep for pyproject.toml files
86        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    // Maven: pom.xml exists at root and contains <modules>
108    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    // Gradle: settings.gradle or settings.gradle.kts exists
119    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    // .NET: *.sln file at root
125    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    // Swift: Package.swift with multiple targets or multiple Package.swift in subdirs
140    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    // Dart/Flutter: pubspec.yaml with workspace, melos.yaml, or multiple pubspec.yaml
154    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    // Elixir: mix.exs + apps/ directory (umbrella project)
190    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    // Scala/sbt: build.sbt at root
196    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}