cleanlib-client 0.3.0

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! CLEANLIB-691 — canonical manifest-filename → ecosystem detection.
//!
//! Single source of truth so every surface infers the ecosystem identically:
//! the CLI (`cleanlib scan --packages <file>` without `--ecosystem`), the LSP,
//! the MCP tools, and the SDK convenience wrappers. Lives in `cleanlib-client`
//! (not the CLI) for the same reason the customer-state taxonomy does — it is a
//! cross-surface contract, not a CLI-local helper.
//!
//! Detection is on the file NAME (basename). It is deliberately conservative:
//! it recognises the canonical manifest/lockfile names and returns `None` for
//! anything ambiguous or unrecognised, so the caller can prompt for an explicit
//! `--ecosystem` rather than guess wrong on a security product.

/// The wire ecosystem string for a manifest/lockfile path, or `None` when the
/// filename is not a recognised manifest (the caller then asks the user to pass
/// `--ecosystem` explicitly). Matches the ecosystem names the App's
/// `GET /health` advertises (npm / pypi / crates / go / maven / packagist /
/// rubygems / nuget / pub).
///
/// The `path` may be a full path or a bare filename; only the last path
/// component (after `/` or `\`) is inspected.
pub fn ecosystem_from_path(path: &str) -> Option<&'static str> {
    let name = path.rsplit(['/', '\\']).next().unwrap_or(path);

    // Exact canonical manifest/lockfile names (the deterministic ≥95% case).
    let eco = match name {
        "package.json" | "package-lock.json" | "npm-shrinkwrap.json" | "yarn.lock"
        | "pnpm-lock.yaml" => "npm",
        "requirements.txt" | "Pipfile" | "Pipfile.lock" | "pyproject.toml" | "poetry.lock"
        | "setup.py" => "pypi",
        "Cargo.toml" | "Cargo.lock" => "crates",
        "go.mod" | "go.sum" => "go",
        "pom.xml" | "build.gradle" | "build.gradle.kts" => "maven",
        "composer.json" | "composer.lock" => "packagist",
        "Gemfile" | "Gemfile.lock" => "rubygems",
        "packages.config" => "nuget",
        "pubspec.yaml" | "pubspec.lock" => "pub",
        _ => "",
    };
    if !eco.is_empty() {
        return Some(eco);
    }

    // Patterned names the exact match can't enumerate:
    //  - `requirements-dev.txt` / `requirements-test.txt` … → pypi
    //  - `<project>.csproj` → nuget
    if name.starts_with("requirements-") && name.ends_with(".txt") {
        return Some("pypi");
    }
    if name.ends_with(".csproj") {
        return Some("nuget");
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonical_manifests_map_to_their_ecosystem() {
        let cases = [
            ("package.json", "npm"),
            ("package-lock.json", "npm"),
            ("yarn.lock", "npm"),
            ("pnpm-lock.yaml", "npm"),
            ("requirements.txt", "pypi"),
            ("pyproject.toml", "pypi"),
            ("Pipfile", "pypi"),
            ("poetry.lock", "pypi"),
            ("Cargo.toml", "crates"),
            ("Cargo.lock", "crates"),
            ("go.mod", "go"),
            ("go.sum", "go"),
            ("pom.xml", "maven"),
            ("build.gradle", "maven"),
            ("build.gradle.kts", "maven"),
            ("composer.json", "packagist"),
            ("Gemfile", "rubygems"),
            ("Gemfile.lock", "rubygems"),
            ("packages.config", "nuget"),
            ("pubspec.yaml", "pub"),
        ];
        for (file, eco) in cases {
            assert_eq!(ecosystem_from_path(file), Some(eco), "{file}");
        }
    }

    #[test]
    fn inspects_only_the_basename_of_a_full_path() {
        assert_eq!(ecosystem_from_path("/home/dev/proj/package.json"), Some("npm"));
        assert_eq!(ecosystem_from_path("./sub/dir/Cargo.toml"), Some("crates"));
        // Windows-style separators too.
        assert_eq!(ecosystem_from_path(r"C:\proj\go.mod"), Some("go"));
    }

    #[test]
    fn patterned_names_are_detected() {
        assert_eq!(ecosystem_from_path("requirements-dev.txt"), Some("pypi"));
        assert_eq!(ecosystem_from_path("requirements-test.txt"), Some("pypi"));
        assert_eq!(ecosystem_from_path("MyApp.csproj"), Some("nuget"));
    }

    #[test]
    fn unrecognised_or_ambiguous_returns_none() {
        // A random file, an extensionless name, and a bare directory-ish path
        // must NOT be guessed — the caller prompts for explicit --ecosystem.
        assert_eq!(ecosystem_from_path("deps.txt"), None);
        assert_eq!(ecosystem_from_path("README.md"), None);
        assert_eq!(ecosystem_from_path("manifest"), None);
        assert_eq!(ecosystem_from_path(""), None);
    }
}