Skip to main content

ironflow_cli/
output.rs

1//! Output formatting for table and JSON modes.
2//!
3//! Provides helpers to render API responses as either a UTF-8 styled
4//! terminal table (with colored status) or raw JSON.
5
6use 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
17/// Map a [`RunStatus`] to a terminal color.
18fn 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
30/// Map a [`StepStatus`] to a terminal color.
31fn 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
43/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
44fn format_datetime(dt: &DateTime<Utc>) -> String {
45    dt.format("%Y-%m-%d %H:%M:%S").to_string()
46}
47
48/// Format an optional [`DateTime`].
49fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
50    dt.as_ref().map_or("-".to_string(), format_datetime)
51}
52
53/// Format milliseconds as a human-readable duration.
54fn 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
72/// Create a base table with UTF-8 styling.
73fn 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
81/// Render a value as JSON or table into the given writer.
82///
83/// # Errors
84///
85/// Returns an error if JSON serialization or writing fails.
86pub 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
101/// Convenience wrapper: render to stdout.
102///
103/// # Errors
104///
105/// Returns an error if JSON serialization or writing fails.
106pub 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
114/// Render a list of runs as a table.
115/// Fraction of the cost cap above which the spend is highlighted.
116const COST_WARNING_RATIO: f64 = 0.8;
117
118/// Render a run's spend, with its cap when one is configured.
119///
120/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
121fn 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
128/// Highlight colour for a run's spend relative to its cap.
129///
130/// `None` means no highlight: either the run has no cap, or it is comfortably
131/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
132/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
133fn 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
150/// Build the table cell for a run's spend, highlighted when close to its cap.
151fn 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
184/// Render a single run detail as a table.
185pub 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
238/// Render a run's steps as a table.
239pub 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
270/// Render a list of workflows as a table.
271pub 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
286/// Render a workflow detail as a table.
287pub 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
320/// Render stats as a table.
321pub 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); // 50%
380        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
381        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)); // at cap
383        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
384    }
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}