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
//! `cellctl diff -f formation.yaml` — show what would change on apply.
//!
//! Local YAML vs the current server-side formation, rendered as a unified text
//! diff over canonical JSON. Per Feynman (Session 16): "kubectl diff is useful,
//! add it." Same single-API-call rule applies: one GET, no writes.

use std::path::Path;

use owo_colors::OwoColorize;
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 local: Value = serde_yaml::from_str(&yaml)?;

    let local_name = extract_name(&local).ok_or_else(|| {
        CtlError::validation(format!(
            "{}: cannot find formation name (`name` or `metadata.name`)",
            path.display()
        ))
    })?;

    let remote: Formation = match client
        .get_json(&format!("/v1/formations/{}", urlencode(&local_name)))
        .await
    {
        Ok(f) => f,
        // Only a real HTTP 404 from the server means "the formation does not
        // exist server-side, so applying would create it". Any other error
        // (transport failure, 5xx, 4xx other than 404) must propagate so the
        // operator gets a non-zero exit instead of a false "would create"
        // plan against a server that was never contacted. See SMOKE-TEST
        // report Finding #1.
        Err(e) if e.status == Some(404) => {
            println!("(formation/{local_name} not found on server — would create)");
            for line in serde_yaml::to_string(&local).unwrap_or_default().lines() {
                println!("{}", format!("+ {line}").green());
            }
            return Ok(());
        }
        Err(e) => return Err(e),
    };

    let local_pretty = serde_json::to_string_pretty(&local).unwrap_or_default();
    let remote_pretty = serde_json::to_string_pretty(&remote).unwrap_or_default();
    print_unified_diff(&remote_pretty, &local_pretty);
    Ok(())
}

fn extract_name(v: &Value) -> Option<String> {
    if let Some(n) = v.get("name").and_then(|x| x.as_str()) {
        return Some(n.to_string());
    }
    v.get("metadata")
        .and_then(|m| m.get("name"))
        .and_then(|n| n.as_str())
        .map(|s| s.to_string())
}

/// Tiny line-level unified-diff: emit `-` for lines only in `a`, `+` for lines
/// only in `b`, ` ` otherwise. Not Myers diff — good enough for review,
/// avoids pulling in a diff crate. Loud about that limitation in the header.
fn print_unified_diff(a: &str, b: &str) {
    println!("--- server (current)");
    println!("+++ local  (proposed)");
    println!(
        "# (cellctl diff is a shallow line comparison; for fine-grained review use `diff -u`)"
    );
    let a_lines: Vec<&str> = a.lines().collect();
    let b_lines: Vec<&str> = b.lines().collect();
    let n = a_lines.len().max(b_lines.len());
    for i in 0..n {
        let al = a_lines.get(i).copied();
        let bl = b_lines.get(i).copied();
        match (al, bl) {
            (Some(x), Some(y)) if x == y => println!("  {x}"),
            (Some(x), Some(y)) => {
                println!("{}", format!("- {x}").red());
                println!("{}", format!("+ {y}").green());
            }
            (Some(x), None) => println!("{}", format!("- {x}").red()),
            (None, Some(y)) => println!("{}", format!("+ {y}").green()),
            (None, None) => {}
        }
    }
}

fn urlencode(s: &str) -> String {
    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}