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, stdout};
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use comfy_table::presets::UTF8_FULL;
11use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
12use ironflow_sdk::types::{
13    ApiKeyResponse, ApiKeyScope, ArtifactResponse, AuditLogEntry, CreateApiKeyResponse,
14    KeyVersionsResponse, RunDetailResponse, RunResponse, RunStatus, ScopeEntry, SecretResponse,
15    StatsHistoryResponse, StatsResponse, StepResponse, StepStatus, UserResponse,
16    WorkflowDetailResponse, WorkflowSummary,
17};
18use serde::Serialize;
19use serde_json::to_string_pretty;
20use uuid::Uuid;
21
22/// Map a [`RunStatus`] to a terminal color.
23fn status_color(status: &RunStatus) -> Color {
24    match status {
25        RunStatus::Completed => Color::Green,
26        RunStatus::Failed => Color::Red,
27        RunStatus::Running => Color::Blue,
28        RunStatus::Pending => Color::Yellow,
29        RunStatus::Cancelled => Color::Grey,
30        RunStatus::AwaitingApproval => Color::Magenta,
31        RunStatus::Retrying => Color::Cyan,
32        RunStatus::Warning => Color::DarkYellow,
33        RunStatus::Sleeping => Color::DarkCyan,
34    }
35}
36
37/// Map a [`StepStatus`] to a terminal color.
38fn step_status_color(status: &StepStatus) -> Color {
39    match status {
40        StepStatus::Completed => Color::Green,
41        StepStatus::Failed => Color::Red,
42        StepStatus::Running => Color::Blue,
43        StepStatus::Pending => Color::Yellow,
44        StepStatus::Skipped => Color::Grey,
45        StepStatus::AwaitingApproval => Color::Magenta,
46        StepStatus::Rejected => Color::Red,
47    }
48}
49
50/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
51fn format_datetime(dt: &DateTime<Utc>) -> String {
52    dt.format("%Y-%m-%d %H:%M:%S").to_string()
53}
54
55/// Format an optional [`DateTime`].
56fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
57    dt.as_ref().map_or("-".to_string(), format_datetime)
58}
59
60/// Format milliseconds as a human-readable duration.
61fn format_duration_ms(ms: i64) -> String {
62    if ms < 1000 {
63        return format!("{ms}ms");
64    }
65    let secs = ms / 1000;
66    if secs < 60 {
67        return format!("{secs}s");
68    }
69    let mins = secs / 60;
70    let remaining_secs = secs % 60;
71    if mins < 60 {
72        return format!("{mins}m {remaining_secs}s");
73    }
74    let hours = mins / 60;
75    let remaining_mins = mins % 60;
76    format!("{hours}h {remaining_mins}m")
77}
78
79/// Create a base table with UTF-8 styling.
80fn base_table() -> Table {
81    let mut table = Table::new();
82    table
83        .load_preset(UTF8_FULL)
84        .set_content_arrangement(ContentArrangement::Dynamic);
85    table
86}
87
88/// Render a value as JSON or table into the given writer.
89///
90/// # Errors
91///
92/// Returns an error if JSON serialization or writing fails.
93pub fn render_output<W: Write, T: Serialize>(
94    writer: &mut W,
95    json_mode: bool,
96    value: &T,
97    table_fn: impl FnOnce() -> Table,
98) -> Result<()> {
99    if json_mode {
100        let json = to_string_pretty(value)?;
101        writeln!(writer, "{json}")?;
102    } else {
103        writeln!(writer, "{}", table_fn())?;
104    }
105    Ok(())
106}
107
108/// Convenience wrapper: render to stdout.
109///
110/// # Errors
111///
112/// Returns an error if JSON serialization or writing fails.
113pub fn print_output<T: Serialize>(
114    json_mode: bool,
115    value: &T,
116    table_fn: impl FnOnce() -> Table,
117) -> Result<()> {
118    render_output(&mut stdout().lock(), json_mode, value, table_fn)
119}
120
121/// Render a value as pretty JSON to stdout.
122///
123/// For commands whose output is a summary the CLI builds itself, with no
124/// table equivalent.
125///
126/// # Errors
127///
128/// Returns an error if JSON serialization or writing fails.
129pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
130    let json = to_string_pretty(value)?;
131    writeln!(stdout().lock(), "{json}")?;
132    Ok(())
133}
134
135/// Render a list of runs as a table.
136/// Fraction of the cost cap above which the spend is highlighted.
137const COST_WARNING_RATIO: f64 = 0.8;
138
139/// Render a run's spend, with its cap when one is configured.
140///
141/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
142fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
143    match max_cost_usd {
144        Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
145        None => format!("${cost_usd:.4}"),
146    }
147}
148
149/// Highlight colour for a run's spend relative to its cap.
150///
151/// `None` means no highlight: either the run has no cap, or it is comfortably
152/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
153/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
154fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
155    let cap = max_cost_usd?;
156
157    if cap <= 0.0 {
158        return (cost_usd > 0.0).then_some(Color::Red);
159    }
160
161    let ratio = cost_usd / cap;
162    if ratio >= 1.0 {
163        Some(Color::Red)
164    } else if ratio >= COST_WARNING_RATIO {
165        Some(Color::Yellow)
166    } else {
167        None
168    }
169}
170
171/// Build the table cell for a run's spend, highlighted when close to its cap.
172fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
173    let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
174    match cost_color(cost_usd, max_cost_usd) {
175        Some(color) => cell.fg(color),
176        None => cell,
177    }
178}
179
180pub fn runs_table(runs: &[RunResponse]) -> Table {
181    let mut table = base_table();
182    table.set_header(vec![
183        "ID",
184        "Workflow",
185        "Status",
186        "Triggered by",
187        "Duration",
188        "Cost",
189        "Created",
190        "Started",
191    ]);
192
193    for run in runs {
194        let status_cell = Cell::new(run.status)
195            .fg(status_color(&run.status))
196            .set_alignment(CellAlignment::Center);
197
198        table.add_row(vec![
199            Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
200            Cell::new(&run.workflow_name),
201            status_cell,
202            Cell::new(&run.created_by.label),
203            Cell::new(format_duration_ms(run.duration_ms)),
204            cost_cell(run.cost_usd, run.max_cost_usd),
205            Cell::new(format_datetime(&run.created_at)),
206            Cell::new(format_optional_datetime(&run.started_at)),
207        ]);
208    }
209
210    table
211}
212
213/// Render a single run detail as a table.
214pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
215    let run = &detail.run;
216    let mut table = base_table();
217    table.set_header(vec!["Field", "Value"]);
218
219    let status_cell = Cell::new(run.status).fg(status_color(&run.status));
220
221    table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
222    table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
223    table.add_row(vec![Cell::new("Status"), status_cell]);
224    table.add_row(vec![
225        Cell::new("Trigger"),
226        Cell::new(format!("{:?}", run.trigger)),
227    ]);
228    table.add_row(vec![
229        Cell::new("Triggered by"),
230        Cell::new(&run.created_by.label),
231    ]);
232    table.add_row(vec![
233        Cell::new("Duration"),
234        Cell::new(format_duration_ms(run.duration_ms)),
235    ]);
236    table.add_row(vec![
237        Cell::new("Cost"),
238        cost_cell(run.cost_usd, run.max_cost_usd),
239    ]);
240    table.add_row(vec![
241        Cell::new("Created"),
242        Cell::new(format_datetime(&run.created_at)),
243    ]);
244    table.add_row(vec![
245        Cell::new("Started"),
246        Cell::new(format_optional_datetime(&run.started_at)),
247    ]);
248    table.add_row(vec![
249        Cell::new("Completed"),
250        Cell::new(format_optional_datetime(&run.completed_at)),
251    ]);
252    table.add_row(vec![
253        Cell::new("Retries"),
254        Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
255    ]);
256
257    if let Some(ref error) = run.error {
258        table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
259    }
260
261    if !detail.steps.is_empty() {
262        table.add_row(vec![
263            Cell::new("Steps"),
264            Cell::new(format!("{} step(s)", detail.steps.len())),
265        ]);
266    }
267
268    table
269}
270
271/// Summarize a step's artifacts as a count and a total size.
272///
273/// A dash when the step produced none, so the column stays scannable.
274fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
275    if artifacts.is_empty() {
276        return "-".to_string();
277    }
278
279    let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
280    format!("{} ({})", artifacts.len(), format_bytes(total))
281}
282
283/// Human-readable file size, using 1024-based units.
284fn format_bytes(bytes: i64) -> String {
285    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
286
287    if bytes < 1024 {
288        return format!("{bytes} B");
289    }
290
291    let mut value = bytes as f64;
292    let mut unit = 0;
293    while value >= 1024.0 && unit < UNITS.len() - 1 {
294        value /= 1024.0;
295        unit += 1;
296    }
297
298    let decimals = if value < 10.0 { 1 } else { 0 };
299    format!("{value:.decimals$} {}", UNITS[unit])
300}
301
302/// Render a run's steps as a table.
303pub fn steps_table(steps: &[StepResponse]) -> Table {
304    let mut table = base_table();
305    table.set_header(vec![
306        "ID",
307        "Name",
308        "Status",
309        "Attempt",
310        "Duration",
311        "Cost",
312        "Artifacts",
313        "Started",
314        "Completed",
315    ]);
316
317    for step in steps {
318        let color = step_status_color(&step.status);
319
320        table.add_row(vec![
321            Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
322            Cell::new(&step.name),
323            Cell::new(step.status)
324                .fg(color)
325                .set_alignment(CellAlignment::Center),
326            Cell::new(step.attempt).set_alignment(CellAlignment::Center),
327            Cell::new(format_duration_ms(step.duration_ms)),
328            Cell::new(format!("${:.4}", step.cost_usd)),
329            Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
330            Cell::new(format_optional_datetime(&step.started_at)),
331            Cell::new(format_optional_datetime(&step.completed_at)),
332        ]);
333    }
334
335    table
336}
337
338/// Render a list of workflows as a table.
339pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
340    let mut table = base_table();
341    table.set_header(vec!["Name", "Category", "Version"]);
342
343    for wf in workflows {
344        table.add_row(vec![
345            Cell::new(&wf.name),
346            Cell::new(wf.category.as_deref().unwrap_or("-")),
347            Cell::new(wf.version.as_deref().unwrap_or("-")),
348        ]);
349    }
350
351    table
352}
353
354/// Render a workflow detail as a table.
355pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
356    let mut table = base_table();
357    table.set_header(vec!["Field", "Value"]);
358
359    table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
360    table.add_row(vec![
361        Cell::new("Description"),
362        Cell::new(&detail.description),
363    ]);
364    table.add_row(vec![
365        Cell::new("Category"),
366        Cell::new(detail.category.as_deref().unwrap_or("-")),
367    ]);
368    table.add_row(vec![
369        Cell::new("Version"),
370        Cell::new(detail.version.as_deref().unwrap_or("-")),
371    ]);
372
373    if !detail.sub_workflows.is_empty() {
374        let names: Vec<&str> = detail
375            .sub_workflows
376            .iter()
377            .map(|s| s.name.as_str())
378            .collect();
379        table.add_row(vec![
380            Cell::new("Sub-workflows"),
381            Cell::new(names.join(", ")),
382        ]);
383    }
384
385    table
386}
387
388/// Render stats as a table.
389pub fn stats_table(stats: &StatsResponse) -> Table {
390    let mut table = base_table();
391    table.set_header(vec!["Metric", "Value"]);
392
393    table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
394    table.add_row(vec![
395        Cell::new("Completed"),
396        Cell::new(stats.completed_runs).fg(Color::Green),
397    ]);
398    table.add_row(vec![
399        Cell::new("Failed"),
400        Cell::new(stats.failed_runs).fg(Color::Red),
401    ]);
402    table.add_row(vec![
403        Cell::new("Cancelled"),
404        Cell::new(stats.cancelled_runs).fg(Color::Grey),
405    ]);
406    table.add_row(vec![
407        Cell::new("Active"),
408        Cell::new(stats.active_runs).fg(Color::Blue),
409    ]);
410    table.add_row(vec![
411        Cell::new("Success rate"),
412        Cell::new(format!("{:.1}%", stats.success_rate_percent)),
413    ]);
414    table.add_row(vec![
415        Cell::new("Total cost"),
416        Cell::new(format!("${:.4}", stats.total_cost_usd)),
417    ]);
418    table.add_row(vec![
419        Cell::new("Total duration"),
420        Cell::new(format_duration_ms(stats.total_duration_ms)),
421    ]);
422
423    table
424}
425
426/// Render historical stats as a table.
427pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
428    let mut table = base_table();
429    table.set_header(vec![
430        "Time",
431        "Completed",
432        "Failed",
433        "Cancelled",
434        "Avg (ms)",
435        "P95 (ms)",
436        "Cost",
437    ]);
438
439    for bucket in &history.buckets {
440        table.add_row(vec![
441            Cell::new(bucket.time),
442            Cell::new(bucket.completed).fg(Color::Green),
443            Cell::new(bucket.failed).fg(Color::Red),
444            Cell::new(bucket.cancelled).fg(Color::Grey),
445            Cell::new(bucket.avg_duration_ms),
446            Cell::new(bucket.p95_duration_ms),
447            Cell::new(format!("${:.4}", bucket.total_cost_usd)),
448        ]);
449    }
450
451    table
452}
453
454/// Render a list of key versions as a comma-separated string.
455fn format_versions(versions: &[i32]) -> String {
456    if versions.is_empty() {
457        return "-".to_string();
458    }
459    versions
460        .iter()
461        .map(|v| v.to_string())
462        .collect::<Vec<_>>()
463        .join(", ")
464}
465
466/// Outcome of a `delete` command.
467///
468/// The API answers `204 No Content`, which serializes to nothing useful, so the
469/// CLI reports the deletion itself and keeps `--json` machine-readable.
470///
471/// # Examples
472///
473/// ```
474/// use ironflow_cli::output::Deleted;
475///
476/// let deleted = Deleted::new("secret", "db/password");
477/// assert_eq!(deleted.kind, "secret");
478/// ```
479#[derive(Debug, Serialize)]
480pub struct Deleted {
481    /// What was deleted (`secret`, `api-key`, `user`).
482    pub kind: &'static str,
483    /// Identifier of the deleted resource.
484    pub id: String,
485    /// Always `true`; present so consumers can match on a stable shape.
486    pub deleted: bool,
487}
488
489impl Deleted {
490    /// Build a deletion report.
491    pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
492        Self {
493            kind,
494            id: id.into(),
495            deleted: true,
496        }
497    }
498}
499
500/// Render a deletion report as a table.
501pub fn deleted_table(deleted: &Deleted) -> Table {
502    let mut table = base_table();
503    table.set_header(vec!["Deleted", "ID"]);
504    table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
505    table
506}
507
508/// Report a deletion on stdout, as a table or as JSON.
509///
510/// # Errors
511///
512/// Returns an error if JSON serialization or writing fails.
513///
514/// # Examples
515///
516/// ```no_run
517/// use ironflow_cli::output::report_deletion;
518///
519/// # fn example() -> anyhow::Result<()> {
520/// report_deletion(false, "secret", "db/password")?;
521/// # Ok(())
522/// # }
523/// ```
524pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
525    let deleted = Deleted::new(kind, id);
526    print_output(json_mode, &deleted, || deleted_table(&deleted))
527}
528
529/// Render a list of secrets as a table.
530///
531/// [`SecretResponse`] carries no value field, so no secret material can reach
532/// this table by construction.
533pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
534    let mut table = base_table();
535    table.set_header(vec!["Key", "Created", "Updated"]);
536
537    for secret in secrets {
538        table.add_row(vec![
539            Cell::new(&secret.key),
540            Cell::new(format_datetime(&secret.created_at)),
541            Cell::new(format_datetime(&secret.updated_at)),
542        ]);
543    }
544
545    table
546}
547
548/// Join the scopes of an API key into a single cell value.
549fn format_scopes(scopes: &[ApiKeyScope]) -> String {
550    scopes
551        .iter()
552        .map(ToString::to_string)
553        .collect::<Vec<_>>()
554        .join(", ")
555}
556
557/// Render the encryption key ring status as a table.
558pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
559    let mut table = base_table();
560    table.set_header(vec!["Property", "Versions"]);
561
562    table.add_row(vec![
563        Cell::new("Active"),
564        Cell::new(status.active).fg(Color::Green),
565    ]);
566    table.add_row(vec![
567        Cell::new("Configured"),
568        Cell::new(format_versions(&status.configured)),
569    ]);
570    table.add_row(vec![
571        Cell::new("In use"),
572        Cell::new(format_versions(&status.in_use)),
573    ]);
574    table.add_row(vec![
575        Cell::new("Missing"),
576        Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
577            Color::Grey
578        } else {
579            Color::Red
580        }),
581    ]);
582    table.add_row(vec![
583        Cell::new("Retirable"),
584        Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
585            Color::Grey
586        } else {
587            Color::Yellow
588        }),
589    ]);
590
591    table
592}
593
594/// Render a list of API keys as a table.
595///
596/// [`ApiKeyResponse`] never carries the raw key, only its prefix.
597pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
598    let mut table = base_table();
599    table.set_header(vec![
600        "ID",
601        "Name",
602        "Prefix",
603        "Scopes",
604        "Active",
605        "Rate limit",
606        "Last used",
607        "Expires",
608        "Created",
609    ]);
610
611    for key in keys {
612        let active = Cell::new(if key.is_active { "yes" } else { "no" })
613            .fg(if key.is_active {
614                Color::Green
615            } else {
616                Color::Grey
617            })
618            .set_alignment(CellAlignment::Center);
619
620        let rate_limit = key
621            .rate_limit_override
622            .map(|v| v.to_string())
623            .unwrap_or_else(|| "-".to_string());
624
625        table.add_row(vec![
626            Cell::new(key.id),
627            Cell::new(&key.name),
628            Cell::new(&key.key_prefix),
629            Cell::new(format_scopes(&key.scopes)),
630            active,
631            Cell::new(rate_limit),
632            Cell::new(format_optional_datetime(&key.last_used_at)),
633            Cell::new(format_optional_datetime(&key.expires_at)),
634            Cell::new(format_datetime(&key.created_at)),
635        ]);
636    }
637
638    table
639}
640
641/// Render a freshly created API key, including its one-time raw secret.
642///
643/// This is the only place the raw key is ever rendered: the API returns it once
644/// at creation and never again, so withholding it would make the command
645/// useless.
646pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
647    let mut table = base_table();
648    table.set_header(vec!["Field", "Value"]);
649
650    table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
651    table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
652    table.add_row(vec![
653        Cell::new("Key"),
654        Cell::new(&key.key).fg(Color::Yellow),
655    ]);
656    table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
657    table.add_row(vec![
658        Cell::new("Scopes"),
659        Cell::new(format_scopes(&key.scopes)),
660    ]);
661    if let Some(override_val) = key.rate_limit_override {
662        table.add_row(vec![
663            Cell::new("Rate limit"),
664            Cell::new(format!("{override_val} req/min")),
665        ]);
666    }
667    table.add_row(vec![
668        Cell::new("Expires"),
669        Cell::new(format_optional_datetime(&key.expires_at)),
670    ]);
671    table.add_row(vec![
672        Cell::new("Created"),
673        Cell::new(format_datetime(&key.created_at)),
674    ]);
675
676    table
677}
678
679/// Render the available API key scopes as a table.
680pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
681    let mut table = base_table();
682    table.set_header(vec!["Value", "Label", "Description"]);
683
684    for scope in scopes {
685        table.add_row(vec![
686            Cell::new(&scope.value),
687            Cell::new(&scope.label),
688            Cell::new(&scope.description),
689        ]);
690    }
691
692    table
693}
694
695/// Render a list of users as a table.
696pub fn users_table(users: &[UserResponse]) -> Table {
697    let mut table = base_table();
698    table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
699
700    for user in users {
701        let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
702            .fg(if user.is_admin {
703                Color::Magenta
704            } else {
705                Color::Grey
706            })
707            .set_alignment(CellAlignment::Center);
708
709        table.add_row(vec![
710            Cell::new(user.id),
711            Cell::new(&user.username),
712            Cell::new(&user.email),
713            admin,
714            Cell::new(format_datetime(&user.created_at)),
715        ]);
716    }
717
718    table
719}
720
721/// Render a side-by-side comparison of two runs of the same workflow.
722pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
723    let (ra, rb) = (&a.run, &b.run);
724    let mut table = base_table();
725    table.set_header(vec![
726        "Field",
727        &format!("Run {}", short_id(ra.id)),
728        &format!("Run {}", short_id(rb.id)),
729    ]);
730
731    let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
732        let hl = va != vb;
733        vec![
734            Cell::new(f),
735            if hl {
736                Cell::new(&va).fg(Color::Yellow)
737            } else {
738                Cell::new(&va)
739            },
740            if hl {
741                Cell::new(&vb).fg(Color::Yellow)
742            } else {
743                Cell::new(&vb)
744            },
745        ]
746    };
747
748    table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
749    table.add_row(row(
750        "Duration",
751        format_duration_ms(ra.duration_ms),
752        format_duration_ms(rb.duration_ms),
753    ));
754    table.add_row(row(
755        "Cost",
756        format_cost(ra.cost_usd, ra.max_cost_usd),
757        format_cost(rb.cost_usd, rb.max_cost_usd),
758    ));
759    table.add_row(row(
760        "Started",
761        format_optional_datetime(&ra.started_at),
762        format_optional_datetime(&rb.started_at),
763    ));
764    table.add_row(row(
765        "Completed",
766        format_optional_datetime(&ra.completed_at),
767        format_optional_datetime(&rb.completed_at),
768    ));
769    table.add_row(row(
770        "Error",
771        ra.error.clone().unwrap_or("-".into()),
772        rb.error.clone().unwrap_or("-".into()),
773    ));
774    if a.payload != b.payload {
775        table.add_row(row(
776            "Payload",
777            serde_json::to_string(&a.payload).unwrap_or_default(),
778            serde_json::to_string(&b.payload).unwrap_or_default(),
779        ));
780    }
781    for i in 0..a.steps.len().max(b.steps.len()) {
782        let (sa, sb) = (a.steps.get(i), b.steps.get(i));
783        let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
784        table.add_row(row(
785            &format!("{name} status"),
786            sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
787            sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
788        ));
789        table.add_row(row(
790            &format!("{name} duration"),
791            sa.map(|s| format_duration_ms(s.duration_ms))
792                .unwrap_or("-".into()),
793            sb.map(|s| format_duration_ms(s.duration_ms))
794                .unwrap_or("-".into()),
795        ));
796        table.add_row(row(
797            &format!("{name} cost"),
798            sa.map(|s| format!("${:.4}", s.cost_usd))
799                .unwrap_or("-".into()),
800            sb.map(|s| format!("${:.4}", s.cost_usd))
801                .unwrap_or("-".into()),
802        ));
803    }
804    table
805}
806
807/// Render a UUID as its first hyphen-separated group, enough to spot a row.
808fn short_id(id: Uuid) -> String {
809    id.to_string()
810        .split('-')
811        .next()
812        .unwrap_or_default()
813        .to_string()
814}
815
816/// Render a UUID as a short prefix, or `-` when absent.
817fn format_optional_id(id: &Option<Uuid>) -> String {
818    id.map_or_else(|| "-".to_string(), short_id)
819}
820
821/// Render a list of audit log entries as a table.
822///
823/// The event payload is omitted: it is arbitrary JSON that would wreck the
824/// table layout. Use `--json` to get it.
825pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
826    let mut table = base_table();
827    table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
828
829    for entry in entries {
830        table.add_row(vec![
831            Cell::new(short_id(entry.id)),
832            Cell::new(entry.event_type.to_string()),
833            Cell::new(format_optional_id(&entry.run_id)),
834            Cell::new(format_optional_id(&entry.step_id)),
835            Cell::new(format_optional_id(&entry.user_id)),
836            Cell::new(format_datetime(&entry.created_at)),
837        ]);
838    }
839
840    table
841}
842
843#[cfg(test)]
844mod tests {
845    use std::collections::HashMap;
846    use std::slice;
847
848    use ironflow_sdk::types::{ApiKeyScope, CreatedBy, CreatedByKind, EventKind, TriggerKind};
849    use serde_json::{Map, Value};
850
851    use super::*;
852
853    /// Minimal run whose only meaningful field is its author.
854    fn run_fixture(created_by: CreatedBy) -> RunResponse {
855        let now = Utc::now();
856        RunResponse {
857            id: Uuid::now_v7(),
858            workflow_name: "deploy".to_string(),
859            status: RunStatus::Completed,
860            trigger: TriggerKind::Api,
861            error: None,
862            retry_count: 0,
863            max_retries: 0,
864            cost_usd: 0.0,
865            duration_ms: 0,
866            created_at: now,
867            updated_at: now,
868            started_at: None,
869            completed_at: None,
870            handler_version: None,
871            labels: HashMap::new(),
872            scheduled_at: None,
873            created_by,
874            idempotency_key: None,
875            max_cost_usd: None,
876        }
877    }
878
879    #[test]
880    fn format_cost_without_cap_shows_amount_only() {
881        assert_eq!(format_cost(0.1234, None), "$0.1234");
882    }
883
884    #[test]
885    fn format_cost_with_cap_shows_both_amounts() {
886        assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
887    }
888
889    #[test]
890    fn cost_color_is_absent_without_a_cap() {
891        assert_eq!(cost_color(999.0, None), None);
892    }
893
894    #[test]
895    fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
896        assert_eq!(cost_color(1.0, Some(2.0)), None); // 50%
897        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
898        assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
899        assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); // at cap
900        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
901    }
902
903    #[test]
904    fn cost_color_handles_a_zero_cap() {
905        assert_eq!(cost_color(0.0, Some(0.0)), None);
906        assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
907    }
908
909    fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
910        ArtifactResponse {
911            id: Uuid::now_v7(),
912            step_id: Uuid::now_v7(),
913            name: name.to_string(),
914            content_type: "text/plain".to_string(),
915            size_bytes,
916            sha256: "0".repeat(64),
917            created_at: Utc::now(),
918        }
919    }
920
921    #[test]
922    fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
923        assert_eq!(format_bytes(0), "0 B");
924        assert_eq!(format_bytes(1023), "1023 B");
925    }
926
927    #[test]
928    fn format_bytes_switches_units_at_each_boundary() {
929        assert_eq!(format_bytes(1024), "1.0 KB");
930        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
931        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
932    }
933
934    #[test]
935    fn format_bytes_drops_the_decimal_past_ten() {
936        assert_eq!(format_bytes(145_408), "142 KB");
937    }
938
939    #[test]
940    fn format_artifacts_shows_a_dash_when_there_are_none() {
941        assert_eq!(format_artifacts(&[]), "-");
942    }
943
944    #[test]
945    fn format_artifacts_shows_the_count_and_total_size() {
946        let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
947        assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
948    }
949
950    #[test]
951    fn format_duration_ms_millis() {
952        assert_eq!(format_duration_ms(500), "500ms");
953        assert_eq!(format_duration_ms(0), "0ms");
954    }
955
956    #[test]
957    fn format_duration_ms_seconds() {
958        assert_eq!(format_duration_ms(5000), "5s");
959        assert_eq!(format_duration_ms(59000), "59s");
960    }
961
962    #[test]
963    fn format_duration_ms_minutes() {
964        assert_eq!(format_duration_ms(60000), "1m 0s");
965        assert_eq!(format_duration_ms(125000), "2m 5s");
966    }
967
968    #[test]
969    fn format_duration_ms_hours() {
970        assert_eq!(format_duration_ms(3_600_000), "1h 0m");
971        assert_eq!(format_duration_ms(5_400_000), "1h 30m");
972    }
973
974    #[test]
975    fn format_optional_datetime_none() {
976        assert_eq!(format_optional_datetime(&None), "-");
977    }
978
979    #[test]
980    fn format_optional_datetime_some() {
981        let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
982        assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
983    }
984
985    #[test]
986    fn status_colors_are_distinct() {
987        let statuses = [
988            RunStatus::Completed,
989            RunStatus::Failed,
990            RunStatus::Running,
991            RunStatus::Pending,
992            RunStatus::Cancelled,
993            RunStatus::AwaitingApproval,
994            RunStatus::Retrying,
995        ];
996
997        let colors: Vec<Color> = statuses.iter().map(status_color).collect();
998        for (i, c1) in colors.iter().enumerate() {
999            for (j, c2) in colors.iter().enumerate() {
1000                if i != j {
1001                    assert_ne!(c1, c2, "status colors must be distinct");
1002                }
1003            }
1004        }
1005    }
1006
1007    #[test]
1008    fn empty_runs_table_has_header() {
1009        let table = runs_table(&[]);
1010        let output = table.to_string();
1011        assert!(output.contains("ID"));
1012        assert!(output.contains("Workflow"));
1013        assert!(output.contains("Status"));
1014        assert!(output.contains("Triggered by"));
1015    }
1016
1017    #[test]
1018    fn runs_table_renders_the_author_label() {
1019        let run = run_fixture(CreatedBy {
1020            kind: CreatedByKind::ApiKey,
1021            id: Some(Uuid::now_v7()),
1022            label: "ci-deploy (alice)".to_string(),
1023        });
1024
1025        let output = runs_table(slice::from_ref(&run)).to_string();
1026        assert!(
1027            output.contains("ci-deploy (alice)"),
1028            "author missing from:\n{output}"
1029        );
1030    }
1031
1032    #[test]
1033    fn run_detail_table_renders_the_author_label() {
1034        let detail = RunDetailResponse {
1035            run: run_fixture(CreatedBy {
1036                kind: CreatedByKind::System,
1037                id: None,
1038                label: "/hooks/github".to_string(),
1039            }),
1040            steps: Vec::new(),
1041            payload: Value::Object(Map::new()),
1042        };
1043
1044        let output = run_detail_table(&detail).to_string();
1045        assert!(output.contains("Triggered by"));
1046        assert!(
1047            output.contains("/hooks/github"),
1048            "author missing from:\n{output}"
1049        );
1050    }
1051
1052    #[test]
1053    fn empty_workflows_table_has_header() {
1054        let table = workflows_table(&[]);
1055        let output = table.to_string();
1056        assert!(output.contains("Name"));
1057        assert!(output.contains("Category"));
1058    }
1059
1060    // ── Secrets ────────────────────────────────────────────────
1061
1062    fn secret_fixture(key: &str) -> SecretResponse {
1063        let now = Utc::now();
1064        SecretResponse {
1065            id: Uuid::now_v7(),
1066            key: key.to_string(),
1067            created_at: now,
1068            updated_at: now,
1069        }
1070    }
1071
1072    #[test]
1073    fn empty_secrets_table_has_header() {
1074        let output = secrets_table(&[]).to_string();
1075        assert!(output.contains("Key"));
1076        assert!(output.contains("Created"));
1077        assert!(output.contains("Updated"));
1078    }
1079
1080    #[test]
1081    fn secrets_table_renders_the_key() {
1082        let secret = secret_fixture("workflows/inbox/gmail_token");
1083        let output = secrets_table(slice::from_ref(&secret)).to_string();
1084        assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1085    }
1086
1087    /// The value never even reaches this layer: `SecretResponse` has no such
1088    /// field. Rendering it as JSON proves the whole payload is value-free.
1089    #[test]
1090    fn a_secret_response_carries_no_value_at_all() {
1091        let secret = secret_fixture("db/password");
1092        let json = serde_json::to_string(&secret).unwrap();
1093        assert!(!json.contains("value"), "{json}");
1094    }
1095
1096    // ── API keys ───────────────────────────────────────────────
1097
1098    fn api_key_fixture() -> ApiKeyResponse {
1099        ApiKeyResponse {
1100            id: Uuid::now_v7(),
1101            name: "ci-deploy".to_string(),
1102            key_prefix: "ifk_abcd".to_string(),
1103            scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1104            is_active: true,
1105            created_at: Utc::now(),
1106            expires_at: None,
1107            last_used_at: None,
1108            rate_limit_override: None,
1109        }
1110    }
1111
1112    #[test]
1113    fn empty_api_keys_table_has_header() {
1114        let output = api_keys_table(&[]).to_string();
1115        for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1116            assert!(output.contains(header), "missing {header} in {output}");
1117        }
1118    }
1119
1120    #[test]
1121    fn api_keys_table_joins_the_scopes() {
1122        let key = api_key_fixture();
1123        let output = api_keys_table(slice::from_ref(&key)).to_string();
1124        assert!(output.contains("runs_read, runs_write"), "{output}");
1125        assert!(output.contains("ifk_abcd"), "{output}");
1126    }
1127
1128    #[test]
1129    fn created_api_key_table_shows_the_raw_key() {
1130        let created = CreateApiKeyResponse {
1131            id: Uuid::now_v7(),
1132            name: "ci-deploy".to_string(),
1133            key: "ifk_full_raw_key".to_string(),
1134            key_prefix: "ifk_full".to_string(),
1135            scopes: vec![ApiKeyScope::Admin],
1136            created_at: Utc::now(),
1137            expires_at: None,
1138            rate_limit_override: None,
1139        };
1140
1141        let output = created_api_key_table(&created).to_string();
1142        assert!(output.contains("ifk_full_raw_key"), "{output}");
1143    }
1144
1145    #[test]
1146    fn empty_scopes_table_has_header() {
1147        let output = scopes_table(&[]).to_string();
1148        assert!(output.contains("Value"));
1149        assert!(output.contains("Description"));
1150    }
1151
1152    // ── Users ──────────────────────────────────────────────────
1153
1154    fn user_fixture(is_admin: bool) -> UserResponse {
1155        let now = Utc::now();
1156        UserResponse {
1157            id: Uuid::now_v7(),
1158            username: "alice".to_string(),
1159            email: "alice@example.com".to_string(),
1160            is_admin,
1161            created_at: now,
1162            updated_at: now,
1163        }
1164    }
1165
1166    #[test]
1167    fn empty_users_table_has_header() {
1168        let output = users_table(&[]).to_string();
1169        for header in ["ID", "Username", "Email", "Admin", "Created"] {
1170            assert!(output.contains(header), "missing {header} in {output}");
1171        }
1172    }
1173
1174    #[test]
1175    fn users_table_spells_out_the_role() {
1176        let admin = user_fixture(true);
1177        assert!(
1178            users_table(slice::from_ref(&admin))
1179                .to_string()
1180                .contains("yes")
1181        );
1182
1183        let member = user_fixture(false);
1184        assert!(
1185            users_table(slice::from_ref(&member))
1186                .to_string()
1187                .contains("no")
1188        );
1189    }
1190
1191    // ── Audit logs ─────────────────────────────────────────────
1192
1193    #[test]
1194    fn empty_audit_logs_table_has_header() {
1195        let output = audit_logs_table(&[]).to_string();
1196        for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1197            assert!(output.contains(header), "missing {header} in {output}");
1198        }
1199    }
1200
1201    #[test]
1202    fn audit_logs_table_omits_the_payload() {
1203        let entry = AuditLogEntry {
1204            id: Uuid::now_v7(),
1205            event_type: EventKind::RunCreated,
1206            payload: Value::Object(Map::new()),
1207            run_id: Some(Uuid::now_v7()),
1208            step_id: None,
1209            user_id: None,
1210            created_at: Utc::now(),
1211        };
1212
1213        let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1214        assert!(output.contains("run_created"), "{output}");
1215        // Absent IDs collapse to a dash rather than an empty cell.
1216        assert!(output.contains('-'), "{output}");
1217    }
1218
1219    #[test]
1220    fn format_optional_id_shortens_and_falls_back() {
1221        assert_eq!(format_optional_id(&None), "-");
1222        let id = Uuid::now_v7();
1223        let short = format_optional_id(&Some(id));
1224        assert_eq!(short, id.to_string().split('-').next().unwrap());
1225    }
1226
1227    // ── Deletions ──────────────────────────────────────────────
1228
1229    #[test]
1230    fn deleted_table_reports_the_kind_and_id() {
1231        let deleted = Deleted::new("secret", "db/password");
1232        let output = deleted_table(&deleted).to_string();
1233        assert!(output.contains("secret"), "{output}");
1234        assert!(output.contains("db/password"), "{output}");
1235
1236        let json = serde_json::to_string(&deleted).unwrap();
1237        assert!(json.contains(r#""deleted":true"#), "{json}");
1238    }
1239}