cleanlib-client 0.1.3

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! Verdict + ancillary response types per Client spec rev1 §2.4 +
//! App Rev 4 §4.1 Vector verdict shape.
//!
//! All fields default-tolerant via `#[serde(default)]` so the SDK can
//! consume partial responses during cycle-3 → cycle-N spec evolution
//! without forcing a recompile-and-redeploy on every App-side schema
//! widening.

use serde::{Deserialize, Serialize};

/// `Verdict` mirrors App Rev 4 §4.1 `Verdict` struct surfaced via
/// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
///
/// Cycle-9 R1 fix-forward Lane-2 M1: adds `severity` + `decision` to align
/// with the App-canonical envelope shape (sister of `cleanlib-core::Verdict`
/// + js/py/go SDK envelope carrying). All new fields are `Option<String>`
/// to preserve serde-default tolerance — pre-R1 verdict payloads (without
/// these fields) deserialize cleanly with `None`. Sister-shape with the
/// `VerdictEnvelopeV1` schema-locked at `cleanlib-contract-fixtures@v1.0.0`.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Verdict {
    pub verdict_id: String,
    /// `ALLOWED_NO_FINDINGS` | `VECTOR_VERDICT` | `DM_THRESHOLD_BLOCK` |
    /// `INSUFFICIENT_DATA` per locked Verdict-label enum.
    pub verdict: String,
    pub source: String,
    pub confidence: f64,
    pub composite_score: u8,
    pub reasoning: String,
    pub similar_to: Vec<String>,
    pub evidence_gaps: Vec<String>,
    pub suggested_actions: Vec<String>,
    pub data_freshness_at: Option<String>,
    pub data_oldest_signal_at: Option<String>,
    pub stale_since_at: Option<String>,
    pub staleness_reason: Option<String>,
    pub computed_at: Option<String>,
    /// App-canonical severity tier (`NONE` | `LOW` | `MEDIUM` | `HIGH` |
    /// `CRITICAL` per `cleanlib-core::Severity`). Cycle-9 Lane-2 M1 close.
    /// `Option<String>` for serde-default tolerance against pre-M1 payloads.
    pub severity: Option<String>,
    /// Coarse gating decision (`ALLOW` | `WARN` | `DENY` |
    /// `RISK_ACCEPTANCE_REQUIRED`). Sister of js/py/go SDK carrying.
    /// Cycle-9 Lane-2 M1 close. Optional for serde-default tolerance.
    pub decision: Option<String>,
    /// Prior-verdict comparison shape; envelope emits `null` when no prior
    /// verdict exists. v0.1.3 parity-ripple with `sdk-go::PreviousVerdict`
    /// (cycle-13 M1' ship). NOTE: no `skip_serializing_if` — Verdict is
    /// bincode-serialized by `cleanlib-cli` PersistentCache, which is
    /// positional and breaks if fields are conditionally omitted. JSON
    /// consumers see `previous_verdict: null` which matches the App's
    /// canonical envelope shape.
    pub previous_verdict: Option<PreviousVerdict>,
}

/// Prior-verdict comparison. Surfaces when the CleanLibrary App has a
/// stored prior verdict for the same `(ecosystem, package, version)` that
/// differs from the current one — useful for AI agents and dashboards
/// that want to flag verdict-state changes since the last fetch.
/// Sister-shape with `cleanlib_sdk_go::PreviousVerdict` and
/// `cleanlib-core::PreviousVerdict` in the App.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PreviousVerdict {
    pub verdict_id: String,
    pub verdict: String,
    pub computed_at: String,
    pub diff: String,
}

impl Default for Verdict {
    fn default() -> Self {
        Self {
            verdict_id: String::new(),
            verdict: String::new(),
            source: String::new(),
            confidence: 0.0,
            composite_score: 0,
            reasoning: String::new(),
            similar_to: Vec::new(),
            evidence_gaps: Vec::new(),
            suggested_actions: Vec::new(),
            data_freshness_at: None,
            data_oldest_signal_at: None,
            stale_since_at: None,
            staleness_reason: None,
            computed_at: None,
            severity: None,
            decision: None,
            previous_verdict: None,
        }
    }
}

/// One package identity for policy-preview / scan requests.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PackageRef {
    pub ecosystem: String,
    pub name: String,
    pub version: String,
}

/// Body of `POST /v1/customer/policy/preview` — packages + optional
/// hypothetical policy override (JSON-shaped; YAML-source customers
/// convert client-side).
#[derive(Debug, Clone, Serialize)]
pub struct PolicyPreviewRequest {
    pub packages: Vec<PackageRef>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub policy: Option<serde_json::Value>,
}

/// Per-package decision returned from `/v1/customer/policy/preview` or
/// embedded in audit entries.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyDecision {
    pub ecosystem: String,
    pub package: String,
    pub version: String,
    /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
    pub decision: String,
    pub reason: String,
    pub verdict_id: Option<String>,
    pub policy_rule_id: Option<String>,
}

/// Response from `POST /v1/customer/policy/preview`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct PolicyPreviewResponse {
    pub decisions: Vec<PolicyDecision>,
}

/// One audit log entry returned from `GET /v1/customer/audit`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditEntry {
    pub request_id: String,
    pub at: String,
    pub ecosystem: String,
    pub package: String,
    pub version: String,
    pub decision: String,
    pub reason: String,
    pub verdict_id: Option<String>,
}

