cellos-ctl 0.5.0

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 apply -f formation.yaml` → POST /v1/formations.
//!
//! YAML is parsed locally only to validate shape and convert to JSON. The
//! authoritative validation happens on the server; we do a minimal local check
//! to surface obvious schema errors with exit-code 3 before any network call.

use std::path::Path;

use serde_json::Value;

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

pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
    let yaml = std::fs::read_to_string(path)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;

    let body: Value = serde_yaml::from_str(&yaml)?;
    validate_formation_spec(&body, path)?;

    let created: Formation = client.post_json("/v1/formations", &body).await?;
    let name = if !created.name.is_empty() {
        &created.name
    } else {
        &created.id
    };
    println!("formation/{name} created (state={})", created.state);
    Ok(())
}

/// Local shape check — the server is authoritative, but obvious mistakes
/// should fail fast with exit code 3 (validation) rather than 2 (api).
fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
    let Some(obj) = v.as_object() else {
        return Err(CtlError::validation(format!(
            "{}: top-level must be a YAML mapping",
            path.display()
        )));
    };

    // Accept either `kind: Formation` (kubectl-style) OR a bare formation spec
    // (which is what Session 15/16 sketched). Don't be pedantic — server decides.
    if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
        if kind != "Formation" {
            return Err(CtlError::validation(format!(
                "{}: kind must be \"Formation\" (got \"{kind}\")",
                path.display()
            )));
        }
    }

    // Require either `name` or `metadata.name`.
    let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
        || obj
            .get("metadata")
            .and_then(|m| m.get("name"))
            .and_then(|n| n.as_str())
            .is_some();
    if !has_name {
        return Err(CtlError::validation(format!(
            "{}: missing required field `name` (or `metadata.name`)",
            path.display()
        )));
    }
    Ok(())
}