cellos-ctl 0.5.1

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! HTTP client wrapping reqwest for cellos-server.
//!
//! Per Feynman (CHATROOM Session 16): "cellctl is a thin client over cellos-server.
//! Every command = exactly one API call." This module is that contract.
//!
//! All state queries hit the projector via HTTP. There is no client-side cache.

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::config::Config;
use crate::exit::{CtlError, CtlResult};

/// User-Agent string sent to the server — helps the projector identify CLI traffic.
const USER_AGENT: &str = concat!("cellctl/", env!("CARGO_PKG_VERSION"));

#[derive(Clone)]
pub struct CellosClient {
    base: String,
    http: reqwest::Client,
}

impl CellosClient {
    pub fn new(cfg: &Config) -> CtlResult<Self> {
        let base = cfg.effective_server();
        // Normalize: strip trailing slash so we can confidently concatenate paths.
        let base = base.trim_end_matches('/').to_string();

        let mut headers = HeaderMap::new();
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
        if let Some(tok) = cfg.effective_token() {
            let v = HeaderValue::from_str(&format!("Bearer {tok}"))
                .map_err(|e| CtlError::usage(format!("bad token: {e}")))?;
            headers.insert(AUTHORIZATION, v);
        }

        let http = reqwest::Client::builder()
            .user_agent(USER_AGENT)
            .default_headers(headers)
            .build()
            .map_err(|e| CtlError::api(format!("init http client: {e}")))?;

        Ok(Self { base, http })
    }

    pub fn base_url(&self) -> &str {
        &self.base
    }

    fn url(&self, path: &str) -> String {
        if path.starts_with('/') {
            format!("{}{}", self.base, path)
        } else {
            format!("{}/{}", self.base, path)
        }
    }

    /// GET <path> → T
    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> CtlResult<T> {
        let resp = self.http.get(self.url(path)).send().await?;
        decode_json(resp).await
    }

    /// POST <path> with JSON body → T
    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> CtlResult<T> {
        let resp = self.http.post(self.url(path)).json(body).send().await?;
        decode_json(resp).await
    }

    /// DELETE <path> — body ignored, only status checked.
    pub async fn delete(&self, path: &str) -> CtlResult<()> {
        let resp = self.http.delete(self.url(path)).send().await?;
        check_status(resp).await.map(|_| ())
    }

    /// Stream Server-Sent-Events-ish JSON lines (newline-delimited) from a GET endpoint.
    /// Used by `logs --follow` over HTTP chunked transfer.
    pub async fn get_stream(&self, path: &str) -> CtlResult<reqwest::Response> {
        let resp = self.http.get(self.url(path)).send().await?;
        check_status(resp).await
    }

    /// Build a WebSocket URL aligned to the same base. http→ws, https→wss.
    pub fn ws_url(&self, path: &str) -> CtlResult<String> {
        let mut u = url::Url::parse(&self.url(path))
            .map_err(|e| CtlError::usage(format!("bad url: {e}")))?;
        match u.scheme() {
            "http" => u
                .set_scheme("ws")
                .map_err(|_| CtlError::usage("set ws scheme"))?,
            "https" => u
                .set_scheme("wss")
                .map_err(|_| CtlError::usage("set wss scheme"))?,
            "ws" | "wss" => {}
            other => return Err(CtlError::usage(format!("unsupported scheme: {other}"))),
        }
        Ok(u.to_string())
    }

    /// Auth header value for bolting onto a WebSocket request, if a token is configured.
    ///
    /// Currently unused — the WS path uses raw `connect_async(url)` and relies
    /// on cookie / query-string auth on the server side. Kept here for the
    /// Bearer-over-`Sec-WebSocket-Protocol` upgrade path described in
    /// CHATROOM Session 16.
    #[allow(dead_code)]
    pub fn auth_header(&self) -> Option<String> {
        self.http
            .request(Method::GET, &self.base)
            .build()
            .ok()
            .and_then(|req| req.headers().get(AUTHORIZATION).cloned())
            .and_then(|v| v.to_str().ok().map(|s| s.to_string()))
    }
}

async fn check_status(resp: reqwest::Response) -> CtlResult<reqwest::Response> {
    let status = resp.status();
    if status.is_success() {
        return Ok(resp);
    }
    // Best-effort: include server-provided body in the error message.
    let body = resp.text().await.unwrap_or_default();
    let body_trim = body.trim();
    let detail = if body_trim.is_empty() {
        format!("server returned {status}")
    } else {
        format!("server returned {status}: {body_trim}")
    };
    let code = status.as_u16();
    Err(match status {
        StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
            CtlError::validation(detail).with_status(code)
        }
        _ => CtlError::api(detail).with_status(code),
    })
}

async fn decode_json<T: DeserializeOwned>(resp: reqwest::Response) -> CtlResult<T> {
    let resp = check_status(resp).await?;
    let bytes = resp.bytes().await?;
    serde_json::from_slice(&bytes).map_err(|e| CtlError::api(format!("decode json: {e}")))
}