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