cleanlib-client 0.1.8

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
//! Cross-SDK contract test — verifies `derive_status` byte-identical output
//! against the canonical `cleanlib-contract-fixtures` v1.0.0 corpus.
//!
//! Sister of:
//! - sdk-js  `tests/contract.test.ts` (vitest)
//! - sdk-py  `tests/test_contract.py` (pytest)
//! - sdk-go  `contract_test.go`       (go test)
//!
//! Each SDK consumer (sdk-js, sdk-py, sdk-go, cleanlib-client Rust) loads
//! the same `EXPECTED.json` and asserts byte-identical `(status, reason_code)`
//! for every envelope fixture. Drift = CI failure across all 4 implementations.
//!
//! `verdict-unreachable.json` is intentionally SKIPPED here — it's an
//! out-of-band transport-error synthetic (CLI exit-3 + extension
//! LIVE_DEGRADED status bar; no envelope status assigned).

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use serde_json::Value;

use cleanlib_client::{derive_status, verdict_to_envelope_v1, Verdict, VerdictEnvelopeV1};

/// `EXPECTED.json` top-level shape.
#[derive(Debug, serde::Deserialize)]
struct ExpectedCorpus {
    fixtures: HashMap<String, ExpectedRow>,
}

#[derive(Debug, serde::Deserialize)]
struct ExpectedRow {
    status: String,
    reason_code: String,
}

fn fixtures_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("contract-fixtures")
}

#[test]
fn all_envelope_fixtures_match_expected_json() {
    let base = fixtures_dir();
    let expected_path = base.join("EXPECTED.json");
    let raw = fs::read_to_string(&expected_path).unwrap_or_else(|e| {
        panic!("could not read {}: {e}", expected_path.display());
    });
    let corpus: ExpectedCorpus =
        serde_json::from_str(&raw).expect("EXPECTED.json must parse as ExpectedCorpus");

    let fixtures_path = base.join("fixtures");
    let mut checked = 0usize;
    let mut failures: Vec<String> = Vec::new();

    for (fname, expected) in &corpus.fixtures {
        // verdict-unreachable.json lives under _out_of_band in EXPECTED.json,
        // so the top-level `fixtures` map only ever yields envelope fixtures.
        // Belt-and-braces — explicit skip for the transport-error synthetic.
        if fname == "verdict-unreachable.json" {
            continue;
        }
        let fixture_path = fixtures_path.join(fname);
        let body = fs::read_to_string(&fixture_path).unwrap_or_else(|e| {
            panic!("could not read fixture {}: {e}", fixture_path.display());
        });
        let env: VerdictEnvelopeV1 = serde_json::from_str(&body).unwrap_or_else(|e| {
            panic!("fixture {} did not parse as VerdictEnvelopeV1: {e}", fname);
        });
        let derived = derive_status(&env);

        // Compare on canonical wire-format strings — matches what sdk-js,
        // sdk-py, sdk-go assert against the same EXPECTED.json.
        let got_status = derived.status.as_str();
        let got_reason = derived.reason_code.as_str();
        if got_status != expected.status || got_reason != expected.reason_code {
            failures.push(format!(
                "  {}: expected ({}, {}), got ({}, {})",
                fname, expected.status, expected.reason_code, got_status, got_reason
            ));
        }
        checked += 1;
    }

    assert!(
        failures.is_empty(),
        "{} fixture(s) drift from EXPECTED.json:\n{}",
        failures.len(),
        failures.join("\n")
    );

    // Empirical: 7 envelope fixtures in v1.0.0 (8 files total minus the
    // out-of-band transport synthetic). Locks the corpus size against
    // accidental drop / silent corpus shrink.
    assert_eq!(
        checked, 7,
        "contract corpus must contain exactly 7 envelope fixtures (got {checked})"
    );
}

#[test]
fn cleanlib_176_source_projection_fixtures_match_expected() {
    // CLEANLIB-176 source-projection corpus — DISTINCT from the derive_status
    // corpus above. These fixtures are App-side `Verdict` wire shapes (under
    // source-fixtures/), each carrying a `verdict.source` projection variant.
    // The contract is over `verdict_to_envelope_v1` (the App-verdict→envelope
    // adapter), which preserves the label+severity status tier and refines the
    // reason_code by source. derive_status never reads source, so it is NOT
    // exercised here; keeping the two corpora separate avoids conflating the
    // App-side projection with the enrich-cascade decision.
    let base = fixtures_dir();
    let expected_path = base.join("SOURCE_EXPECTED.json");
    let raw = fs::read_to_string(&expected_path)
        .unwrap_or_else(|e| panic!("could not read {}: {e}", expected_path.display()));
    let corpus: ExpectedCorpus =
        serde_json::from_str(&raw).expect("SOURCE_EXPECTED.json must parse as ExpectedCorpus");

    let fixtures_path = base.join("source-fixtures");
    let mut checked = 0usize;
    let mut failures: Vec<String> = Vec::new();

    for (fname, expected) in &corpus.fixtures {
        let fixture_path = fixtures_path.join(fname);
        let body = fs::read_to_string(&fixture_path)
            .unwrap_or_else(|e| panic!("could not read source fixture {}: {e}", fixture_path.display()));
        let verdict: Verdict = serde_json::from_str(&body)
            .unwrap_or_else(|e| panic!("source fixture {} did not parse as Verdict: {e}", fname));

        let env = verdict_to_envelope_v1(&verdict);
        if env.status != expected.status || env.reason_code != expected.reason_code {
            failures.push(format!(
                "  {}: expected ({}, {}), got ({}, {})",
                fname, expected.status, expected.reason_code, env.status, env.reason_code
            ));
        }

        // Round-trip: the produced envelope must serialize then parse back
        // unchanged (proves no malformed JSON for the new source variants).
        let s = serde_json::to_string(&env).expect("envelope serializes");
        let back: VerdictEnvelopeV1 =
            serde_json::from_str(&s).expect("envelope round-trips back to VerdictEnvelopeV1");
        assert_eq!(back.status, env.status, "round-trip status drift for {fname}");
        assert_eq!(back.reason_code, env.reason_code, "round-trip reason drift for {fname}");

        checked += 1;
    }

    assert!(
        failures.is_empty(),
        "{} source fixture(s) drift from SOURCE_EXPECTED.json:\n{}",
        failures.len(),
        failures.join("\n")
    );

    // Lock the source-projection corpus at the 4 CLEANLIB-176 variants.
    assert_eq!(
        checked, 4,
        "source-projection corpus must contain exactly 4 variant fixtures (got {checked})"
    );
}

#[test]
fn unreachable_fixture_is_out_of_band_not_envelope() {
    // `verdict-unreachable.json` is intentionally NOT a VerdictEnvelopeV1 —
    // it's an SDK transport-failure synthetic. Reading it as the loose
    // `serde_json::Value` succeeds; reading as the typed envelope fails
    // because required fields (`status`, `reason_code`) are absent.
    let body = fs::read_to_string(fixtures_dir().join("fixtures").join("verdict-unreachable.json"))
        .expect("verdict-unreachable.json must be vendored alongside envelope fixtures");
    let v: Value =
        serde_json::from_str(&body).expect("unreachable fixture must still be valid JSON");
    assert!(v.is_object(), "unreachable fixture is a JSON object");

    let typed: Result<VerdictEnvelopeV1, _> = serde_json::from_str(&body);
    assert!(
        typed.is_err(),
        "verdict-unreachable.json must NOT parse as VerdictEnvelopeV1 (it's out-of-band)"
    );
}