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
//! Output rendering — `--output table|json|wide|name`.
//!
//! Doctrine: machine-readable output (json, name) goes to **stdout** raw, with no
//! decoration. Human output (table, wide) is also stdout but may include color.
//! Errors NEVER appear on stdout; see `exit::CtlError`.

use std::str::FromStr;

use owo_colors::OwoColorize;
use tabled::settings::{object::Rows, Alignment, Modify, Style};
use tabled::{Table, Tabled};

use crate::exit::CtlError;
use crate::model::{Cell, Formation};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
    /// Human-readable table — default.
    #[default]
    Table,
    /// Wider table with extra columns.
    Wide,
    /// Raw JSON.
    Json,
    /// One name per line — pipeable into `xargs`.
    Name,
}

impl FromStr for OutputFormat {
    type Err = CtlError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_ascii_lowercase().as_str() {
            "" | "table" => Ok(Self::Table),
            "wide" => Ok(Self::Wide),
            "json" => Ok(Self::Json),
            "name" => Ok(Self::Name),
            other => Err(CtlError::usage(format!(
                "unknown --output format: {other} (expected: table|wide|json|name)"
            ))),
        }
    }
}

// ---------------------------------------------------------------------------
// Tabled rows
// ---------------------------------------------------------------------------

#[derive(Tabled)]
struct FormationRow {
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "STATE")]
    state: String,
    #[tabled(rename = "CELLS")]
    cells: String,
    #[tabled(rename = "AGE")]
    age: String,
}

#[derive(Tabled)]
struct FormationWideRow {
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "ID")]
    id: String,
    #[tabled(rename = "STATE")]
    state: String,
    #[tabled(rename = "CELLS")]
    cells: String,
    #[tabled(rename = "TENANT")]
    tenant: String,
    #[tabled(rename = "CREATED")]
    created: String,
    #[tabled(rename = "UPDATED")]
    updated: String,
}

#[derive(Tabled)]
struct CellRow {
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "STATE")]
    state: String,
    #[tabled(rename = "FORMATION")]
    formation: String,
    #[tabled(rename = "AGE")]
    age: String,
}

#[derive(Tabled)]
struct CellWideRow {
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "ID")]
    id: String,
    #[tabled(rename = "STATE")]
    state: String,
    #[tabled(rename = "FORMATION")]
    formation: String,
    #[tabled(rename = "IMAGE")]
    image: String,
    #[tabled(rename = "CRITICAL")]
    critical: String,
    #[tabled(rename = "STARTED")]
    started: String,
    #[tabled(rename = "FINISHED")]
    finished: String,
}

// ---------------------------------------------------------------------------
// Renderers
// ---------------------------------------------------------------------------

pub fn render_formations(items: &[Formation], fmt: OutputFormat) {
    match fmt {
        OutputFormat::Json => print_json(items),
        OutputFormat::Name => {
            for f in items {
                println!("{}", pick_name(&f.name, &f.id));
            }
        }
        OutputFormat::Table => {
            let rows: Vec<FormationRow> = items
                .iter()
                .map(|f| FormationRow {
                    name: pick_name(&f.name, &f.id),
                    state: colorize_state(&f.state),
                    cells: f.cells.len().to_string(),
                    age: age_of(f.created_at.as_deref()),
                })
                .collect();
            print_table_rows(rows);
        }
        OutputFormat::Wide => {
            let rows: Vec<FormationWideRow> = items
                .iter()
                .map(|f| FormationWideRow {
                    name: pick_name(&f.name, &f.id),
                    id: short_id(&f.id),
                    state: colorize_state(&f.state),
                    cells: f.cells.len().to_string(),
                    tenant: f.tenant.clone().unwrap_or_else(|| "-".into()),
                    created: f.created_at.clone().unwrap_or_else(|| "-".into()),
                    updated: f.updated_at.clone().unwrap_or_else(|| "-".into()),
                })
                .collect();
            print_table_rows(rows);
        }
    }
}

