cleanlib-cli 0.1.2

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! Subcommand handlers — cycle-7 Cli2. Each verb's handler lives in its own
//! module; `main.rs` is reduced to clap parsing + dispatch into these handlers.
//! Per dispatch §2.2: this is the migration target from the cycle-4 polish
//! state of `main.rs`.

pub mod audit;
pub mod config_init;
pub mod fetch;
pub mod login;
pub mod logout;
pub mod policy;
pub mod risk_accept;
pub mod scan;
pub mod status;
pub mod verdict;
pub mod wrap;

use cleanlib_client::types;

/// Normalize a raw decision / verdict-label string to the canonical
/// exit-code vocabulary (ALLOW / WARN / DENY / RISK_ACCEPTANCE_REQUIRED).
///
/// Shared by `scan_exit_code` + `verdict_exit_code` so both paths apply
/// identical fail-loud semantics and cannot drift apart again — the
/// CLEANLIB-155 root cause was exactly that drift: the scan path matched
/// only canonical decision strings (bare `_ => {}` fallthrough → exit 0)
/// while the verdict path (CLEANLIB-31b) already derived the block-labels,
/// so a block-equivalent verdict silently passed `cleanlib scan`.
///
/// Block-labels (`VECTOR_VERDICT` / `DM_THRESHOLD_BLOCK`) → DENY.
/// `INSUFFICIENT_DATA` + any unknown label → WARN (never silent-ALLOW),
/// mirroring the App-side policy_evaluator + `wrap::derive_decision_from_verdict`.
fn normalize_decision(raw: &str) -> &'static str {
    match raw {
        "ALLOW" | "ALLOWED_NO_FINDINGS" => "ALLOW",
        "DENY" | "VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" => "DENY",
        "RISK_ACCEPTANCE_REQUIRED" => "RISK_ACCEPTANCE_REQUIRED",
        // INSUFFICIENT_DATA + any unknown label → fail-loud WARN (exit 2),
        // never an implicit ALLOW that would silently pass customer CI.
        _ => "WARN",
    }
}

/// Exit-code convention per CLI vocab decision 2026-05-20 + dispatch §2.2:
/// `0` all ALLOW · `1` any DENY · `2` any WARN (no DENY) ·
/// `3` any RISK_ACCEPTANCE_REQUIRED (no DENY/WARN).
///
/// CLEANLIB-155: each decision is normalized first, so block-equivalent
/// labels (VECTOR_VERDICT / DM_THRESHOLD_BLOCK) map to DENY → exit 1 rather
/// than silently falling through to exit 0, and unknown labels fail loud as
/// WARN. Symmetric with `verdict_exit_code` via `normalize_decision`.
pub fn scan_exit_code(decisions: &[types::PolicyDecision]) -> i32 {
    let mut has_warn = false;
    let mut has_risk = false;
    for d in decisions {
        match normalize_decision(&d.decision) {
            "DENY" => return 1,
            "WARN" => has_warn = true,
            "RISK_ACCEPTANCE_REQUIRED" => has_risk = true,
            _ => {} // ALLOW
        }
    }
    if has_warn {
        2
    } else if has_risk {
        3
    } else {
        0
    }
}

