sail-rs 0.1.0

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Typed app discovery over the central API.
//!
//! `find` hits the central API host (`/v1/apps/find`); `list` rolls up the
//! sailbox overview on the sailbox-API host. Both map non-2xx responses onto
//! the canonical [`SailError`] taxonomy.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::error::SailError;
use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
use crate::retry::DEFAULT_RETRY_POLICY;

/// A Sail application.
#[derive(Debug, Clone, Serialize)]
pub struct App {
    /// Stable server-assigned app identifier.
    pub id: String,
    /// Human-readable app name unique within the owning org.
    pub name: String,
    /// App creation time as a Unix timestamp in seconds.
    pub created_at: i64,
}

/// Per-app rollup of lifecycle counts and resource usage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppOverview {
    /// Identifier of the app this rollup describes.
    #[serde(default)]
    pub app_id: String,
    /// Name of the app this rollup describes.
    #[serde(default)]
    pub app_name: String,
    /// Total number of sailboxes owned by the app across all states.
    #[serde(default)]
    pub sailbox_count: i64,
    /// Number of sailboxes currently running.
    #[serde(default)]
    pub running_count: i64,
    /// Number of sailboxes currently paused.
    #[serde(default)]
    pub paused_count: i64,
    /// Number of sailboxes currently sleeping.
    #[serde(default)]
    pub sleeping_count: i64,
    /// Number of sailboxes currently in a failed state.
    #[serde(default)]
    pub failed_count: i64,
    /// Sum of configured CPU across the app's sailboxes, in vCPUs.
    #[serde(default)]
    pub cpu_requested_vcpu: i64,
    /// Sum of observed CPU usage across the app's sailboxes, in vCPUs.
    #[serde(default)]
    pub cpu_used_vcpu: f64,
    /// Sum of configured memory across the app's sailboxes, in bytes.
    #[serde(default)]
    pub memory_requested_bytes: i64,
    /// Sum of observed memory usage across the app's sailboxes, in bytes.
    #[serde(default)]
    pub memory_used_bytes: i64,
    /// Sum of configured state-disk capacity across the app's sailboxes, in bytes.
    #[serde(default)]
    pub disk_requested_bytes: i64,
    /// Sum of observed state-disk usage across the app's sailboxes, in bytes.
    #[serde(default)]
    pub disk_used_bytes: i64,
}

/// Find an app by name on the central API, optionally minting it.
pub async fn find_app(
    api_http: &HttpCore,
    name: &str,
    mint_if_missing: bool,
) -> Result<App, SailError> {
    let body = json!({ "name": name, "mint_if_missing": mint_if_missing });
    let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
        message: format!("failed to serialize request body: {e}"),
    })?;
    let (status, data) = api_http
        .request(&RequestSpec {
            method: Method::Post,
            path: "/v1/apps/find".to_string(),
            query: Vec::new(),
            body: Some(bytes),
            extra_headers: Vec::new(),
            timeout: None,
            policy: DEFAULT_RETRY_POLICY,
            idempotency_key: IdempotencyKey::Auto,
        })
        .await?;
    // find returns 404 for any miss (not type-gated, unlike sailbox lookups).
    if status == 404 {
        return Err(SailError::NotFound {
            message: format!("App {name:?} not found"),
        });
    }
    raise_app_error(status, &data, "Failed to find app")?;
    Ok(App {
        id: data
            .get("id")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_string(),
        name: data
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or(name)
            .to_string(),
        created_at: data.get("created_at").and_then(Value::as_i64).unwrap_or(0),
    })
}

/// One rollup row per app the org owns (via the sailbox overview).
pub async fn list_apps(sailbox_http: &HttpCore) -> Result<Vec<AppOverview>, SailError> {
    let (status, data) = sailbox_http
        .request(&RequestSpec {
            method: Method::Get,
            path: "/v1/sailboxes/overview".to_string(),
            query: Vec::new(),
            body: None,
            extra_headers: Vec::new(),
            timeout: None,
            policy: DEFAULT_RETRY_POLICY,
            idempotency_key: IdempotencyKey::Auto,
        })
        .await?;
    raise_app_error(status, &data, "Failed to list apps")?;
    let Some(rows) = data.get("data").and_then(Value::as_array) else {
        return Ok(Vec::new());
    };
    rows.iter()
        .map(|row| {
            serde_json::from_value::<AppOverview>(row.clone()).map_err(|e| SailError::Internal {
                message: format!("failed to parse app overview: {e}"),
            })
        })
        .collect()
}

/// App ladder: 401/403 → PermissionDenied, any other non-2xx → Api
/// (transient/unexpected). 404 is handled by the caller where it is meaningful.
fn raise_app_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
    if status < 300 {
        return Ok(());
    }
    let detail = data
        .get("error")
        .and_then(|e| e.get("message"))
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or("unknown error");
    let message = format!("{context}: {detail}");
    if status == 401 || status == 403 {
        return Err(SailError::PermissionDenied { message });
    }
    Err(SailError::Api {
        status,
        message,
        body: data.clone(),
    })
}