velixar 0.1.0

Governed, auditable memory for AI agents. Official Rust client for the Velixar API.
Documentation
use serde::Deserialize;

/// The API's error envelope: `{"error": {"code": "...", "message": "..."}}`.
///
/// Captured from live production, not assumed. Every non-2xx response carries it.
#[derive(Debug, Clone, Deserialize)]
pub struct ApiError {
    pub code: String,
    pub message: String,
}

#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEnvelope {
    pub error: ApiError,
}

/// Everything that can go wrong.
///
/// The variants are the ones a caller actually branches on. Auth, scope and
/// validation are separated because the fix differs: rotate a key, widen a scope,
/// or correct the request. Collapsing them into one `Api` variant would force
/// every caller to string-match on `code`, which is exactly the kind of
/// stringly-typed contract that rots.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// 401 — the key is missing, malformed, revoked or expired. (AUTH_002 / AUTH_003)
    #[error("authentication failed: {}", .0.message)]
    Auth(ApiError),

    /// 403 — the key is valid but lacks the scope for this call. (SCOPE_003)
    ///
    /// e.g. calling [`delete`](crate::Velixar::delete) with a read+write key.
    /// Mint a key with `memory:delete`.
    #[error("insufficient scope: {}", .0.message)]
    Scope(ApiError),

    /// 400 — the request is malformed. (VALIDATION_001: content required,
    /// VALIDATION_003: query required)
    #[error("invalid request: {}", .0.message)]
    Validation(ApiError),

    /// 404 — no such memory. (NOT_FOUND_003)
    #[error("not found: {}", .0.message)]
    NotFound(ApiError),

    /// 429 — rate limited.
    #[error("rate limited: {}", .0.message)]
    RateLimited(ApiError),

    /// Any other API error, carrying the server's own code so nothing is lost.
    #[error("api error {status}: {}", .body.message)]
    Api { status: u16, body: ApiError },

    /// The transport failed, or the server returned something that was not the
    /// documented envelope (a proxy error page, a 502, a truncated body).
    #[error("transport error: {0}")]
    Transport(#[from] reqwest::Error),

    /// A 2xx body that did not match the documented shape. Worth its own variant:
    /// it means the API changed under us, which is a different problem from a
    /// network blip and should not be silently retried.
    #[error("unexpected response shape: {0}")]
    Decode(String),
}

impl Error {
    /// The server's error code, when there is one (`"SCOPE_003"`, `"AUTH_003"`, …).
    pub fn code(&self) -> Option<&str> {
        match self {
            Error::Auth(e)
            | Error::Scope(e)
            | Error::Validation(e)
            | Error::NotFound(e)
            | Error::RateLimited(e)
            | Error::Api { body: e, .. } => Some(&e.code),
            _ => None,
        }
    }

    pub(crate) fn from_status(status: u16, body: ApiError) -> Self {
        match status {
            401 => Error::Auth(body),
            403 => Error::Scope(body),
            400 => Error::Validation(body),
            404 => Error::NotFound(body),
            429 => Error::RateLimited(body),
            _ => Error::Api { status, body },
        }
    }
}

pub type Result<T> = std::result::Result<T, Error>;