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.
//! `cleanlib verdict --ecosystem <ecosystem> --package <package> --version <version>` (cycle-7 Cli2).
//!
//! Migrates the cycle-4 `cmd_verdict` body out of `main.rs` into this
//! handler module. The render path now flows through `render::terminal`
//! (Cli3) instead of inline; cache lookup on transport-error miss is the
//! Cli7 offline-mode fallback (treats the cached entry as `LIVE_DEGRADED`).

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

use crate::cache::{default_cache_dir, PersistentCache};
use crate::render::terminal;
use crate::render::json;
use crate::render::sarif;

use super::verdict_exit_code;

/// Version string surfaced in the SARIF `tool.driver.version` field.
/// Sourced from the crate's Cargo `version` at compile time so
/// `cleanlib verdict --output sarif` self-identifies without a drift-prone
/// hand-maintained constant. Sister of CLEANLIB-196.
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Execute the verdict verb. `output` is `"text"` (default) or `"json"`.
///
/// CLEANLIB-130 / Jira CLEANLIB-31b: exit code is now decision-aware —
/// ALLOW→0, WARN→2, DENY→1, RISK_ACCEPTANCE_REQUIRED→3 — so customer
/// CI/CD pipelines fail loudly when verdict DENY is rendered. Pre-fix
/// behavior was always exit 0 on render-success, silently bypassing the
/// security gate. Sister of `scan_exit_code` already wired in
/// `commands::scan` + `commands::policy::preview` + `commands::wrap`.
pub async fn run(ecosystem: String, package: String, version: String, output: String) -> Result<()> {
    let path = config::default_path();
    let cfg = config::load_with_env_overrides(path.as_deref())?;
    let client = transport::Client::from_config(&cfg)?;

    let (verdict, from_cache) = match client.fetch_verdict(&ecosystem, &package, &version).await {
        Ok(v) => {
            // Populate persistent cache on every successful live response
            // (per dispatch §2.6 — populated on every successful
            // `cleanlib verdict`).
            if let Some(dir) = default_cache_dir() {
                if let Ok(cache) = PersistentCache::open(&dir) {
                    let _ = cache.put(&ecosystem, &package, &version, v.clone());
                }
            }
            (v, false)
        }
        Err(e) => {
            // Cli7 offline-fallback: when cleanlib-enrich is unreachable,
            // check the persistent cache; a hit within TTL emits the
            // verdict with a `(cached: ...)` annotation. Any cache-miss /
            // expiry propagates the original transport error.
            if let Some(dir) = default_cache_dir() {
                if let Ok(cache) = PersistentCache::open(&dir) {
                    if let Ok(Some(entry)) = cache.get(&ecosystem, &package, &version) {
                        if let Some(cached_at) = entry.cached_at() {
                            eprintln!(
                                "# warning: cleanlib-enrich unreachable; serving cached verdict (cached: {})",
                                cached_at.format("%Y-%m-%d")
                            );
                        } else {
                            eprintln!(
                                "# warning: cleanlib-enrich unreachable; serving cached verdict"
                            );
                        }
                        render_verdict(&entry.envelope, &output, &ecosystem, &package, &version)?;
                        // Still honor decision-derived exit code on cache hit.
                        let code = verdict_exit_code(&entry.envelope);
                        if code != 0 {
                            std::process::exit(code);
                        }
                        return Ok(());
                    }
                }
            }
            return Err(e.into());
        }
    };
    let _ = from_cache;

    render_verdict(&verdict, &output, &ecosystem, &package, &version)?;

    // CLEANLIB-31b close: propagate decision → exit code AFTER render so
    // the customer sees the verdict text before the process terminates.
    let code = verdict_exit_code(&verdict);
    if code != 0 {
        std::process::exit(code);
    }
    Ok(())
}

fn render_verdict(
    v: &cleanlib_client::types::Verdict,
    output: &str,
    ecosystem: &str,
    package: &str,
    version: &str,
) -> Result<()> {
    match output {
        "json" => {
            // CLEANLIB-178: enrich the JSON envelope with the customer-facing
            // `state` (+ label/tier) derived from the wire `source`, so
            // programmatic consumers get the same 8-state taxonomy the text
            // renderer + extension + SDKs use — without re-deriving it. The
            // raw wire fields are preserved untouched (additive, back-compat).
            let state = cleanlib_client::CustomerState::from_wire(&v.source);
            let mut value = serde_json::to_value(v)?;
            if let serde_json::Value::Object(map) = &mut value {
                map.insert("state".into(), state.as_str().into());
                map.insert("state_label".into(), state.label().into());
                map.insert(
                    "state_tier".into(),
                    match state.tier() {
                        cleanlib_client::Tier::Block => "block",
                        cleanlib_client::Tier::Warn => "warn",
                        cleanlib_client::Tier::Clean => "clean",
                    }
                    .into(),
                );
            }
            json::render_verdict(v, &json::RenderOpts::default());
        }
        "text" => terminal::render_verdict(v, &terminal::RenderOpts::default()),
        // CLEANLIB-196 (Client-3.1) — SARIF v2.1.0 output. Emits an OASIS
        // SARIF log with a single result carrying the verdict-label as
        // ruleId, the 3-tier decision mapped to SARIF `level`, and the
        // package coordinate as a logicalLocation. Consumed by GitHub Code
        // Scanning, GitLab MR Security Dashboard, and third-party CI
        // security dashboards. Serialization error is surfaced (not
        // silently dropped like the JSON path) because a corrupt SARIF
        // upload breaks a downstream Code-Scanning ingest.
        "sarif" => {
            sarif::print_verdict_sarif(v, ecosystem, package, version, CLI_VERSION)
                .map_err(|e| anyhow::anyhow!("failed to serialise SARIF: {e}"))?;
        }
        // CLEANLIB-166: unreachable in practice — the `--output` value is now
        // clap-validated at parse-time via the `OutputFormat` ValueEnum in
        // main.rs, so an invalid string exits with clap's canonical error
        // (`invalid value 'X' for '--output <FORMAT>'`) before we get here.
        // Kept as a defensive fail-loud path in case any future caller invokes
        // this helper directly with an off-wire string (fail loud > silent
        // text fallback, which was the original CLEANLIB-166 bug).
        other => {
            eprintln!(
                "error: unsupported output format '{}'. valid options are: json, text, sarif",
                other
            );
            std::process::exit(2);
        }
    }
    Ok(())
}