cleanlib-cli 0.1.0

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> <package> <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 super::verdict_exit_code;

/// 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)?;
                        // 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)?;

    // 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) -> Result<()> {
    match output {
        "json" => println!("{}", serde_json::to_string_pretty(v)?),
        _ => terminal::render_verdict(v, &terminal::RenderOpts::default()),
    }
    Ok(())
}