pub fn render_cells(items: &[Cell], fmt: OutputFormat) {
    match fmt {
        OutputFormat::Json => print_json(items),
        OutputFormat::Name => {
            for c in items {
                println!("{}", pick_name(&c.name, &c.id));
            }
        }
        OutputFormat::Table => {
            let rows: Vec<CellRow> = items
                .iter()
                .map(|c| CellRow {
                    name: pick_name(&c.name, &c.id),
                    state: colorize_state(&c.state),
                    formation: c.formation_id.clone().unwrap_or_else(|| "-".into()),
                    age: age_of(c.created_at.as_deref()),
                })
                .collect();
            print_table_rows(rows);
        }
        OutputFormat::Wide => {
            let rows: Vec<CellWideRow> = items
                .iter()
                .map(|c| CellWideRow {
                    name: pick_name(&c.name, &c.id),
                    id: short_id(&c.id),
                    state: colorize_state(&c.state),
                    formation: c.formation_id.clone().unwrap_or_else(|| "-".into()),
                    image: c.image.clone().unwrap_or_else(|| "-".into()),
                    critical: c
                        .critical
                        .map(|b| if b { "yes" } else { "no" }.to_string())
                        .unwrap_or_else(|| "-".into()),
                    started: c.started_at.clone().unwrap_or_else(|| "-".into()),
                    finished: c.finished_at.clone().unwrap_or_else(|| "-".into()),
                })
                .collect();
            print_table_rows(rows);
        }
    }
}

fn print_table_rows<R: Tabled>(rows: Vec<R>) {
    if rows.is_empty() {
        // kubectl prints "No resources found." on stderr; we mirror that so
        // stdout stays empty for piping.
        eprintln!("No resources found.");
        return;
    }
    let mut t = Table::new(rows);
    t.with(Style::blank())
        .with(Modify::new(Rows::first()).with(Alignment::left()));
    println!("{t}");
}

fn print_json<T: serde::Serialize + ?Sized>(v: &T) {
    match serde_json::to_string_pretty(v) {
        Ok(s) => println!("{s}"),
        Err(e) => eprintln!("cellctl: api: encode json: {e}"),
    }
}

fn pick_name(name: &str, id: &str) -> String {
    if !name.is_empty() {
        name.to_string()
    } else if !id.is_empty() {
        id.to_string()
    } else {
        "-".into()
    }
}

fn short_id(id: &str) -> String {
    if id.len() > 12 {
        id[..12].to_string()
    } else {
        id.to_string()
    }
}

fn colorize_state(state: &str) -> String {
    // Only colorize when stdout is a TTY. owo-colors defers to supports-colors feature.
    let up = state.to_ascii_uppercase();
    match up.as_str() {
        "RUNNING" | "LAUNCHING" => up.green().to_string(),
        "COMPLETED" => up.cyan().to_string(),
        "DEGRADED" => up.yellow().to_string(),
        "FAILED" | "KILLED" => up.red().to_string(),
        "PENDING" | "ADMITTED" => up.bright_blue().to_string(),
        // owo-colors: trait bound satisfied for all &str above
        _ if up.is_empty() => "-".to_string(),
        _ => up,
    }
}

/// Best-effort human-readable age. Falls back to the raw string if parsing fails.
fn age_of(ts: Option<&str>) -> String {
    let Some(ts) = ts else { return "-".into() };
    match chrono::DateTime::parse_from_rfc3339(ts) {
        Ok(dt) => {
            let now = chrono::Utc::now();
            let delta = now.signed_duration_since(dt.with_timezone(&chrono::Utc));
            let secs = delta.num_seconds().max(0);
            if secs < 60 {
                format!("{secs}s")
            } else if secs < 3600 {
                format!("{}m", secs / 60)
            } else if secs < 86_400 {
                format!("{}h", secs / 3600)
            } else {
                format!("{}d", secs / 86_400)
            }
        }
        Err(_) => ts.to_string(),
    }
}