cleanlib-client 0.4.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 App's actual canonical set
/// (`cleanlib-app::http::SUPPORTED_ECOSYSTEMS`): npm / pypi / go / maven /
/// crates / nuget / rubygems / composer. CLEANLIB-864: this used to also claim
/// "packagist" (composer.json/.lock) and "pub" (pubspec.yaml/.lock) — neither
/// is in that set, so every detection into either name failed at the App with
/// `400 unknown_ecosystem` on every attempt. composer.json/.lock now map to
/// the real name; pubspec.yaml/.lock are dropped (no `pub` backend exists at
/// all, so returning `None` and letting the caller be told to pass an explicit
/// `--ecosystem` is the honest answer, not a wrong one).
///
/// 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",
        // CLEANLIB-864: was "packagist" -- the App's actual customer-verdict
        // route rejects that name outright (`SUPPORTED_ECOSYSTEMS` in
        // cleanlib-app::http.rs is the 8-name canonical set: npm/pypi/go/
        // maven/crates/nuget/rubygems/composer, no "packagist"). Live-confirmed:
        // `/v1/customer/verdicts/composer/...` -> 200, `/v1/customer/verdicts/
        // packagist/...` -> 400 unknown_ecosystem. This function's ONE job is
        // to hand callers a name the rest of the platform accepts, so it was
        // simply wrong, not a stylistic choice.
        "composer.json" | "composer.lock" => "composer",
        "Gemfile" | "Gemfile.lock" => "rubygems",
        "packages.config" => "nuget",
        // CLEANLIB-864: `pubspec.yaml`/`pubspec.lock` (Dart/Flutter) used to
        // detect as "pub", which has NO backend at all -- not even the
        // audit-only fallback vocabulary the App's other ecosystem list
        // carries covers actually serving verdicts for it. Detecting a
        // manifest into a name that can never resolve is worse than not
        // detecting it: the caller gets a confident answer that is
        // deterministically wrong on every attempt, forever, with no
        // `--ecosystem` override able to fix it since no real ecosystem
        // exists to pass. Dropped entirely so these fall through to `None`
        // below (the same honest "ask the user" path used for any other
        // unrecognised manifest) until a real `pub` backend exists.
        _ => "",
    };
    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"),
            // CLEANLIB-864: was "packagist" -- the App's real customer-verdict
            // route rejects that name (400 unknown_ecosystem); the canonical
            // name is "composer". This assertion used to pin the WRONG value
            // -- it passed because it agreed with the bug, not because the
            // bug was absent.
            ("composer.json", "composer"),
            ("composer.lock", "composer"),
            ("Gemfile", "rubygems"),
            ("Gemfile.lock", "rubygems"),
            ("packages.config", "nuget"),
        ];
        for (file, eco) in cases {
            assert_eq!(ecosystem_from_path(file), Some(eco), "{file}");
        }
    }

    #[test]
    fn cleanlib_864_pub_is_no_longer_detected_no_backend_exists() {
        // pubspec.yaml/.lock used to detect as "pub", a name the App never
        // accepts on the customer-verdict route (no backend exists for it at
        // all). Dropped entirely -- must now fall through to the same honest
        // `None` (ask for --ecosystem) as any other unrecognised manifest,
        // not silently resolve to a name that can never work.
        assert_eq!(ecosystem_from_path("pubspec.yaml"), None);
        assert_eq!(ecosystem_from_path("pubspec.lock"), None);
    }

    #[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);
    }

    /// Contract guard credited to a human engineer's independently-authored
    /// PR (#568/#569 — same CLEANLIB-864/865 root causes, arriving after this
    /// crate's own fix had already merged). Their PR added exactly this
    /// regression class of test where ours didn't: it would have caught the
    /// original bug (`ecosystem_from_path` emitting `"packagist"`, a name
    /// cleanlib-app's `SUPPORTED_ECOSYSTEMS` rejects outright) automatically,
    /// rather than needing a human to notice a live 400 by hand.
    ///
    /// `WIRE_ACCEPTED` is verified directly against
    /// `cleanlib-app/src/http.rs::SUPPORTED_ECOSYSTEMS` (not just copied from
    /// this file's own comments) — that's the actual wire contract this
    /// function must never violate.
    #[test]
    fn every_detected_ecosystem_is_wire_accepted() {
        const WIRE_ACCEPTED: [&str; 8] =
            ["npm", "pypi", "go", "maven", "crates", "nuget", "rubygems", "composer"];

        // Every manifest filename this function recognizes: the full exact-
        // match table, plus one representative name per patterned case.
        //
        // `pubspec.yaml`/`pubspec.lock` are included even though this crate's
        // CLEANLIB-864 fix took a different (equally valid) shape than
        // #568/#569's KNOWN_GAP-list approach: rather than keep detecting
        // `"pub"` and documenting it as an accepted exception, this crate
        // drops `pub` detection entirely — see
        // `cleanlib_864_pub_is_no_longer_detected_no_backend_exists` above —
        // so they fall through to `None` and this loop's `continue` skips
        // them today. They stay in the input set anyway so a future
        // reintroduction of `pub` detection would be caught by this same
        // guard instead of silently missed.
        let manifests = [
            "package.json",
            "package-lock.json",
            "npm-shrinkwrap.json",
            "yarn.lock",
            "pnpm-lock.yaml",
            "requirements.txt",
            "Pipfile",
            "Pipfile.lock",
            "pyproject.toml",
            "poetry.lock",
            "setup.py",
            "Cargo.toml",
            "Cargo.lock",
            "go.mod",
            "go.sum",
            "pom.xml",
            "build.gradle",
            "build.gradle.kts",
            "composer.json",
            "composer.lock",
            "Gemfile",
            "Gemfile.lock",
            "packages.config",
            "pubspec.yaml",
            "pubspec.lock",
            // patterned cases
            "requirements-dev.txt",
            "MyApp.csproj",
        ];
        for m in manifests {
            let Some(eco) = ecosystem_from_path(m) else {
                continue;
            };
            assert!(
                WIRE_ACCEPTED.contains(&eco),
                "{m} detects as `{eco}`, which cleanlib-app's SUPPORTED_ECOSYSTEMS rejects"
            );
        }
    }
}