Skip to main content

cleanlib_client/
ecosystem_detect.rs

1//! CLEANLIB-691 — canonical manifest-filename → ecosystem detection.
2//!
3//! Single source of truth so every surface infers the ecosystem identically:
4//! the CLI (`cleanlib scan --packages <file>` without `--ecosystem`), the LSP,
5//! the MCP tools, and the SDK convenience wrappers. Lives in `cleanlib-client`
6//! (not the CLI) for the same reason the customer-state taxonomy does — it is a
7//! cross-surface contract, not a CLI-local helper.
8//!
9//! Detection is on the file NAME (basename). It is deliberately conservative:
10//! it recognises the canonical manifest/lockfile names and returns `None` for
11//! anything ambiguous or unrecognised, so the caller can prompt for an explicit
12//! `--ecosystem` rather than guess wrong on a security product.
13
14/// The wire ecosystem string for a manifest/lockfile path, or `None` when the
15/// filename is not a recognised manifest (the caller then asks the user to pass
16/// `--ecosystem` explicitly). Matches the ecosystem names the App's
17/// `GET /health` advertises (npm / pypi / crates / go / maven / packagist /
18/// rubygems / nuget / pub).
19///
20/// The `path` may be a full path or a bare filename; only the last path
21/// component (after `/` or `\`) is inspected.
22pub fn ecosystem_from_path(path: &str) -> Option<&'static str> {
23    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
24
25    // Exact canonical manifest/lockfile names (the deterministic ≥95% case).
26    let eco = match name {
27        "package.json" | "package-lock.json" | "npm-shrinkwrap.json" | "yarn.lock"
28        | "pnpm-lock.yaml" => "npm",
29        "requirements.txt" | "Pipfile" | "Pipfile.lock" | "pyproject.toml" | "poetry.lock"
30        | "setup.py" => "pypi",
31        "Cargo.toml" | "Cargo.lock" => "crates",
32        "go.mod" | "go.sum" => "go",
33        "pom.xml" | "build.gradle" | "build.gradle.kts" => "maven",
34        "composer.json" | "composer.lock" => "packagist",
35        "Gemfile" | "Gemfile.lock" => "rubygems",
36        "packages.config" => "nuget",
37        "pubspec.yaml" | "pubspec.lock" => "pub",
38        _ => "",
39    };
40    if !eco.is_empty() {
41        return Some(eco);
42    }
43
44    // Patterned names the exact match can't enumerate:
45    //  - `requirements-dev.txt` / `requirements-test.txt` … → pypi
46    //  - `<project>.csproj` → nuget
47    if name.starts_with("requirements-") && name.ends_with(".txt") {
48        return Some("pypi");
49    }
50    if name.ends_with(".csproj") {
51        return Some("nuget");
52    }
53
54    None
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn canonical_manifests_map_to_their_ecosystem() {
63        let cases = [
64            ("package.json", "npm"),
65            ("package-lock.json", "npm"),
66            ("yarn.lock", "npm"),
67            ("pnpm-lock.yaml", "npm"),
68            ("requirements.txt", "pypi"),
69            ("pyproject.toml", "pypi"),
70            ("Pipfile", "pypi"),
71            ("poetry.lock", "pypi"),
72            ("Cargo.toml", "crates"),
73            ("Cargo.lock", "crates"),
74            ("go.mod", "go"),
75            ("go.sum", "go"),
76            ("pom.xml", "maven"),
77            ("build.gradle", "maven"),
78            ("build.gradle.kts", "maven"),
79            ("composer.json", "packagist"),
80            ("Gemfile", "rubygems"),
81            ("Gemfile.lock", "rubygems"),
82            ("packages.config", "nuget"),
83            ("pubspec.yaml", "pub"),
84        ];
85        for (file, eco) in cases {
86            assert_eq!(ecosystem_from_path(file), Some(eco), "{file}");
87        }
88    }
89
90    #[test]
91    fn inspects_only_the_basename_of_a_full_path() {
92        assert_eq!(ecosystem_from_path("/home/dev/proj/package.json"), Some("npm"));
93        assert_eq!(ecosystem_from_path("./sub/dir/Cargo.toml"), Some("crates"));
94        // Windows-style separators too.
95        assert_eq!(ecosystem_from_path(r"C:\proj\go.mod"), Some("go"));
96    }
97
98    #[test]
99    fn patterned_names_are_detected() {
100        assert_eq!(ecosystem_from_path("requirements-dev.txt"), Some("pypi"));
101        assert_eq!(ecosystem_from_path("requirements-test.txt"), Some("pypi"));
102        assert_eq!(ecosystem_from_path("MyApp.csproj"), Some("nuget"));
103    }
104
105    #[test]
106    fn unrecognised_or_ambiguous_returns_none() {
107        // A random file, an extensionless name, and a bare directory-ish path
108        // must NOT be guessed — the caller prompts for explicit --ecosystem.
109        assert_eq!(ecosystem_from_path("deps.txt"), None);
110        assert_eq!(ecosystem_from_path("README.md"), None);
111        assert_eq!(ecosystem_from_path("manifest"), None);
112        assert_eq!(ecosystem_from_path(""), None);
113    }
114}