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 "Duration",
258 "Cost",
259 "Started",
260 "Completed",
261 ]);
262
263 for step in steps {
264 let color = step_status_color(&step.status);
265
266 table.add_row(vec![
267 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
268 Cell::new(&step.name),
269 Cell::new(step.status)
270 .fg(color)
271 .set_alignment(CellAlignment::Center),
272 Cell::new(format_duration_ms(step.duration_ms)),
273 Cell::new(format!("${:.4}", step.cost_usd)),
274 Cell::new(format_optional_datetime(&step.started_at)),
275 Cell::new(format_optional_datetime(&step.completed_at)),
276 ]);
277 }
278
279 table
280}
281
282pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
284 let mut table = base_table();
285 table.set_header(vec!["Name", "Category", "Version"]);
286
287 for wf in workflows {
288 table.add_row(vec![
289 Cell::new(&wf.name),
290 Cell::new(wf.category.as_deref().unwrap_or("-")),
291 Cell::new(wf.version.as_deref().unwrap_or("-")),
292 ]);
293 }
294
295 table
296}
297
298pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
300 let mut table = base_table();
301 table.set_header(vec!["Field", "Value"]);
302
303 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
304 table.add_row(vec![
305 Cell::new("Description"),
306 Cell::new(&detail.description),
307 ]);
308 table.add_row(vec![
309 Cell::new("Category"),
310 Cell::new(detail.category.as_deref().unwrap_or("-")),
311 ]);
312 table.add_row(vec![
313 Cell::new("Version"),
314 Cell::new(detail.version.as_deref().unwrap_or("-")),
315 ]);
316
317 if !detail.sub_workflows.is_empty() {
318 let names: Vec<&str> = detail
319 .sub_workflows
320 .iter()
321 .map(|s| s.name.as_str())
322 .collect();
323 table.add_row(vec![
324 Cell::new("Sub-workflows"),
325 Cell::new(names.join(", ")),
326 ]);
327 }
328
329 table
330}
331
332pub fn stats_table(stats: &StatsResponse) -> Table {
334 let mut table = base_table();
335 table.set_header(vec!["Metric", "Value"]);
336
337 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
338 table.add_row(vec![
339 Cell::new("Completed"),
340 Cell::new(stats.completed_runs).fg(Color::Green),
341 ]);
342 table.add_row(vec![
343 Cell::new("Failed"),
344 Cell::new(stats.failed_runs).fg(Color::Red),
345 ]);
346 table.add_row(vec![
347 Cell::new("Cancelled"),
348 Cell::new(stats.cancelled_runs).fg(Color::Grey),
349 ]);
350 table.add_row(vec![
351 Cell::new("Active"),
352 Cell::new(stats.active_runs).fg(Color::Blue),
353 ]);
354 table.add_row(vec![
355 Cell::new("Success rate"),
356 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
357 ]);
358 table.add_row(vec![
359 Cell::new("Total cost"),
360 Cell::new(format!("${:.4}", stats.total_cost_usd)),
361 ]);
362 table.add_row(vec![
363 Cell::new("Total duration"),
364 Cell::new(format_duration_ms(stats.total_duration_ms)),
365 ]);
366
367 table
368}
369
370#[cfg(test)]
371mod tests {
372 use std::collections::HashMap;
373 use std::slice;
374
375 use ironflow_sdk::types::{CreatedBy, CreatedByKind, TriggerKind};
376 use serde_json::{Map, Value};
377 use uuid::Uuid;
378
379 use super::*;
380
381 fn run_fixture(created_by: CreatedBy) -> RunResponse {
383 let now = Utc::now();
384 RunResponse {
385 id: Uuid::now_v7(),
386 workflow_name: "deploy".to_string(),
387 status: RunStatus::Completed,
388 trigger: TriggerKind::Api,
389 error: None,
390 retry_count: 0,
391 max_retries: 0,
392 cost_usd: 0.0,
393 duration_ms: 0,
394 created_at: now,
395 updated_at: now,
396 started_at: None,
397 completed_at: None,
398 handler_version: None,
399 labels: HashMap::new(),
400 scheduled_at: None,
401 created_by,
402 idempotency_key: None,
403 max_cost_usd: None,
404 }
405 }
406
407 #[test]
408 fn format_cost_without_cap_shows_amount_only() {
409 assert_eq!(format_cost(0.1234, None), "$0.1234");
410 }
411
412 #[test]
413 fn format_cost_with_cap_shows_both_amounts() {
414 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
415 }
416
417 #[test]
418 fn cost_color_is_absent_without_a_cap() {
419 assert_eq!(cost_color(999.0, None), None);
420 }
421
422 #[test]
423 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
424 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));
427 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
430
431 #[test]
432 fn cost_color_handles_a_zero_cap() {
433 assert_eq!(cost_color(0.0, Some(0.0)), None);
434 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
435 }
436
437 #[test]
438 fn format_duration_ms_millis() {
439 assert_eq!(format_duration_ms(500), "500ms");
440 assert_eq!(format_duration_ms(0), "0ms");
441 }
442
443 #[test]
444 fn format_duration_ms_seconds() {
445 assert_eq!(format_duration_ms(5000), "5s");
446 assert_eq!(format_duration_ms(59000), "59s");
447 }
448
449 #[test]
450 fn format_duration_ms_minutes() {
451 assert_eq!(format_duration_ms(60000), "1m 0s");
452 assert_eq!(format_duration_ms(125000), "2m 5s");
453 }
454
455 #[test]
456 fn format_duration_ms_hours() {
457 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
458 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
459 }
460
461 #[test]
462 fn format_optional_datetime_none() {
463 assert_eq!(format_optional_datetime(&None), "-");
464 }
465
466 #[test]
467 fn format_optional_datetime_some() {
468 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
469 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
470 }
471
472 #[test]
473 fn status_colors_are_distinct() {
474 let statuses = [
475 RunStatus::Completed,
476 RunStatus::Failed,
477 RunStatus::Running,
478 RunStatus::Pending,
479 RunStatus::Cancelled,
480 RunStatus::AwaitingApproval,
481 RunStatus::Retrying,
482 ];
483
484 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
485 for (i, c1) in colors.iter().enumerate() {
486 for (j, c2) in colors.iter().enumerate() {
487 if i != j {
488 assert_ne!(c1, c2, "status colors must be distinct");
489 }
490 }
491 }
492 }
493
494 #[test]
495 fn empty_runs_table_has_header() {
496 let table = runs_table(&[]);
497 let output = table.to_string();
498 assert!(output.contains("ID"));
499 assert!(output.contains("Workflow"));
500 assert!(output.contains("Status"));
501 assert!(output.contains("Triggered by"));
502 }
503
504 #[test]
505 fn runs_table_renders_the_author_label() {
506 let run = run_fixture(CreatedBy {
507 kind: CreatedByKind::ApiKey,
508 id: Some(Uuid::now_v7()),
509 label: "ci-deploy (alice)".to_string(),
510 });
511
512 let output = runs_table(slice::from_ref(&run)).to_string();
513 assert!(
514 output.contains("ci-deploy (alice)"),
515 "author missing from:\n{output}"
516 );
517 }
518
519 #[test]
520 fn run_detail_table_renders_the_author_label() {
521 let detail = RunDetailResponse {
522 run: run_fixture(CreatedBy {
523 kind: CreatedByKind::System,
524 id: None,
525 label: "/hooks/github".to_string(),
526 }),
527 steps: Vec::new(),
528 payload: Value::Object(Map::new()),
529 };
530
531 let output = run_detail_table(&detail).to_string();
532 assert!(output.contains("Triggered by"));
533 assert!(
534 output.contains("/hooks/github"),
535 "author missing from:\n{output}"
536 );
537 }
538
539 #[test]
540 fn empty_workflows_table_has_header() {
541 let table = workflows_table(&[]);
542 let output = table.to_string();
543 assert!(output.contains("Name"));
544 assert!(output.contains("Category"));
545 }
546}