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    match up.as_str() {
236        "RUNNING" | "LAUNCHING" => up.green().to_string(),
237        "COMPLETED" => up.cyan().to_string(),
238        "DEGRADED" => up.yellow().to_string(),
239        "FAILED" | "KILLED" => up.red().to_string(),
240        "PENDING" | "ADMITTED" => up.bright_blue().to_string(),
241        // owo-colors: trait bound satisfied for all &str above
242        _ if up.is_empty() => "-".to_string(),
243        _ => up,
244    }
245}
246
247/// Best-effort human-readable age. Falls back to the raw string if parsing fails.
248fn age_of(ts: Option<&str>) -> String {
249    let Some(ts) = ts else { return "-".into() };
250    match chrono::DateTime::parse_from_rfc3339(ts) {
251        Ok(dt) => {
252            let now = chrono::Utc::now();
253            let delta = now.signed_duration_since(dt.with_timezone(&chrono::Utc));
254            let secs = delta.num_seconds().max(0);
255            if secs < 60 {
256                format!("{secs}s")
257            } else if secs < 3600 {
258                format!("{}m", secs / 60)
259            } else if secs < 86_400 {
260                format!("{}h", secs / 3600)
261            } else {
262                format!("{}d", secs / 86_400)
263            }
264        }
265        Err(_) => ts.to_string(),
266    }
267}