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
114const COST_WARNING_RATIO: f64 = 0.8;
117
118fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
122 match max_cost_usd {
123 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
124 None => format!("${cost_usd:.4}"),
125 }
126}
127
128fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
134 let cap = max_cost_usd?;
135
136 if cap <= 0.0 {
137 return (cost_usd > 0.0).then_some(Color::Red);
138 }
139
140 let ratio = cost_usd / cap;
141 if ratio >= 1.0 {
142 Some(Color::Red)
143 } else if ratio >= COST_WARNING_RATIO {
144 Some(Color::Yellow)
145 } else {
146 None
147 }
148}
149
150fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
152 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
153 match cost_color(cost_usd, max_cost_usd) {
154 Some(color) => cell.fg(color),
155 None => cell,
156 }
157}
158
159pub fn runs_table(runs: &[RunResponse]) -> Table {
160 let mut table = base_table();
161 table.set_header(vec![
162 "ID",
163 "Workflow",
164 "Status",
165 "Triggered by",
166 "Duration",
167 "Cost",
168 "Created",
169 "Started",
170 ]);
171
172 for run in runs {
173 let status_cell = Cell::new(run.status)
174 .fg(status_color(&run.status))
175 .set_alignment(CellAlignment::Center);
176
177 table.add_row(vec![
178 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
179 Cell::new(&run.workflow_name),
180 status_cell,
181 Cell::new(&run.created_by.label),
182 Cell::new(format_duration_ms(run.duration_ms)),
183 cost_cell(run.cost_usd, run.max_cost_usd),
184 Cell::new(format_datetime(&run.created_at)),
185 Cell::new(format_optional_datetime(&run.started_at)),
186 ]);
187 }
188
189 table
190}
191
192pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
194 let run = &detail.run;
195 let mut table = base_table();
196 table.set_header(vec!["Field", "Value"]);
197
198 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
199
200 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
201 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
202 table.add_row(vec![Cell::new("Status"), status_cell]);
203 table.add_row(vec![
204 Cell::new("Trigger"),
205 Cell::new(format!("{:?}", run.trigger)),
206 ]);
207 table.add_row(vec![
208 Cell::new("Triggered by"),
209 Cell::new(&run.created_by.label),
210 ]);
211 table.add_row(vec![
212 Cell::new("Duration"),
213 Cell::new(format_duration_ms(run.duration_ms)),
214 ]);
215 table.add_row(vec![
216 Cell::new("Cost"),
217 cost_cell(run.cost_usd, run.max_cost_usd),
218 ]);
219 table.add_row(vec![
220 Cell::new("Created"),
221 Cell::new(format_datetime(&run.created_at)),
222 ]);
223 table.add_row(vec![
224 Cell::new("Started"),
225 Cell::new(format_optional_datetime(&run.started_at)),
226 ]);
227 table.add_row(vec![
228 Cell::new("Completed"),
229 Cell::new(format_optional_datetime(&run.completed_at)),
230 ]);
231 table.add_row(vec![
232 Cell::new("Retries"),
233 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
234 ]);
235
236 if let Some(ref error) = run.error {
237 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
238 }
239
240 if !detail.steps.is_empty() {
241 table.add_row(vec![
242 Cell::new("Steps"),
243 Cell::new(format!("{} step(s)", detail.steps.len())),
244 ]);
245 }
246
247 table
248}
249
250pub fn steps_table(steps: &[StepResponse]) -> Table {
252 let mut table = base_table();
253 table.set_header(vec![
254 "ID",
255 "Name",
256 "Status",
257 "Attempt",
258 "Duration",
259 "Cost",
260 "Started",
261 "Completed",
262 ]);
263
264 for step in steps {
265 let color = step_status_color(&step.status);
266
267 table.add_row(vec![
268 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
269 Cell::new(&step.name),
270 Cell::new(step.status)
271 .fg(color)
272 .set_alignment(CellAlignment::Center),
273 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
274 Cell::new(format_duration_ms(step.duration_ms)),
275 Cell::new(format!("${:.4}", step.cost_usd)),
276 Cell::new(format_optional_datetime(&step.started_at)),
277 Cell::new(format_optional_datetime(&step.completed_at)),
278 ]);
279 }
280
281 table
282}
283
284pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
286 let mut table = base_table();
287 table.set_header(vec!["Name", "Category", "Version"]);
288
289 for wf in workflows {
290 table.add_row(vec![
291 Cell::new(&wf.name),
292 Cell::new(wf.category.as_deref().unwrap_or("-")),
293 Cell::new(wf.version.as_deref().unwrap_or("-")),
294 ]);
295 }
296
297 table
298}
299
300pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
302 let mut table = base_table();
303 table.set_header(vec!["Field", "Value"]);
304
305 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
306 table.add_row(vec![
307 Cell::new("Description"),
308 Cell::new(&detail.description),
309 ]);
310 table.add_row(vec![
311 Cell::new("Category"),
312 Cell::new(detail.category.as_deref().unwrap_or("-")),
313 ]);
314 table.add_row(vec![
315 Cell::new("Version"),
316 Cell::new(detail.version.as_deref().unwrap_or("-")),
317 ]);
318
319 if !detail.sub_workflows.is_empty() {
320 let names: Vec<&str> = detail
321 .sub_workflows
322 .iter()
323 .map(|s| s.name.as_str())
324 .collect();
325 table.add_row(vec![
326 Cell::new("Sub-workflows"),
327 Cell::new(names.join(", ")),
328 ]);
329 }
330
331 table
332}
333
334pub fn stats_table(stats: &StatsResponse) -> Table {
336 let mut table = base_table();
337 table.set_header(vec!["Metric", "Value"]);
338
339 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
340 table.add_row(vec![
341 Cell::new("Completed"),
342 Cell::new(stats.completed_runs).fg(Color::Green),
343 ]);
344 table.add_row(vec![
345 Cell::new("Failed"),
346 Cell::new(stats.failed_runs).fg(Color::Red),
347 ]);
348 table.add_row(vec![
349 Cell::new("Cancelled"),
350 Cell::new(stats.cancelled_runs).fg(Color::Grey),
351 ]);
352 table.add_row(vec![
353 Cell::new("Active"),
354 Cell::new(stats.active_runs).fg(Color::Blue),
355 ]);
356 table.add_row(vec![
357 Cell::new("Success rate"),
358 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
359 ]);
360 table.add_row(vec![
361 Cell::new("Total cost"),
362 Cell::new(format!("${:.4}", stats.total_cost_usd)),
363 ]);
364 table.add_row(vec![
365 Cell::new("Total duration"),
366 Cell::new(format_duration_ms(stats.total_duration_ms)),
367 ]);
368
369 table
370}
371
372#[cfg(test)]
373mod tests {
374 use std::collections::HashMap;
375 use std::slice;
376
377 use ironflow_sdk::types::{CreatedBy, CreatedByKind, TriggerKind};
378 use serde_json::{Map, Value};
379 use uuid::Uuid;
380
381 use super::*;
382
383 fn run_fixture(created_by: CreatedBy) -> RunResponse {
385 let now = Utc::now();
386 RunResponse {
387 id: Uuid::now_v7(),
388 workflow_name: "deploy".to_string(),
389 status: RunStatus::Completed,
390 trigger: TriggerKind::Api,
391 error: None,
392 retry_count: 0,
393 max_retries: 0,
394 cost_usd: 0.0,
395 duration_ms: 0,
396 created_at: now,
397 updated_at: now,
398 started_at: None,
399 completed_at: None,
400 handler_version: None,
401 labels: HashMap::new(),
402 scheduled_at: None,
403 created_by,
404 idempotency_key: None,
405 max_cost_usd: None,
406 }
407 }
408
409 #[test]
410 fn format_cost_without_cap_shows_amount_only() {
411 assert_eq!(format_cost(0.1234, None), "$0.1234");
412 }
413
414 #[test]
415 fn format_cost_with_cap_shows_both_amounts() {
416 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
417 }
418
419 #[test]
420 fn cost_color_is_absent_without_a_cap() {
421 assert_eq!(cost_color(999.0, None), None);
422 }
423
424 #[test]
425 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
426 assert_eq!(cost_color(1.0, Some(2.0)), None); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
429 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
432
433 #[test]
434 fn cost_color_handles_a_zero_cap() {
435 assert_eq!(cost_color(0.0, Some(0.0)), None);
436 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
437 }
438
439 #[test]
440 fn format_duration_ms_millis() {
441 assert_eq!(format_duration_ms(500), "500ms");
442 assert_eq!(format_duration_ms(0), "0ms");
443 }
444
445 #[test]
446 fn format_duration_ms_seconds() {
447 assert_eq!(format_duration_ms(5000), "5s");
448 assert_eq!(format_duration_ms(59000), "59s");
449 }
450
451 #[test]
452 fn format_duration_ms_minutes() {
453 assert_eq!(format_duration_ms(60000), "1m 0s");
454 assert_eq!(format_duration_ms(125000), "2m 5s");
455 }
456
457 #[test]
458 fn format_duration_ms_hours() {
459 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
460 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
461 }
462
463 #[test]
464 fn format_optional_datetime_none() {
465 assert_eq!(format_optional_datetime(&None), "-");
466 }
467
468 #[test]
469 fn format_optional_datetime_some() {
470 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
471 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
472 }
473
474 #[test]
475 fn status_colors_are_distinct() {
476 let statuses = [
477 RunStatus::Completed,
478 RunStatus::Failed,
479 RunStatus::Running,
480 RunStatus::Pending,
481 RunStatus::Cancelled,
482 RunStatus::AwaitingApproval,
483 RunStatus::Retrying,
484 ];
485
486 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
487 for (i, c1) in colors.iter().enumerate() {
488 for (j, c2) in colors.iter().enumerate() {
489 if i != j {
490 assert_ne!(c1, c2, "status colors must be distinct");
491 }
492 }
493 }
494 }
495
496 #[test]
497 fn empty_runs_table_has_header() {
498 let table = runs_table(&[]);
499 let output = table.to_string();
500 assert!(output.contains("ID"));
501 assert!(output.contains("Workflow"));
502 assert!(output.contains("Status"));
503 assert!(output.contains("Triggered by"));
504 }
505
506 #[test]
507 fn runs_table_renders_the_author_label() {
508 let run = run_fixture(CreatedBy {
509 kind: CreatedByKind::ApiKey,
510 id: Some(Uuid::now_v7()),
511 label: "ci-deploy (alice)".to_string(),
512 });
513
514 let output = runs_table(slice::from_ref(&run)).to_string();
515 assert!(
516 output.contains("ci-deploy (alice)"),
517 "author missing from:\n{output}"
518 );
519 }
520
521 #[test]
522 fn run_detail_table_renders_the_author_label() {
523 let detail = RunDetailResponse {
524 run: run_fixture(CreatedBy {
525 kind: CreatedByKind::System,
526 id: None,
527 label: "/hooks/github".to_string(),
528 }),
529 steps: Vec::new(),
530 payload: Value::Object(Map::new()),
531 };
532
533 let output = run_detail_table(&detail).to_string();
534 assert!(output.contains("Triggered by"));
535 assert!(
536 output.contains("/hooks/github"),
537 "author missing from:\n{output}"
538 );
539 }
540
541 #[test]
542 fn empty_workflows_table_has_header() {
543 let table = workflows_table(&[]);
544 let output = table.to_string();
545 assert!(output.contains("Name"));
546 assert!(output.contains("Category"));
547 }
548}