/// Response from `GET /v1/customer/audit`.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct AuditResponse {
    pub entries: Vec<AuditEntry>,
    pub next_cursor: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimal_verdict() {
        let json = r#"{
            "verdict_id": "01JBYK000",
            "verdict": "ALLOWED_NO_FINDINGS",
            "source": "ALLOWED_NO_FINDINGS"
        }"#;
        let v: Verdict = serde_json::from_str(json).unwrap();
        assert_eq!(v.verdict_id, "01JBYK000");
        assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
        assert_eq!(v.confidence, 0.0);
        assert!(v.similar_to.is_empty());
    }

    #[test]
    fn parses_full_verdict() {
        let json = r#"{
            "verdict_id": "01JBYK001",
            "verdict": "VECTOR_VERDICT",
            "source": "VECTOR_VERDICT",
            "confidence": 0.98,
            "composite_score": 92,
            "reasoning": "Confirmed malware",
            "similar_to": ["01JBYK999"],
            "evidence_gaps": [],
            "suggested_actions": ["DENY across customers"],
            "data_freshness_at": "2026-05-21T10:00:00Z",
            "computed_at": "2026-05-21T10:01:00Z"
        }"#;
        let v: Verdict = serde_json::from_str(json).unwrap();
        assert_eq!(v.composite_score, 92);
        assert_eq!(v.confidence, 0.98);
        assert_eq!(v.similar_to.len(), 1);
        assert_eq!(v.suggested_actions[0], "DENY across customers");
    }

    #[test]
    fn parses_policy_preview_response() {
        let json = r#"{
            "decisions": [
                {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
                {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
            ]
        }"#;
        let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.decisions.len(), 2);
        assert_eq!(resp.decisions[0].decision, "ALLOW");
        assert_eq!(resp.decisions[1].decision, "DENY");
        assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
    }

    #[test]
    fn parses_audit_response_with_cursor() {
        let json = r#"{
            "entries": [
                {"request_id":"req-1","at":"2026-05-22T10:00:00Z","ecosystem":"npm","package":"lodash","version":"4.17.21","decision":"ALLOW","reason":"ok"}
            ],
            "next_cursor": "abc123"
        }"#;
        let resp: AuditResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.entries.len(), 1);
        assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
    }

    #[test]
    fn empty_audit_response_is_valid() {
        let json = r#"{"entries": []}"#;
        let resp: AuditResponse = serde_json::from_str(json).unwrap();
        assert!(resp.entries.is_empty());
        assert!(resp.next_cursor.is_none());
    }

    #[test]
    fn policy_preview_request_omits_none_policy() {
        let req = PolicyPreviewRequest {
            packages: vec![PackageRef {
                ecosystem: "npm".to_string(),
                name: "lodash".to_string(),
                version: "4.17.21".to_string(),
            }],
            policy: None,
        };
        let json = serde_json::to_string(&req).unwrap();
        // None policy should not appear in serialized output
        assert!(!json.contains("policy"));
        assert!(json.contains("lodash"));
    }

    #[test]
    fn policy_preview_request_emits_policy_when_some() {
        let req = PolicyPreviewRequest {
            packages: vec![],
            policy: Some(serde_json::json!({"rules": []})),
        };
        let json = serde_json::to_string(&req).unwrap();
        assert!(json.contains("\"policy\""));
        assert!(json.contains("\"rules\""));
    }

    #[test]
    fn round_trips_via_json() {
        let v = Verdict {
            verdict_id: "01JBYK002".to_string(),
            verdict: "INSUFFICIENT_DATA".to_string(),
            source: "INSUFFICIENT_DATA".to_string(),
            stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
            staleness_reason: Some("upstream silent >30d".to_string()),
            ..Default::default()
        };
        let s = serde_json::to_string(&v).unwrap();
        let parsed: Verdict = serde_json::from_str(&s).unwrap();
        assert_eq!(parsed.verdict_id, "01JBYK002");
        assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
    }

    // ─── Lane-2 M1 — severity + decision carrying ──────────────────────

    #[test]
    fn verdict_round_trips_severity_and_decision() {
        let v = Verdict {
            verdict_id: "01JM1S001".to_string(),
            verdict: "VECTOR_VERDICT".to_string(),
            source: "VECTOR_VERDICT".to_string(),
            severity: Some("HIGH".to_string()),
            decision: Some("DENY".to_string()),
            ..Default::default()
        };
        let s = serde_json::to_string(&v).unwrap();
        let parsed: Verdict = serde_json::from_str(&s).unwrap();
        assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
        assert_eq!(parsed.decision.as_deref(), Some("DENY"));
    }

    #[test]
    fn verdict_tolerates_missing_severity_and_decision() {
        // Pre-M1 payload shape — no severity/decision fields. Must still parse
        // via serde-default tolerance per the struct's `#[serde(default)]`.
        let pre_m1_json = r#"{
            "verdict_id": "01JM1S002",
            "verdict": "ALLOWED_NO_FINDINGS",
            "source": "ALLOWED_NO_FINDINGS",
            "confidence": 0.95,
            "composite_score": 8,
            "reasoning": "",
            "similar_to": [],
            "evidence_gaps": [],
            "suggested_actions": []
        }"#;
        let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
        assert!(v.severity.is_none());
        assert!(v.decision.is_none());
    }

    #[test]
    fn verdict_decision_canonical_values_match_js_py_go() {
        // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
        // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
        for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
            let v = Verdict {
                decision: Some(d.to_string()),
                ..Default::default()
            };
            let s = serde_json::to_string(&v).unwrap();
            assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
        }
    }

    #[test]
    fn verdict_severity_canonical_values_match_cleanlib_core() {
        // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
        for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
            let v = Verdict {
                severity: Some(sev.to_string()),
                ..Default::default()
            };
            let s = serde_json::to_string(&v).unwrap();
            assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
        }
    }
}