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",
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
192/// Render a single run detail as a table.
193pub 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
250/// Render a run's steps as a table.
251pub 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
282/// Render a list of workflows as a table.
283pub 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
298/// Render a workflow detail as a table.
299pub 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
332/// Render stats as a table.
333pub 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    /// Minimal run whose only meaningful field is its author.
382    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); // 50%
425        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
426        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)); // at cap
428        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
429    }
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}