cleanlib-cli 0.1.1

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;

/// 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).
pub fn scan_exit_code(decisions: &[types::PolicyDecision]) -> i32 {
    let mut has_warn = false;
    let mut has_risk = false;
    for d in decisions {
        match d.decision.as_str() {
            "DENY" => return 1,
            "WARN" => has_warn = true,
            "RISK_ACCEPTANCE_REQUIRED" => has_risk = true,
            _ => {}
        }
    }
    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; reuses identical
/// ALLOW/WARN/DENY/RISK_ACCEPTANCE_REQUIRED mapping so verdict + scan +
/// wrap + policy-preview all carry the same exit-code contract.
///
/// Prefers `Verdict.decision` (Lane-2 M1); falls back to mapping the
/// `verdict` label for pre-M1 payloads (mirrors
/// `wrap::derive_decision_from_verdict` semantics — same mapping the
/// App-side policy_evaluator applies). `INSUFFICIENT_DATA` is fail-loud:
/// no decision → exit 2 (WARN) so customer CI doesn't silently pass on
/// missing data; sister-shape of fail-loud-on-empty-bearer (CLEANLIB-129).
pub fn verdict_exit_code(v: &types::Verdict) -> i32 {
    let decision_str = v.decision.clone().unwrap_or_else(|| {
        match v.verdict.as_str() {
            "VECTOR_VERDICT" | "DM_THRESHOLD_BLOCK" => "DENY".to_string(),
            "INSUFFICIENT_DATA" => "WARN".to_string(),
            "ALLOWED_NO_FINDINGS" => "ALLOW".to_string(),
            // Unknown verdict label → fail-loud as WARN; mirrors derive
            // behavior at `wrap.rs` but with safer CI semantics (was
            // implicit "ALLOW" → silently passes; now "WARN" → exit 2).
            _ => "WARN".to_string(),
        }
    });
    match decision_str.as_str() {
        "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-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);
    }
}