Skip to main content

cellos_ctl/
output.rs

1//! Output rendering — `--output table|json|wide|name`.
2//!
3//! Doctrine: machine-readable output (json, name) goes to **stdout** raw, with no
4//! decoration. Human output (table, wide) is also stdout but may include color.
5//! Errors NEVER appear on stdout; see `exit::CtlError`.
6
7use std::str::FromStr;
8
9use owo_colors::OwoColorize;
10use tabled::settings::{object::Rows, Alignment, Modify, Style};
11use tabled::{Table, Tabled};
12
13use crate::exit::CtlError;
14use crate::model::{Cell, Formation};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum OutputFormat {
18    /// Human-readable table — default.
19    #[default]
20    Table,
21    /// Wider table with extra columns.
22    Wide,
23    /// Raw JSON.
24    Json,
25    /// One name per line — pipeable into `xargs`.
26    Name,
27}
28
29impl FromStr for OutputFormat {
30    type Err = CtlError;
31    fn from_str(s: &str) -> Result<Self, Self::Err> {
32        match s.to_ascii_lowercase().as_str() {
33            "" | "table" => Ok(Self::Table),
34            "wide" => Ok(Self::Wide),
35            "json" => Ok(Self::Json),
36            "name" => Ok(Self::Name),
37            other => Err(CtlError::usage(format!(
38                "unknown --output format: {other} (expected: table|wide|json|name)"
39            ))),
40        }
41    }
42}
43
44// ---------------------------------------------------------------------------
45// Tabled rows
46// ---------------------------------------------------------------------------
47
48#[derive(Tabled)]
49struct FormationRow {
50    #[tabled(rename = "NAME")]
51    name: String,
52    #[tabled(rename = "STATE")]
53    state: String,
54    #[tabled(rename = "CELLS")]
55    cells: String,
56    #[tabled(rename = "AGE")]
57    age: String,
58}
59
60#[derive(Tabled)]
61struct FormationWideRow {
62    #[tabled(rename = "NAME")]
63    name: String,
64    #[tabled(rename = "ID")]
65    id: String,
66    #[tabled(rename = "STATE")]
67    state: String,
68    #[tabled(rename = "CELLS")]
69    cells: String,
70    #[tabled(rename = "TENANT")]
71    tenant: String,
72    #[tabled(rename = "CREATED")]
73    created: String,
74    #[tabled(rename = "UPDATED")]
75    updated: String,
76}
77
78#[derive(Tabled)]
79struct CellRow {
80    #[tabled(rename = "NAME")]
81    name: String,
82    #[tabled(rename = "STATE")]
83    state: String,
84    #[tabled(rename = "FORMATION")]
85    formation: String,
86    #[tabled(rename = "AGE")]
87    age: String,
88}
89
90#[derive(Tabled)]
91struct CellWideRow {
92    #[tabled(rename = "NAME")]
93    name: String,
94    #[tabled(rename = "ID")]
95    id: String,
96    #[tabled(rename = "STATE")]
97    state: String,
98    #[tabled(rename = "FORMATION")]
99    formation: String,
100    #[tabled(rename = "IMAGE")]
101    image: String,
102    #[tabled(rename = "CRITICAL")]
103    critical: String,
104    #[tabled(rename = "STARTED")]
105    started: String,
106    #[tabled(rename = "FINISHED")]
107    finished: String,
108}
109
110// ---------------------------------------------------------------------------
111// Renderers
112// ---------------------------------------------------------------------------
113
114pub fn render_formations(items: &[Formation], fmt: OutputFormat) {
115    match fmt {
116        OutputFormat::Json => print_json(items),
117        OutputFormat::Name => {
118            for f in items {
119                println!("{}", pick_name(&f.name, &f.id));
120            }
121        }
122        OutputFormat::Table => {
123            let rows: Vec<FormationRow> = items
124                .iter()
125                .map(|f| FormationRow {
126                    name: pick_name(&f.name, &f.id),
127                    state: colorize_state(&f.state),
128                    cells: f.cells.len().to_string(),
129                    age: age_of(f.created_at.as_deref()),
130                })
131                .collect();
132            print_table_rows(rows);
133        }
134        OutputFormat::Wide => {
135            let rows: Vec<FormationWideRow> = items
136                .iter()
137                .map(|f| FormationWideRow {
138                    name: pick_name(&f.name, &f.id),
139                    id: short_id(&f.id),
140                    state: colorize_state(&f.state),
141                    cells: f.cells.len().to_string(),
142                    tenant: f.tenant.clone().unwrap_or_else(|| "-".into()),
143                    created: f.created_at.clone().unwrap_or_else(|| "-".into()),
144                    updated: f.updated_at.clone().unwrap_or_else(|| "-".into()),
145                })
146                .collect();
147            print_table_rows(rows);
148        }
149    }
150}
151
152pub fn render_cells(items: &[Cell], fmt: OutputFormat) {
153    match fmt {
154        OutputFormat::Json => print_json(items),
155        OutputFormat::Name => {
156            for c in items {
157                println!("{}", pick_name(&c.name, &c.id));
158            }
159        }
160        OutputFormat::Table => {
161            let rows: Vec<CellRow> = items
162                .iter()
163                .map(|c| CellRow {
164                    name: pick_name(&c.name, &c.id),
165                    state: colorize_state(&c.state),
166                    formation: c.formation_id.clone().unwrap_or_else(|| "-".into()),
167                    age: age_of(c.created_at.as_deref()),
168                })
169                .collect();
170            print_table_rows(rows);
171        }
172        OutputFormat::Wide => {
173            let rows: Vec<CellWideRow> = items
174                .iter()
175                .map(|c| CellWideRow {
176                    name: pick_name(&c.name, &c.id),
177                    id: short_id(&c.id),
178                    state: colorize_state(&c.state),
179                    formation: c.formation_id.clone().unwrap_or_else(|| "-".into()),
180                    image: c.image.clone().unwrap_or_else(|| "-".into()),
181                    critical: c
182                        .critical
183                        .map(|b| if b { "yes" } else { "no" }.to_string())
184                        .unwrap_or_else(|| "-".into()),
185                    started: c.started_at.clone().unwrap_or_else(|| "-".into()),
186                    finished: c.finished_at.clone().unwrap_or_else(|| "-".into()),
187                })
188                .collect();
189            print_table_rows(rows);
190        }
191    }
192}
193
194fn print_table_rows<R: Tabled>(rows: Vec<R>) {
195    if rows.is_empty() {
196        // kubectl prints "No resources found." on stderr; we mirror that so
197        // stdout stays empty for piping.
198        eprintln!("No resources found.");
199        return;
200    }
201    let mut t = Table::new(rows);
202    t.with(Style::blank())
203        .with(Modify::new(Rows::first()).with(Alignment::left()));
204    println!("{t}");
205}
206
207fn print_json<T: serde::Serialize + ?Sized>(v: &T) {
208    match serde_json::to_string_pretty(v) {
209        Ok(s) => println!("{s}"),
210        Err(e) => eprintln!("cellctl: api: encode json: {e}"),
211    }
212}
213
214fn pick_name(name: &str, id: &str) -> String {
215    if !name.is_empty() {
216        name.to_string()
217    } else if !id.is_empty() {
218        id.to_string()
219    } else {
220        "-".into()
221    }
222}
223
224fn short_id(id: &str) -> String {
225    if id.len() > 12 {
226        id[..12].to_string()
227    } else {
228        id.to_string()
229    }
230}
231
232fn colorize_state(state: &str) -> String {
233    // Only colorize when stdout is a TTY. owo-colors defers to supports-colors feature.
234    let up = state.to_ascii_uppercase();
235    // Wave 2 red-team (HIGH-W2D-2): cellos-server emits `SUCCEEDED` and
236    // `CANCELLED` via the FormationStatus enum's `#[serde(rename_all =
237    // "UPPERCASE")]` derivation; previously these fell through to the
238    // raw-string default and rendered uncolorised, looking out of place
239    // next to `COMPLETED` / `FAILED`. Cells use COMPLETED — both names
240    // share the terminal-success semantics, so both colorise as cyan.
241    match up.as_str() {
242        "RUNNING" | "LAUNCHING" => up.green().to_string(),
243        "COMPLETED" | "SUCCEEDED" => up.cyan().to_string(),
244        "DEGRADED" => up.yellow().to_string(),
245        "FAILED" | "KILLED" | "CANCELLED" => up.red().to_string(),
246        "PENDING" | "ADMITTED" => up.bright_blue().to_string(),
247        // owo-colors: trait bound satisfied for all &str above
248        _ if up.is_empty() => "-".to_string(),
249        _ => up,
250    }
251}
252
253/// Best-effort human-readable age. Falls back to the raw string if parsing fails.
254fn age_of(ts: Option<&str>) -> String {
255    let Some(ts) = ts else { return "-".into() };
256    match chrono::DateTime::parse_from_rfc3339(ts) {
257        Ok(dt) => {
258            let now = chrono::Utc::now();
259            let delta = now.signed_duration_since(dt.with_timezone(&chrono::Utc));
260            let secs = delta.num_seconds().max(0);
261            if secs < 60 {
262                format!("{secs}s")
263            } else if secs < 3600 {
264                format!("{}m", secs / 60)
265            } else if secs < 86_400 {
266                format!("{}h", secs / 3600)
267            } else {
268                format!("{}d", secs / 86_400)
269            }
270        }
271        Err(_) => ts.to_string(),
272    }
273}