agentcarousel 0.2.3

Evaluate agents and skills with YAML fixtures, run cases (mock or live), and keep run rows in SQLite for reports and evidence export.
Documentation
use reqwest::blocking::{multipart, Client};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde_json::Value;
use std::path::Path;
use std::time::Duration;

pub struct RegistryClient {
    base_url: String,
    token: String,
    http: Client,
}

impl RegistryClient {
    pub fn new(base_url: &str, token: &str) -> Result<Self, String> {
        let http = Client::builder()
            .timeout(Duration::from_secs(60))
            .build()
            .map_err(|err| format!("failed to construct HTTP client: {err}"))?;
        Ok(Self {
            base_url: base_url.trim_end_matches('/').to_string(),
            token: token.to_string(),
            http,
        })
    }

    pub fn push_bundle_manifest(&self, manifest: &Value) -> Result<Value, String> {
        let url = format!("{}/v1/bundles", self.base_url);
        let res = self
            .http
            .post(url)
            .header(AUTHORIZATION, format!("Bearer {}", self.token))
            .header(CONTENT_TYPE, "application/json")
            .json(manifest)
            .send()
            .map_err(|err| format!("request failed: {err}"))?;
        parse_json_response("bundle push", res)
    }

    pub fn submit_run_evidence(
        &self,
        registry_bundle_id: &str,
        evidence_path: &Path,
    ) -> Result<Value, String> {
        let url = format!("{}/v1/runs", self.base_url);
        let form = multipart::Form::new()
            .text("registry_bundle_id", registry_bundle_id.to_string())
            .file("evidence", evidence_path)
            .map_err(|err| format!("failed to attach evidence file: {err}"))?;
        let res = self
            .http
            .post(url)
            .header(AUTHORIZATION, format!("Bearer {}", self.token))
            .multipart(form)
            .send()
            .map_err(|err| format!("request failed: {err}"))?;
        parse_json_response("submit run", res)
    }

    pub fn get_trust_state(&self, bundle_id: &str) -> Result<Value, String> {
        let encoded_bundle_id = bundle_id.replace('/', "%2F");
        let url = format!(
            "{}/v1/bundles/{}/trust-state",
            self.base_url, encoded_bundle_id
        );
        let req = self.http.get(url).header(CONTENT_TYPE, "application/json");
        let req = if self.token.trim().is_empty() {
            req
        } else {
            req.header(AUTHORIZATION, format!("Bearer {}", self.token))
        };
        let res = req.send().map_err(|err| format!("request failed: {err}"))?;
        parse_json_response("trust check", res)
    }
}

fn parse_json_response(step: &str, res: reqwest::blocking::Response) -> Result<Value, String> {
    let status = res.status();
    let body = res.text().unwrap_or_default();
    if status.is_success() {
        return serde_json::from_str::<Value>(&body)
            .map_err(|err| format!("{step} succeeded but response was not JSON: {err}"));
    }
    if status.as_u16() == 409 {
        let parsed = serde_json::from_str::<Value>(&body).unwrap_or(Value::Null);
        let payload = if parsed.is_null() {
            serde_json::json!({
                "duplicate": true,
                "status": "already_uploaded",
                "message": "run was already submitted; no new trust-state mutation"
            })
        } else {
            serde_json::json!({
                "duplicate": true,
                "status": "already_uploaded",
                "registry_response": parsed
            })
        };
        return Ok(payload);
    }

    let guidance = match status.as_u16() {
        401 => "Unauthorized: check AGENTCAROUSEL_API_TOKEN and Authorization Bearer value.",
        404 => "Not found: verify registry bundle id and endpoint URL.",
        415 => "Unsupported media type: registry expects multipart/form-data with evidence file.",
        _ => "Registry request failed.",
    };
    if body.trim().is_empty() {
        Err(format!("{step} failed ({status}): {guidance}"))
    } else {
        Err(format!("{step} failed ({status}): {guidance} body={body}"))
    }
}