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