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.
115pub 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
140/// Render a single run detail as a table.
141pub 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
194/// Render a run's steps as a table.
195pub 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
226/// Render a list of workflows as a table.
227pub 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
242/// Render a workflow detail as a table.
243pub 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
276/// Render stats as a table.
277pub 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}