cellos-ctl 0.6.0-pre

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! Exit-code discipline — pattern-11 (`axiom_exit::Exit`) aligned with the
//! Algol/AXIOM cohort (tdep / tboundary / tflip).
//!
//! All non-success exits in `cellctl` MUST go through `CtlError::exit()`, which
//! routes the process exit byte through [`axiom_exit::Exit`] so the contract
//! between the CLI and shell scripts/CI is the same taxonomy every cohort tool
//! ships:
//!
//! | code | `Exit` variant       | meaning                                       |
//! |------|----------------------|-----------------------------------------------|
//! | 0    | `Ok`                 | success                                       |
//! | 1    | `AssertionFailed`    | a verify / chain assertion failed (mismatch)  |
//! | 2    | `Usage`              | usage / config error (bad flag, missing file) |
//! | 3    | `Preflight`          | local validation / parse / startup gate failed|
//! | 4    | `Degraded`           | completed in a reduced / advisory mode        |
//! | >=64 | `ToolSpecific(n)`    | tool-defined terminal outcome                 |
//!
//! `cellctl`'s "API error" (the server returned 4xx/5xx, or the network call
//! failed) is a tool-specific terminal outcome, so it maps to
//! [`CTL_API_FAILED`] (`Exit::ToolSpecific(64)`) — it is NOT an
//! `AssertionFailed` (cellctl runs no verify verb) and NOT a `Preflight`
//! failure (the local preflight passed; the remote call is what failed).
//!
//! Errors are always written to **stderr**. Machine-readable output (JSON, names)
//! always goes to **stdout** so it can be piped through `jq`, `xargs`, etc.

use std::fmt;
use std::process;

use axiom_exit::Exit;

/// `cellctl`'s tool-specific terminal outcome: the server returned an error
/// status, or the network call to cellos-server failed. axiom-exit owns the
/// byte (`64`); cellctl owns the meaning.
pub const CTL_API_FAILED: Exit = Exit::ToolSpecific(64);

/// Structured CLI error carrying both a message and the doctrine-mandated exit code.
///
/// `status` carries the HTTP status code when the error originated from a real
/// HTTP response with a non-success status. Transport / network errors leave
/// `status` as `None` so callers can distinguish "the server said no" from
/// "we never reached the server". See SMOKE-TEST report Finding #1:
/// `cellctl diff` must only treat HTTP 404 as "would create", not every
/// `ErrorKind::Api`.
#[derive(Debug)]
pub struct CtlError {
    pub kind: ErrorKind,
    pub message: String,
    pub status: Option<u16>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
    /// Usage / config error — bad CLI invocation, missing config, malformed YAML on read.
    /// Maps to [`Exit::Usage`] (code 2).
    Usage,
    /// API error — cellos-server returned an error status, or the network call failed.
    /// Maps to [`CTL_API_FAILED`] (`Exit::ToolSpecific(64)`).
    Api,
    /// Validation error — local schema check failed before the API call.
    /// Maps to [`Exit::Preflight`] (code 3).
    Validation,
}

impl ErrorKind {
    /// The pattern-11 [`Exit`] this error maps to. `axiom_exit::Exit` is the
    /// single source of truth for the numeric process-exit byte.
    pub fn exit(self) -> Exit {
        match self {
            Self::Usage => Exit::Usage,
            Self::Api => CTL_API_FAILED,
            Self::Validation => Exit::Preflight,
        }
    }

    pub fn code(self) -> i32 {
        self.exit().as_i32()
    }
}

impl fmt::Display for ErrorKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Usage => f.write_str("usage"),
            Self::Api => f.write_str("api"),
            Self::Validation => f.write_str("validation"),
        }
    }
}

impl CtlError {
    pub fn usage(msg: impl Into<String>) -> Self {
        Self {
            kind: ErrorKind::Usage,
            message: msg.into(),
            status: None,
        }
    }
    pub fn api(msg: impl Into<String>) -> Self {
        Self {
            kind: ErrorKind::Api,
            message: msg.into(),
            status: None,
        }
    }
    pub fn validation(msg: impl Into<String>) -> Self {
        Self {
            kind: ErrorKind::Validation,
            message: msg.into(),
            status: None,
        }
    }

    /// Attach an HTTP status code. Use this in client error paths that map
    /// a real HTTP response into a `CtlError` so downstream callers
    /// (e.g. `cellctl diff`'s 404→"would create" branch) can distinguish
    /// "server returned 404" from "transport failure".
    pub fn with_status(mut self, status: u16) -> Self {
        self.status = Some(status);
        self
    }

    /// Emit the error to stderr and exit with the pattern-11 code derived from
    /// [`axiom_exit::Exit`]. Never returns.
    pub fn exit(&self) -> ! {
        eprintln!("cellctl: {}: {}", self.kind, self.message);
        process::exit(self.kind.exit().code().into())
    }
}

impl fmt::Display for CtlError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.kind, self.message)
    }
}

impl std::error::Error for CtlError {}

/// Convenience: convert anyhow errors into a generic API error.
impl From<anyhow::Error> for CtlError {
    fn from(e: anyhow::Error) -> Self {
        Self::api(e.to_string())
    }
}

impl From<reqwest::Error> for CtlError {
    fn from(e: reqwest::Error) -> Self {
        Self::api(format!("http: {e}"))
    }
}

impl From<std::io::Error> for CtlError {
    fn from(e: std::io::Error) -> Self {
        Self::usage(format!("io: {e}"))
    }
}

impl From<serde_yaml::Error> for CtlError {
    fn from(e: serde_yaml::Error) -> Self {
        Self::validation(format!("yaml: {e}"))
    }
}

impl From<serde_json::Error> for CtlError {
    fn from(e: serde_json::Error) -> Self {
        Self::api(format!("json: {e}"))
    }
}

pub type CtlResult<T> = Result<T, CtlError>;