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", "Workflow", "Status", "Duration", "Cost", "Created", "Started",
163 ]);
164
165 for run in runs {
166 let status_cell = Cell::new(run.status)
167 .fg(status_color(&run.status))
168 .set_alignment(CellAlignment::Center);
169
170 table.add_row(vec![
171 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
172 Cell::new(&run.workflow_name),
173 status_cell,
174 Cell::new(format_duration_ms(run.duration_ms)),
175 cost_cell(run.cost_usd, run.max_cost_usd),
176 Cell::new(format_datetime(&run.created_at)),
177 Cell::new(format_optional_datetime(&run.started_at)),
178 ]);
179 }
180
181 table
182}
183
184pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
186 let run = &detail.run;
187 let mut table = base_table();
188 table.set_header(vec!["Field", "Value"]);
189
190 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
191
192 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
193 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
194 table.add_row(vec![Cell::new("Status"), status_cell]);
195 table.add_row(vec![
196 Cell::new("Trigger"),
197 Cell::new(format!("{:?}", run.trigger)),
198 ]);
199 table.add_row(vec![
200 Cell::new("Duration"),
201 Cell::new(format_duration_ms(run.duration_ms)),
202 ]);
203 table.add_row(vec![
204 Cell::new("Cost"),
205 cost_cell(run.cost_usd, run.max_cost_usd),
206 ]);
207 table.add_row(vec![
208 Cell::new("Created"),
209 Cell::new(format_datetime(&run.created_at)),
210 ]);
211 table.add_row(vec![
212 Cell::new("Started"),
213 Cell::new(format_optional_datetime(&run.started_at)),
214 ]);
215 table.add_row(vec![
216 Cell::new("Completed"),
217 Cell::new(format_optional_datetime(&run.completed_at)),
218 ]);
219 table.add_row(vec![
220 Cell::new("Retries"),
221 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
222 ]);
223
224 if let Some(ref error) = run.error {
225 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
226 }
227
228 if !detail.steps.is_empty() {
229 table.add_row(vec![
230 Cell::new("Steps"),
231 Cell::new(format!("{} step(s)", detail.steps.len())),
232 ]);
233 }
234
235 table
236}
237
238pub fn steps_table(steps: &[StepResponse]) -> Table {
240 let mut table = base_table();
241 table.set_header(vec![
242 "ID",
243 "Name",
244 "Status",
245 "Duration",
246 "Cost",
247 "Started",
248 "Completed",
249 ]);
250
251 for step in steps {
252 let color = step_status_color(&step.status);
253
254 table.add_row(vec![
255 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
256 Cell::new(&step.name),
257 Cell::new(step.status)
258 .fg(color)
259 .set_alignment(CellAlignment::Center),
260 Cell::new(format_duration_ms(step.duration_ms)),
261 Cell::new(format!("${:.4}", step.cost_usd)),
262 Cell::new(format_optional_datetime(&step.started_at)),
263 Cell::new(format_optional_datetime(&step.completed_at)),
264 ]);
265 }
266
267 table
268}
269
270pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
272 let mut table = base_table();
273 table.set_header(vec!["Name", "Category", "Version"]);
274
275 for wf in workflows {
276 table.add_row(vec![
277 Cell::new(&wf.name),
278 Cell::new(wf.category.as_deref().unwrap_or("-")),
279 Cell::new(wf.version.as_deref().unwrap_or("-")),
280 ]);
281 }
282
283 table
284}
285
286pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
288 let mut table = base_table();
289 table.set_header(vec!["Field", "Value"]);
290
291 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
292 table.add_row(vec![
293 Cell::new("Description"),
294 Cell::new(&detail.description),
295 ]);
296 table.add_row(vec![
297 Cell::new("Category"),
298 Cell::new(detail.category.as_deref().unwrap_or("-")),
299 ]);
300 table.add_row(vec![
301 Cell::new("Version"),
302 Cell::new(detail.version.as_deref().unwrap_or("-")),
303 ]);
304
305 if !detail.sub_workflows.is_empty() {
306 let names: Vec<&str> = detail
307 .sub_workflows
308 .iter()
309 .map(|s| s.name.as_str())
310 .collect();
311 table.add_row(vec![
312 Cell::new("Sub-workflows"),
313 Cell::new(names.join(", ")),
314 ]);
315 }
316
317 table
318}
319
320pub fn stats_table(stats: &StatsResponse) -> Table {
322 let mut table = base_table();
323 table.set_header(vec!["Metric", "Value"]);
324
325 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
326 table.add_row(vec![
327 Cell::new("Completed"),
328 Cell::new(stats.completed_runs).fg(Color::Green),
329 ]);
330 table.add_row(vec![
331 Cell::new("Failed"),
332 Cell::new(stats.failed_runs).fg(Color::Red),
333 ]);
334 table.add_row(vec![
335 Cell::new("Cancelled"),
336 Cell::new(stats.cancelled_runs).fg(Color::Grey),
337 ]);
338 table.add_row(vec![
339 Cell::new("Active"),
340 Cell::new(stats.active_runs).fg(Color::Blue),
341 ]);
342 table.add_row(vec![
343 Cell::new("Success rate"),
344 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
345 ]);
346 table.add_row(vec![
347 Cell::new("Total cost"),
348 Cell::new(format!("${:.4}", stats.total_cost_usd)),
349 ]);
350 table.add_row(vec![
351 Cell::new("Total duration"),
352 Cell::new(format_duration_ms(stats.total_duration_ms)),
353 ]);
354
355 table
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn format_cost_without_cap_shows_amount_only() {
364 assert_eq!(format_cost(0.1234, None), "$0.1234");
365 }
366
367 #[test]
368 fn format_cost_with_cap_shows_both_amounts() {
369 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
370 }
371
372 #[test]
373 fn cost_color_is_absent_without_a_cap() {
374 assert_eq!(cost_color(999.0, None), None);
375 }
376
377 #[test]
378 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
379 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));
382 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
385
386 #[test]
387 fn cost_color_handles_a_zero_cap() {
388 assert_eq!(cost_color(0.0, Some(0.0)), None);
389 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
390 }
391
392 #[test]
393 fn format_duration_ms_millis() {
394 assert_eq!(format_duration_ms(500), "500ms");
395 assert_eq!(format_duration_ms(0), "0ms");
396 }
397
398 #[test]
399 fn format_duration_ms_seconds() {
400 assert_eq!(format_duration_ms(5000), "5s");
401 assert_eq!(format_duration_ms(59000), "59s");
402 }
403
404 #[test]
405 fn format_duration_ms_minutes() {
406 assert_eq!(format_duration_ms(60000), "1m 0s");
407 assert_eq!(format_duration_ms(125000), "2m 5s");
408 }
409
410 #[test]
411 fn format_duration_ms_hours() {
412 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
413 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
414 }
415
416 #[test]
417 fn format_optional_datetime_none() {
418 assert_eq!(format_optional_datetime(&None), "-");
419 }
420
421 #[test]
422 fn format_optional_datetime_some() {
423 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
424 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
425 }
426
427 #[test]
428 fn status_colors_are_distinct() {
429 let statuses = [
430 RunStatus::Completed,
431 RunStatus::Failed,
432 RunStatus::Running,
433 RunStatus::Pending,
434 RunStatus::Cancelled,
435 RunStatus::AwaitingApproval,
436 RunStatus::Retrying,
437 ];
438
439 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
440 for (i, c1) in colors.iter().enumerate() {
441 for (j, c2) in colors.iter().enumerate() {
442 if i != j {
443 assert_ne!(c1, c2, "status colors must be distinct");
444 }
445 }
446 }
447 }
448
449 #[test]
450 fn empty_runs_table_has_header() {
451 let table = runs_table(&[]);
452 let output = table.to_string();
453 assert!(output.contains("ID"));
454 assert!(output.contains("Workflow"));
455 assert!(output.contains("Status"));
456 }
457
458 #[test]
459 fn empty_workflows_table_has_header() {
460 let table = workflows_table(&[]);
461 let output = table.to_string();
462 assert!(output.contains("Name"));
463 assert!(output.contains("Category"));
464 }
465}