cellos-ctl 0.5.2

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! `cellctl version` — print client version, then attempt the server version.
//!
//! The client version is printed unconditionally on its own line so an operator
//! always sees what they have locally even when the server is unreachable.
//! After that, we attempt the server query and:
//!
//! - on success, print `server: cellos-server <ver> (<gitSha> <buildProfile>)`
//!   and exit 0 — fields the server omits (e.g. `gitSha` when the build did
//!   not capture one) are simply elided from the parenthetical;
//! - on transport OR HTTP error, return a [`CtlError::api`] so the process
//!   exits non-zero. The error formatter prints
//!   `cellctl: api: unable to connect to the server: <reason>` to stderr,
//!   matching the SMOKE-TEST wave-2 contract.
//!
//! This matches `kubectl version`, which also exits non-zero when it cannot
//! reach the API server. See SMOKE-TEST report Finding #2 (wave 2) and
//! E2E report SRV-001 (wave 3, server route added).
//!
//! ## Wire shape (server side: `GET /v1/version`)
//!
//! ```json
//! { "server": { "version": "0.5.0", "gitSha": "abc1234", "buildProfile": "release" },
//!   "api":    { "version": "v1" } }
//! ```
//!
//! `gitSha` is optional. We tolerate either the nested shape (added by
//! SRV-001) or a flat legacy shape (`{ "version": "..." }`) so a cellctl
//! pointed at an older server still prints something useful.

use serde::Deserialize;
use serde_json::Value;

use crate::client::CellosClient;
use crate::exit::{CtlError, CtlResult};

/// Nested wire shape returned by SRV-001+ servers. All inner fields are
/// optional so a partial response (or future field additions) does not
/// fail the deserializer.
#[derive(Debug, Default, Deserialize)]
struct ServerVersion {
    #[serde(default)]
    version: Option<String>,
    #[serde(rename = "gitSha", default)]
    git_sha: Option<String>,
    #[serde(rename = "buildProfile", default)]
    build_profile: Option<String>,
}

pub async fn run(client: &CellosClient) -> CtlResult<()> {
    println!("client: cellctl {}", env!("CARGO_PKG_VERSION"));

    match client.get_json::<Value>("/v1/version").await {
        Ok(v) => {
            println!("server: {}", format_server_line(&v));
            Ok(())
        }
        Err(e) => {
            // Propagate as an API error so the process exits with code 2 —
            // scripts and CI gates that branch on $? get an honest signal
            // instead of a false-success. `CtlError::exit` formats this as
            // `cellctl: api: unable to connect to the server: <reason>` on
            // stderr, matching the existing error-output contract.
            Err(CtlError::api(format!(
                "unable to connect to the server: {e}"
            )))
        }
    }
}

/// Render the `server:` line content given the raw JSON body. Public to
/// the module so a focused unit test can pin the exact format without
/// spinning up a live HTTP exchange.
fn format_server_line(v: &Value) -> String {
    // Prefer the nested `server` object (SRV-001 shape). If that's not
    // present we fall back to the flat `{ "version": "..." }` legacy
    // shape so cellctl 0.5.x can still talk to a 0.5.0 server.
    let server = v
        .get("server")
        .and_then(|s| serde_json::from_value::<ServerVersion>(s.clone()).ok())
        .unwrap_or_else(|| ServerVersion {
            version: v.get("version").and_then(|x| x.as_str()).map(str::to_owned),
            ..Default::default()
        });

    let version = server.version.as_deref().unwrap_or("unknown");

    // Build the parenthetical from whichever optional fields are
    // populated. An older server returns neither, so we drop the
    // parens entirely rather than printing `cellos-server 0.5.0 ()`.
    let mut parts: Vec<&str> = Vec::with_capacity(2);
    if let Some(s) = server.git_sha.as_deref() {
        parts.push(s);
    }
    if let Some(p) = server.build_profile.as_deref() {
        parts.push(p);
    }
    if parts.is_empty() {
        format!("cellos-server {version}")
    } else {
        format!("cellos-server {version} ({})", parts.join(" "))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn renders_full_nested_shape() {
        let v = json!({
            "server": {
                "version": "0.5.0",
                "gitSha": "abc1234",
                "buildProfile": "release",
            },
            "api": { "version": "v1" },
        });
        assert_eq!(
            format_server_line(&v),
            "cellos-server 0.5.0 (abc1234 release)"
        );
    }

    #[test]
    fn renders_nested_shape_without_git_sha() {
        // gitSha omitted by the server (no CELLOS_GIT_SHA at build) —
        // the buildProfile is the only parenthetical content.
        let v = json!({
            "server": { "version": "0.5.0", "buildProfile": "debug" },
            "api": { "version": "v1" },
        });
        assert_eq!(format_server_line(&v), "cellos-server 0.5.0 (debug)");
    }

    #[test]
    fn renders_flat_legacy_shape() {
        // Older server (pre-SRV-001) returning a bare `{ version }`.
        let v = json!({ "version": "0.4.0" });
        assert_eq!(format_server_line(&v), "cellos-server 0.4.0");
    }

    #[test]
    fn renders_unknown_when_version_missing() {
        let v = json!({ "server": { "buildProfile": "release" } });
        assert_eq!(format_server_line(&v), "cellos-server unknown (release)");
    }
}