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