/// Single-verdict exit-code — closes Jira CLEANLIB-31b (CI security gate
/// bypass: `cleanlib verdict` displays DENY on screen but exits 0). Sister
/// of `scan_exit_code` at the single-package axis; both share
/// `normalize_decision` so verdict + scan + wrap + policy-preview carry the
/// same exit-code contract and can't drift (CLEANLIB-155).
///
/// Prefers `Verdict.decision` (Lane-2 M1); falls back to the `verdict`
/// label for pre-M1 payloads. `INSUFFICIENT_DATA` + unknown → WARN (exit 2),
/// fail-loud so customer CI doesn't silently pass on missing/unknown data;
/// sister-shape of fail-loud-on-empty-bearer (CLEANLIB-129).
pub fn verdict_exit_code(v: &types::Verdict) -> i32 {
    let raw = v.decision.as_deref().unwrap_or(v.verdict.as_str());
    match normalize_decision(raw) {
        "DENY" => 1,
        "WARN" => 2,
        "RISK_ACCEPTANCE_REQUIRED" => 3,
        _ => 0,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cleanlib_client::types::{PolicyDecision, Verdict};

    fn d(decision: &str) -> PolicyDecision {
        PolicyDecision {
            decision: decision.to_string(),
            ..PolicyDecision::default()
        }
    }

    fn v_with_decision(decision: &str) -> Verdict {
        Verdict {
            decision: Some(decision.to_string()),
            ..Verdict::default()
        }
    }

    fn v_with_label(label: &str) -> Verdict {
        Verdict {
            verdict: label.to_string(),
            decision: None,
            ..Verdict::default()
        }
    }

    #[test]
    fn exit_zero_when_all_allow() {
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("ALLOW")]), 0);
    }

    #[test]
    fn exit_one_on_any_deny() {
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("DENY"), d("WARN")]), 1);
    }

    #[test]
    fn exit_two_on_warn_no_deny() {
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("WARN")]), 2);
    }

    #[test]
    fn exit_three_on_risk_only() {
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("RISK_ACCEPTANCE_REQUIRED")]), 3);
    }

    #[test]
    fn deny_dominates_risk() {
        assert_eq!(scan_exit_code(&[d("RISK_ACCEPTANCE_REQUIRED"), d("DENY")]), 1);
    }

    // CLEANLIB-155 — scan_exit_code must derive block-labels (the scan-path
    // sister-gap of CLEANLIB-31b): a block-equivalent verdict label must exit
    // 1, not silently 0; an unknown label must fail loud (exit 2), not pass.

    #[test]
    fn scan_exit_one_on_vector_verdict_label() {
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("VECTOR_VERDICT")]), 1);
    }

    #[test]
    fn scan_exit_one_on_dm_threshold_block_label() {
        assert_eq!(scan_exit_code(&[d("DM_THRESHOLD_BLOCK")]), 1);
    }

    #[test]
    fn scan_exit_two_on_unknown_label_fail_loud() {
        // Was the bug: unknown/non-canonical decision → bare `_ => {}` → 0
        // (silent CI pass). Now → WARN → exit 2.
        assert_eq!(scan_exit_code(&[d("ALLOW"), d("SOME_NEW_LABEL")]), 2);
    }

    #[test]
    fn scan_exit_zero_on_allowed_no_findings_label() {
        assert_eq!(scan_exit_code(&[d("ALLOWED_NO_FINDINGS"), d("ALLOW")]), 0);
    }

    // CLEANLIB-130 / Jira CLEANLIB-31b — verdict_exit_code coverage.

    #[test]
    fn verdict_exit_zero_on_allow_decision() {
        assert_eq!(verdict_exit_code(&v_with_decision("ALLOW")), 0);
    }

    #[test]
    fn verdict_exit_one_on_deny_decision() {
        // Core regression gate: DENY MUST exit 1 so customer CI fails the build.
        assert_eq!(verdict_exit_code(&v_with_decision("DENY")), 1);
    }

    #[test]
    fn verdict_exit_two_on_warn_decision() {
        assert_eq!(verdict_exit_code(&v_with_decision("WARN")), 2);
    }

    #[test]
    fn verdict_exit_three_on_risk_acceptance_required() {
        assert_eq!(
            verdict_exit_code(&v_with_decision("RISK_ACCEPTANCE_REQUIRED")),
            3
        );
    }

    #[test]
    fn verdict_exit_one_on_vector_verdict_label_fallback() {
        // Pre-Lane-2-M1 payloads carry `verdict` label only; CLEANLIB-31b
        // explicitly cites VECTOR_VERDICT + DM_THRESHOLD_BLOCK → DENY → exit 1.
        assert_eq!(verdict_exit_code(&v_with_label("VECTOR_VERDICT")), 1);
        assert_eq!(verdict_exit_code(&v_with_label("DM_THRESHOLD_BLOCK")), 1);
    }

    #[test]
    fn verdict_exit_zero_on_allowed_no_findings_label() {
        assert_eq!(verdict_exit_code(&v_with_label("ALLOWED_NO_FINDINGS")), 0);
    }

    #[test]
    fn verdict_exit_two_on_insufficient_data_label() {
        // INSUFFICIENT_DATA → WARN → exit 2 (fail-loud; don't silently pass CI).
        assert_eq!(verdict_exit_code(&v_with_label("INSUFFICIENT_DATA")), 2);
    }

    #[test]
    fn verdict_exit_two_on_unknown_label_fail_loud() {
        // Unknown label → WARN (fail-loud) rather than implicit-ALLOW.
        // Sister of [[feedback_substrate_state_fresh_read_before_banking]].
        assert_eq!(verdict_exit_code(&v_with_label("UNKNOWN_LABEL_XYZ")), 2);
    }

    #[test]
    fn verdict_exit_two_on_unknown_explicit_decision_fail_loud() {
        // CLEANLIB-155 symmetry: sharing normalize_decision means an unknown
        // *explicit* decision also fails loud (exit 2). Pre-shared-helper this
        // hit the outer `_ => 0` and silently passed.
        assert_eq!(verdict_exit_code(&v_with_decision("SOME_NEW_LABEL")), 2);
    }
}