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.
//! CLI ↔ wiremock matrix — exercises the live HTTP path of the 4 verbs that
//! talk to cleanlib-app, mocked via `wiremock`. Each case emits its
//! `(verb, wrapper, status)` triple on failure.

use cleanlib_client::{transport, types};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

const VERBS: &[&str] = &["verdict", "scan", "audit", "policy_preview"];
const WRAPPERS: &[&str] = &["npm", "pip", "cargo", "go"];
const ECOSYSTEMS: &[(&str, &str)] = &[
    ("npm", "npm"),
    ("pip", "pypi"),
    ("cargo", "crates"),
    ("go", "go"),
];
const STATUSES: &[&str] = &[
    "ALLOWED_NO_FINDINGS",
    "RISK_ACCEPTANCE_REQUIRED",
    "VECTOR_VERDICT",
    "INSUFFICIENT_DATA",
];

// Maps a wire-vocab decision string to the policy-preview decision shape.
fn status_for_policy(status: &str) -> String {
    match status {
        "ALLOWED_NO_FINDINGS" => "ALLOW".to_string(),
        "RISK_ACCEPTANCE_REQUIRED" => "WARN".to_string(),
        "VECTOR_VERDICT" => "DENY".to_string(),
        "INSUFFICIENT_DATA" => "INSUFFICIENT_DATA".to_string(),
        other => other.to_string(),
    }
}

fn fixture_pkg_for_wrapper(wrapper: &str) -> (&'static str, &'static str) {
    match wrapper {
        "npm" => ("cors", "2.8.5"),
        "pip" => ("requests", "2.32.5"),
        "cargo" => ("serde", "1.0.200"),
        "go" => ("github.com/sirupsen/logrus", "v1.9.0"),
        _ => unreachable!(),
    }
}

#[tokio::test]
async fn matrix_64_cases() {
    let mut failed: Vec<(String, String, String, String)> = Vec::new();

    for verb in VERBS {
        for (wrapper, ecosystem) in ECOSYSTEMS {
            for status in STATUSES {
                let case_id = format!("{}/{}/{}", verb, wrapper, status);
                if let Err(msg) = run_case(verb, wrapper, ecosystem, status).await {
                    failed.push((
                        verb.to_string(),
                        wrapper.to_string(),
                        status.to_string(),
                        msg,
                    ));
                    eprintln!("CASE FAIL [{}]: {}", case_id, failed.last().unwrap().3);
                } else {
                    eprintln!("CASE PASS [{}]", case_id);
                }
            }
        }
    }

    let total = VERBS.len() * WRAPPERS.len() * STATUSES.len();
    assert_eq!(total, 64, "matrix dimensions changed; update assertion");
    if !failed.is_empty() {
        for (v, w, s, m) in &failed {
            eprintln!("FAIL ({}, {}, {}): {}", v, w, s, m);
        }
        panic!(
            "{} of {} integration cases failed",
            failed.len(),
            total
        );
    }
}

async fn run_case(
    verb: &str,
    wrapper: &str,
    ecosystem: &str,
    status: &str,
) -> Result<(), String> {
    let server = MockServer::start().await;
    let (pkg, version) = fixture_pkg_for_wrapper(wrapper);

    match verb {
        "verdict" => {
            let resp = ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "verdict_id": format!("01J-{}-{}", wrapper, status),
                "verdict": status,
                "source": status,
                "confidence": 0.9,
                "composite_score": match status {
                    "VECTOR_VERDICT" => 90,
                    "RISK_ACCEPTANCE_REQUIRED" => 60,
                    _ => 5,
                },
                "reasoning": format!("synthetic {} case", status),
                "computed_at": "2026-05-28T00:00:00Z"
            }));
            Mock::given(method("GET"))
                .and(path_regex(r"^/v1/customer/verdicts/"))
                .respond_with(resp)
                .mount(&server)
                .await;

            let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
                .map_err(|e| e.to_string())?;
            let v = client
                .fetch_verdict(ecosystem, pkg, version)
                .await
                .map_err(|e| e.to_string())?;
            if v.verdict != status {
                return Err(format!("expected verdict={} got {}", status, v.verdict));
            }
            Ok(())
        }
        "scan" | "policy_preview" => {
            let decision = status_for_policy(status);
            let resp = ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "decisions": [{
                    "ecosystem": ecosystem,
                    "package": pkg,
                    "version": version,
                    "decision": decision.clone(),
                    "reason": format!("{} reason", decision)
                }]
            }));
            Mock::given(method("POST"))
                .and(path_regex(r"^/v1/customer/policy/preview"))
                .respond_with(resp)
                .mount(&server)
                .await;
            let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
                .map_err(|e| e.to_string())?;
            let req = types::PolicyPreviewRequest {
                packages: vec![types::PackageRef {
                    ecosystem: ecosystem.to_string(),
                    name: pkg.to_string(),
                    version: version.to_string(),
                }],
                policy: None,
            };
            let r = client.policy_preview(&req).await.map_err(|e| e.to_string())?;
            if r.decisions.len() != 1 {
                return Err(format!("expected 1 decision; got {}", r.decisions.len()));
            }
            if r.decisions[0].decision != decision {
                return Err(format!(
                    "expected decision={} got {}",
                    decision, r.decisions[0].decision
                ));
            }
            Ok(())
        }
        "audit" => {
            let decision = status_for_policy(status);
            let resp = ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "entries": [{
                    "request_id": format!("req-{}-{}", wrapper, status),
                    "at": "2026-05-28T00:00:00Z",
                    "ecosystem": ecosystem,
                    "package": pkg,
                    "version": version,
                    "decision": decision,
                    "reason": "audit synthetic"
                }]
            }));
            Mock::given(method("GET"))
                .and(path_regex(r"^/v1/customer/audit"))
                .respond_with(resp)
                .mount(&server)
                .await;
            let client = transport::Client::new(&server.uri(), Some("test-bearer".into()))
                .map_err(|e| e.to_string())?;
            let r = client
                .audit(None, Some(&decision), Some(ecosystem))
                .await
                .map_err(|e| e.to_string())?;
            if r.entries.len() != 1 {
                return Err(format!("expected 1 entry; got {}", r.entries.len()));
            }
            if r.entries[0].decision != decision {
                return Err(format!(
                    "expected entry decision={} got {}",
                    decision, r.entries[0].decision
                ));
            }
            Ok(())
        }
        other => Err(format!("unknown verb {}", other)),
    }
}