1use std::io::Write;
7
8use chrono::{DateTime, Utc};
9use comfy_table::presets::UTF8_FULL;
10use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
11use ironflow_sdk::types::{
12 RunDetailResponse, RunResponse, RunStatus, StatsResponse, StepResponse, StepStatus,
13 WorkflowDetailResponse, WorkflowSummary,
14};
15use serde::Serialize;
16
17fn status_color(status: &RunStatus) -> Color {
19 match status {
20 RunStatus::Completed => Color::Green,
21 RunStatus::Failed => Color::Red,
22 RunStatus::Running => Color::Blue,
23 RunStatus::Pending => Color::Yellow,
24 RunStatus::Cancelled => Color::Grey,
25 RunStatus::AwaitingApproval => Color::Magenta,
26 RunStatus::Retrying => Color::Cyan,
27 }
28}
29
30fn step_status_color(status: &StepStatus) -> Color {
32 match status {
33 StepStatus::Completed => Color::Green,
34 StepStatus::Failed => Color::Red,
35 StepStatus::Running => Color::Blue,
36 StepStatus::Pending => Color::Yellow,
37 StepStatus::Skipped => Color::Grey,
38 StepStatus::AwaitingApproval => Color::Magenta,
39 StepStatus::Rejected => Color::Red,
40 }
41}
42
43fn format_datetime(dt: &DateTime<Utc>) -> String {
45 dt.format("%Y-%m-%d %H:%M:%S").to_string()
46}
47
48fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
50 dt.as_ref().map_or("-".to_string(), format_datetime)
51}
52
53fn format_duration_ms(ms: i64) -> String {
55 if ms < 1000 {
56 return format!("{ms}ms");
57 }
58 let secs = ms / 1000;
59 if secs < 60 {
60 return format!("{secs}s");
61 }
62 let mins = secs / 60;
63 let remaining_secs = secs % 60;
64 if mins < 60 {
65 return format!("{mins}m {remaining_secs}s");
66 }
67 let hours = mins / 60;
68 let remaining_mins = mins % 60;
69 format!("{hours}h {remaining_mins}m")
70}
71
72fn base_table() -> Table {
74 let mut table = Table::new();
75 table
76 .load_preset(UTF8_FULL)
77 .set_content_arrangement(ContentArrangement::Dynamic);
78 table
79}
80
81pub fn render_output<W: Write, T: Serialize>(
87 writer: &mut W,
88 json_mode: bool,
89 value: &T,
90 table_fn: impl FnOnce() -> Table,
91) -> anyhow::Result<()> {
92 if json_mode {
93 let json = serde_json::to_string_pretty(value)?;
94 writeln!(writer, "{json}")?;
95 } else {
96 writeln!(writer, "{}", table_fn())?;
97 }
98 Ok(())
99}
100
101pub fn print_output<T: Serialize>(
107 json_mode: bool,
108 value: &T,
109 table_fn: impl FnOnce() -> Table,
110) -> anyhow::Result<()> {
111 render_output(&mut std::io::stdout().lock(), json_mode, value, table_fn)
112}
113
114pub fn runs_table(runs: &[RunResponse]) -> Table {
116 let mut table = base_table();
117 table.set_header(vec![
118 "ID", "Workflow", "Status", "Duration", "Cost", "Created", "Started",
119 ]);
120
121 for run in runs {
122 let status_cell = Cell::new(run.status)
123 .fg(status_color(&run.status))
124 .set_alignment(CellAlignment::Center);
125
126 table.add_row(vec![
127 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
128 Cell::new(&run.workflow_name),
129 status_cell,
130 Cell::new(format_duration_ms(run.duration_ms)),
131 Cell::new(format!("${:.4}", run.cost_usd)),
132 Cell::new(format_datetime(&run.created_at)),
133 Cell::new(format_optional_datetime(&run.started_at)),
134 ]);
135 }
136
137 table
138}
139
140pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
142 let run = &detail.run;
143 let mut table = base_table();
144 table.set_header(vec!["Field", "Value"]);
145
146 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
147
148 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
149 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
150 table.add_row(vec![Cell::new("Status"), status_cell]);
151 table.add_row(vec![
152 Cell::new("Trigger"),
153 Cell::new(format!("{:?}", run.trigger)),
154 ]);
155 table.add_row(vec![
156 Cell::new("Duration"),
157 Cell::new(format_duration_ms(run.duration_ms)),
158 ]);
159 table.add_row(vec![
160 Cell::new("Cost"),
161 Cell::new(format!("${:.4}", run.cost_usd)),
162 ]);
163 table.add_row(vec![
164 Cell::new("Created"),
165 Cell::new(format_datetime(&run.created_at)),
166 ]);
167 table.add_row(vec![
168 Cell::new("Started"),
169 Cell::new(format_optional_datetime(&run.started_at)),
170 ]);
171 table.add_row(vec![
172 Cell::new("Completed"),
173 Cell::new(format_optional_datetime(&run.completed_at)),
174 ]);
175 table.add_row(vec![
176 Cell::new("Retries"),
177 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
178 ]);
179
180 if let Some(ref error) = run.error {
181 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
182 }
183
184 if !detail.steps.is_empty() {
185 table.add_row(vec![
186 Cell::new("Steps"),
187 Cell::new(format!("{} step(s)", detail.steps.len())),
188 ]);
189 }
190
191 table
192}
193
194pub fn steps_table(steps: &[StepResponse]) -> Table {
196 let mut table = base_table();
197 table.set_header(vec![
198 "ID",
199 "Name",
200 "Status",
201 "Duration",
202 "Cost",
203 "Started",
204 "Completed",
205 ]);
206
207 for step in steps {
208 let color = step_status_color(&step.status);
209
210 table.add_row(vec![
211 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
212 Cell::new(&step.name),
213 Cell::new(step.status)
214 .fg(color)
215 .set_alignment(CellAlignment::Center),
216 Cell::new(format_duration_ms(step.duration_ms)),
217 Cell::new(format!("${:.4}", step.cost_usd)),
218 Cell::new(format_optional_datetime(&step.started_at)),
219 Cell::new(format_optional_datetime(&step.completed_at)),
220 ]);
221 }
222
223 table
224}
225
226pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
228 let mut table = base_table();
229 table.set_header(vec!["Name", "Category", "Version"]);
230
231 for wf in workflows {
232 table.add_row(vec![
233 Cell::new(&wf.name),
234 Cell::new(wf.category.as_deref().unwrap_or("-")),
235 Cell::new(wf.version.as_deref().unwrap_or("-")),
236 ]);
237 }
238
239 table
240}
241
242pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
244 let mut table = base_table();
245 table.set_header(vec!["Field", "Value"]);
246
247 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
248 table.add_row(vec![
249 Cell::new("Description"),
250 Cell::new(&detail.description),
251 ]);
252 table.add_row(vec![
253 Cell::new("Category"),
254 Cell::new(detail.category.as_deref().unwrap_or("-")),
255 ]);
256 table.add_row(vec![
257 Cell::new("Version"),
258 Cell::new(detail.version.as_deref().unwrap_or("-")),
259 ]);
260
261 if !detail.sub_workflows.is_empty() {
262 let names: Vec<&str> = detail
263 .sub_workflows
264 .iter()
265 .map(|s| s.name.as_str())
266 .collect();
267 table.add_row(vec![
268 Cell::new("Sub-workflows"),
269 Cell::new(names.join(", ")),
270 ]);
271 }
272
273 table
274}
275
276pub fn stats_table(stats: &StatsResponse) -> Table {
278 let mut table = base_table();
279 table.set_header(vec!["Metric", "Value"]);
280
281 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
282 table.add_row(vec![
283 Cell::new("Completed"),
284 Cell::new(stats.completed_runs).fg(Color::Green),
285 ]);
286 table.add_row(vec![
287 Cell::new("Failed"),
288 Cell::new(stats.failed_runs).fg(Color::Red),
289 ]);
290 table.add_row(vec![
291 Cell::new("Cancelled"),
292 Cell::new(stats.cancelled_runs).fg(Color::Grey),
293 ]);
294 table.add_row(vec![
295 Cell::new("Active"),
296 Cell::new(stats.active_runs).fg(Color::Blue),
297 ]);
298 table.add_row(vec![
299 Cell::new("Success rate"),
300 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
301 ]);
302 table.add_row(vec![
303 Cell::new("Total cost"),
304 Cell::new(format!("${:.4}", stats.total_cost_usd)),
305 ]);
306 table.add_row(vec![
307 Cell::new("Total duration"),
308 Cell::new(format_duration_ms(stats.total_duration_ms)),
309 ]);
310
311 table
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn format_duration_ms_millis() {
320 assert_eq!(format_duration_ms(500), "500ms");
321 assert_eq!(format_duration_ms(0), "0ms");
322 }
323
324 #[test]
325 fn format_duration_ms_seconds() {
326 assert_eq!(format_duration_ms(5000), "5s");
327 assert_eq!(format_duration_ms(59000), "59s");
328 }
329
330 #[test]
331 fn format_duration_ms_minutes() {
332 assert_eq!(format_duration_ms(60000), "1m 0s");
333 assert_eq!(format_duration_ms(125000), "2m 5s");
334 }
335
336 #[test]
337 fn format_duration_ms_hours() {
338 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
339 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
340 }
341
342 #[test]
343 fn format_optional_datetime_none() {
344 assert_eq!(format_optional_datetime(&None), "-");
345 }
346
347 #[test]
348 fn format_optional_datetime_some() {
349 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
350 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
351 }
352
353 #[test]
354 fn status_colors_are_distinct() {
355 let statuses = [
356 RunStatus::Completed,
357 RunStatus::Failed,
358 RunStatus::Running,
359 RunStatus::Pending,
360 RunStatus::Cancelled,
361 RunStatus::AwaitingApproval,
362 RunStatus::Retrying,
363 ];
364
365 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
366 for (i, c1) in colors.iter().enumerate() {
367 for (j, c2) in colors.iter().enumerate() {
368 if i != j {
369 assert_ne!(c1, c2, "status colors must be distinct");
370 }
371 }
372 }
373 }
374
375 #[test]
376 fn empty_runs_table_has_header() {
377 let table = runs_table(&[]);
378 let output = table.to_string();
379 assert!(output.contains("ID"));
380 assert!(output.contains("Workflow"));
381 assert!(output.contains("Status"));
382 }
383
384 #[test]
385 fn empty_workflows_table_has_header() {
386 let table = workflows_table(&[]);
387 let output = table.to_string();
388 assert!(output.contains("Name"));
389 assert!(output.contains("Category"));
390 }
391}