cleanlib-cli 0.1.5

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! Package-manager wrapper handler — cycle-9 CLEANLIB-112 R1 fix-forward.
//!
//! R1 audit Lane 1 finding: 33 unit tests prove the wrapper parser substrate
//! (`src/wrappers/{npm,pip,cargo,go}.rs`) exists, but the runtime had no
//! `cleanlib npm/pip/cargo/go` subcommand variants — invocation returned
//! "unrecognized subcommand". This handler closes the gap: parses the
//! wrapped command's positional args via the existing wrapper module,
//! extracts targeted package coordinates, and emits verdicts for each.
//!
//! Scope (v0.1.0): wrap-mode — reports what would be gated. Full transparent
//! gating (intercept binary exec + auto-block on DENY) is the `shell-init`
//! flow per cycle-7 dispatch §2.4; deferred to a future release.
//!
//! CLEANLIB-82: the pass-through hint used to say "use `cleanlib shell-init`
//! for transparent gating" — but `shell-init` is not a subcommand in
//! v0.1.x; following the hint returns "error: unrecognized subcommand
//! 'shell-init'". The hint now points at the actionable `cleanlib <mgr>
//! <install args>` invocation that DOES exist today.
//!
//! Exit code follows the same convention as `cleanlib scan` (see
//! `super::scan_exit_code`): 0 all ALLOW · 1 any DENY · 2 any WARN ·
//! 3 any RISK_ACCEPTANCE_REQUIRED.

use anyhow::Result;
use cleanlib_client::{config, transport, types::PolicyDecision};

use crate::wrappers::{cargo as wrap_cargo, go as wrap_go, npm as wrap_npm, pip as wrap_pip, WrappedPackage};

/// Wrapper-subcommand runtime entry. `manager` is the name of the wrapper
/// (`npm`/`pip`/`cargo`/`go`); `args` is the trailing positional argv from
/// clap (the args the user would have passed to the underlying binary).
pub async fn run(manager: &str, args: Vec<String>) -> Result<()> {
    // Reconstruct argv with the manager binary name as argv[0]; the wrapper
    // parsers expect that shape.
    let mut argv_owned: Vec<String> = vec![manager.to_string()];
    argv_owned.extend(args);
    let argv: Vec<&str> = argv_owned.iter().map(|s| s.as_str()).collect();

    let packages: Vec<WrappedPackage> = match manager {
        "npm" => wrap_npm::parse(&argv),
        "pip" => wrap_pip::parse(&argv),
        "cargo" => wrap_cargo::parse(&argv),
        "go" => wrap_go::parse(&argv),
        other => return Err(anyhow::anyhow!("unsupported wrapper: {}", other)),
    };

    if packages.is_empty() {
        eprintln!(
            "# cleanlib {}: no install verb / packages detected in args; nothing to gate",
            manager
        );
        // CLEANLIB-82: point users at the invocation that DOES exist today,
        // not the deferred `shell-init` subcommand — the pre-fix hint sent
        // users chasing an "unrecognized subcommand" error.
        eprintln!(
            "# pass-through: run the underlying `{}` command directly, \
             or re-invoke as `cleanlib {} install <pkg>` to see verdicts \
             alongside the wrapped command",
            manager, manager
        );
        return Ok(());
    }

    eprintln!(
        "# cleanlib {}: parsed {} package(s) for verdict gating",
        manager,
        packages.len()
    );
    for p in &packages {
        eprintln!("#   - {}/{}@{}", p.ecosystem, p.name, p.version);
    }

    // Fetch verdicts; aggregate decisions for exit code.
    let path = config::default_path();
    let cfg = config::load_with_env_overrides(path.as_deref())?;
    let client = transport::Client::from_config(&cfg)?;

    let mut decisions: Vec<PolicyDecision> = Vec::with_capacity(packages.len());
    let mut any_unreachable = false;
    for p in &packages {
        match client.fetch_verdict(&p.ecosystem, &p.name, &p.version).await {
            Ok(v) => {
                let dec = derive_decision_from_verdict(&v);
                eprintln!(
                    "# {}/{}@{}: decision={}",
                    p.ecosystem, p.name, p.version, dec.decision
                );
                decisions.push(dec);
            }
            Err(e) => {
                any_unreachable = true;
                eprintln!(
                    "# {}/{}@{}: verdict fetch failed: {} — treating as pass-through (UNREACHABLE)",
                    p.ecosystem, p.name, p.version, e
                );
            }
        }
    }

    if any_unreachable && decisions.is_empty() {
        // All packages failed to resolve — emit warning but don't gate.
        eprintln!("# all verdict fetches failed; pass-through mode");
        return Ok(());
    }

    let code = super::scan_exit_code(&decisions);
    if code != 0 {
        std::process::exit(code);
    }
    Ok(())
}

