1use 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 #[default]
20 Table,
21 Wide,
23 Json,
25 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#[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
110pub 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 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 let up = state.to_ascii_uppercase();
235 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 _ if up.is_empty() => "-".to_string(),
249 _ => up,
250 }
251}
252
253fn 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}