/// Derive a `PolicyDecision` shape from a fetched `Verdict`. Prefers the
/// canonical `decision` field added in Lane-2 M1; falls back to mapping the
/// `verdict` label (`ALLOWED_NO_FINDINGS`/`VECTOR_VERDICT`/`DM_THRESHOLD_BLOCK`/
/// `INSUFFICIENT_DATA`) to a coarse decision for pre-M1 payloads.
fn derive_decision_from_verdict(v: &cleanlib_client::types::Verdict) -> PolicyDecision {
    let decision_str = v.decision.clone().unwrap_or_else(|| {
        // Fall back to mapping the verdict label → decision for payloads
        // not yet carrying the explicit `decision` field. Same semantic
        // mapping the App-side `cleanlib-core::policy_evaluator` applies
        // when an explicit policy decision isn't present.
        match v.verdict.as_str() {
            "VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" => "DENY".to_string(),
            "INSUFFICIENT_DATA" => "WARN".to_string(),
            "ALLOWED_NO_FINDINGS" => "ALLOW".to_string(),
            _ => "ALLOW".to_string(),
        }
    });
    PolicyDecision {
        decision: decision_str,
        ..PolicyDecision::default()
    }
}

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

    /// The four wrapper names are recognized + dispatched to the right
    /// parser without "unsupported wrapper" — sister-shape gate of the
    /// CLEANLIB-112 unrecognized-subcommand audit finding.
    #[test]
    fn supported_managers_resolve_without_unsupported_error() {
        for manager in ["npm", "pip", "cargo", "go"] {
            // Empty args → empty packages; no parser error expected.
            let argv = vec![manager.to_string()];
            let argv_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
            let packages = match manager {
                "npm" => wrap_npm::parse(&argv_refs),
                "pip" => wrap_pip::parse(&argv_refs),
                "cargo" => wrap_cargo::parse(&argv_refs),
                "go" => wrap_go::parse(&argv_refs),
                _ => unreachable!(),
            };
            assert!(packages.is_empty(), "{}: empty argv should yield empty packages", manager);
        }
    }

    #[test]
    fn unsupported_wrapper_returns_error() {
        // Build the runtime call shape; `tokio_test` would let us .await it.
        // Instead, exercise the early-return branch directly via match.
        let manager = "maven";
        let result = match manager {
            "npm" | "pip" | "cargo" | "go" => Ok(()),
            other => Err(format!("unsupported wrapper: {}", other)),
        };
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("maven"));
    }

    /// CLEANLIB-82 regression guard: source-level assertion that the
    /// pass-through hint does not mention the unimplemented `shell-init`
    /// subcommand — end-to-end coverage lives in
    /// `tests/integration/wrap.rs` and drives the real binary. Keeps the
    /// unit-test surface honest even if the binary integration test is
    /// skipped for any reason.
    #[test]
    fn pass_through_hint_does_not_reference_shell_init() {
        // The wrap.rs source itself is embedded here so we can assert the
        // user-visible hint substring never re-introduces the bad phrase.
        // The hint lives in the `if packages.is_empty()` branch of `run`;
        // any edit that puts "shell-init" back into that branch will trip
        // this guard.
        let src = include_str!("wrap.rs");
        // Find the pass-through eprintln! block by anchor.
        let hint_region = src
            .split("# pass-through:")
            .nth(1)
            .expect("pass-through hint present in wrap.rs");
        // Limit to the string literal (up to the next `);` closer).
        let hint_body = hint_region.split(");").next().unwrap_or("");
        assert!(
            !hint_body.contains("shell-init"),
            "CLEANLIB-82: pass-through hint must not mention `shell-init` \
             (unimplemented subcommand); got hint body: {hint_body}"
        );
    }
}