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::client::ApiResponse;
13use ironflow_sdk::types::{
14    AccountState, AccountWindowResponse, AccountWindowStatus, ApiKeyResponse, ApiKeyScope,
15    ArtifactResponse, AuditLogEntry, ConcurrencyLimit, CreateApiKeyResponse, ExecutionPlanResponse,
16    KeyVersionsResponse, PlannedStepResponse, ProviderAccountResponse, RunDetailResponse,
17    RunResponse, RunStatus, ScopeEntry, SecretResponse, StatsHistoryResponse, StatsResponse,
18    StepResponse, StepStatus, UserGroupsResponse, UserResponse, WorkerRouting,
19    WorkflowDetailResponse, WorkflowSummary,
20};
21use serde::Serialize;
22use serde_json::to_string_pretty;
23use uuid::Uuid;
24
25mod cancel;
26
27pub use cancel::cancelled_table;
28
29/// Map a [`RunStatus`] to a terminal color.
30fn status_color(status: &RunStatus) -> Color {
31    match status {
32        RunStatus::Completed => Color::Green,
33        RunStatus::Failed => Color::Red,
34        RunStatus::Running => Color::Blue,
35        RunStatus::Pending => Color::Yellow,
36        RunStatus::Cancelled => Color::Grey,
37        RunStatus::AwaitingApproval => Color::Magenta,
38        RunStatus::Retrying => Color::Cyan,
39        RunStatus::Warning => Color::DarkYellow,
40        RunStatus::Sleeping => Color::DarkCyan,
41    }
42}
43
44/// Map a [`StepStatus`] to a terminal color.
45fn step_status_color(status: &StepStatus) -> Color {
46    match status {
47        StepStatus::Completed => Color::Green,
48        StepStatus::Failed => Color::Red,
49        StepStatus::Running => Color::Blue,
50        StepStatus::Pending => Color::Yellow,
51        StepStatus::Skipped => Color::Grey,
52        StepStatus::AwaitingApproval => Color::Magenta,
53        StepStatus::Rejected => Color::Red,
54    }
55}
56
57/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
58fn format_datetime(dt: &DateTime<Utc>) -> String {
59    dt.format("%Y-%m-%d %H:%M:%S").to_string()
60}
61
62/// Format an optional [`DateTime`].
63fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
64    dt.as_ref().map_or("-".to_string(), format_datetime)
65}
66
67/// Fraction of the original SLA window below which the countdown turns yellow.
68const SLA_WARNING_RATIO: f64 = 0.1;
69
70/// Format a countdown in seconds as a coarse duration.
71///
72/// `None` renders as `"-"` (no deadline), a non-positive count as `"expired"`.
73fn format_remaining_secs(remaining: Option<i64>) -> String {
74    let Some(remaining) = remaining else {
75        return "-".to_string();
76    };
77    if remaining <= 0 {
78        return "expired".to_string();
79    }
80
81    if remaining < 60 {
82        return format!("{remaining}s");
83    }
84
85    let minutes = remaining / 60;
86    if minutes < 60 {
87        let rest = remaining % 60;
88        return if rest == 0 {
89            format!("{minutes}m")
90        } else {
91            format!("{minutes}m {rest}s")
92        };
93    }
94
95    let hours = minutes / 60;
96    let rest = minutes % 60;
97    if rest == 0 {
98        format!("{hours}h")
99    } else {
100        format!("{hours}h {rest}m")
101    }
102}
103
104/// Colour for a countdown: red once expired, yellow in the last
105/// [`SLA_WARNING_RATIO`] of the window, plain otherwise.
106fn remaining_color(remaining: Option<i64>, window_secs: Option<i64>) -> Option<Color> {
107    let remaining = remaining?;
108    if remaining <= 0 {
109        return Some(Color::Red);
110    }
111
112    let window = window_secs?;
113    if window > 0 && (remaining as f64) < (window as f64) * SLA_WARNING_RATIO {
114        return Some(Color::Yellow);
115    }
116
117    None
118}
119
120/// Format the remaining SLA of an approval gate.
121///
122/// Returns `"-"` for a step without a deadline, `"expired"` once the countdown
123/// reaches zero, and a coarse duration (`"45s"`, `"12m 30s"`, `"1h 12m"`)
124/// otherwise.
125fn format_sla(step: &StepResponse) -> String {
126    format_remaining_secs(step.approval_seconds_remaining)
127}
128
129/// Colour of the SLA cell.
130///
131/// The window is derived from the gate's own timestamps (`started_at` to
132/// `approval_deadline_at`), so no configuration parsing is needed.
133fn sla_color(step: &StepResponse) -> Option<Color> {
134    let window = match (step.approval_deadline_at, step.started_at) {
135        (Some(deadline), Some(started)) => Some((deadline - started).num_seconds()),
136        _ => None,
137    };
138    remaining_color(step.approval_seconds_remaining, window)
139}
140
141/// Format milliseconds as a human-readable duration.
142fn format_duration_ms(ms: i64) -> String {
143    if ms < 1000 {
144        return format!("{ms}ms");
145    }
146    let secs = ms / 1000;
147    if secs < 60 {
148        return format!("{secs}s");
149    }
150    let mins = secs / 60;
151    let remaining_secs = secs % 60;
152    if mins < 60 {
153        return format!("{mins}m {remaining_secs}s");
154    }
155    let hours = mins / 60;
156    let remaining_mins = mins % 60;
157    format!("{hours}h {remaining_mins}m")
158}
159
160/// Create a base table with UTF-8 styling.
161fn base_table() -> Table {
162    let mut table = Table::new();
163    table
164        .load_preset(UTF8_FULL)
165        .set_content_arrangement(ContentArrangement::Dynamic);
166    table
167}
168
169/// Render a value as JSON or table into the given writer.
170///
171/// # Errors
172///
173/// Returns an error if JSON serialization or writing fails.
174pub fn render_output<W: Write, T: Serialize>(
175    writer: &mut W,
176    json_mode: bool,
177    value: &T,
178    table_fn: impl FnOnce() -> Table,
179) -> Result<()> {
180    if json_mode {
181        let json = to_string_pretty(value)?;
182        writeln!(writer, "{json}")?;
183    } else {
184        writeln!(writer, "{}", table_fn())?;
185    }
186    Ok(())
187}
188
189/// Convenience wrapper: render to stdout.
190///
191/// # Errors
192///
193/// Returns an error if JSON serialization or writing fails.
194pub fn print_output<T: Serialize>(
195    json_mode: bool,
196    value: &T,
197    table_fn: impl FnOnce() -> Table,
198) -> Result<()> {
199    render_output(&mut stdout().lock(), json_mode, value, table_fn)
200}
201
202/// Render a value as pretty JSON to stdout.
203///
204/// For commands whose output is a summary the CLI builds itself, with no
205/// table equivalent.
206///
207/// # Errors
208///
209/// Returns an error if JSON serialization or writing fails.
210pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
211    let json = to_string_pretty(value)?;
212    writeln!(stdout().lock(), "{json}")?;
213    Ok(())
214}
215
216/// Render a list of runs as a table.
217/// Fraction of the cost cap above which the spend is highlighted.
218const COST_WARNING_RATIO: f64 = 0.8;
219
220/// Render a run's spend, with its cap when one is configured.
221///
222/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
223fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
224    match max_cost_usd {
225        Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
226        None => format!("${cost_usd:.4}"),
227    }
228}
229
230/// Highlight colour for a run's spend relative to its cap.
231///
232/// `None` means no highlight: either the run has no cap, or it is comfortably
233/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
234/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
235fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
236    let cap = max_cost_usd?;
237
238    if cap <= 0.0 {
239        return (cost_usd > 0.0).then_some(Color::Red);
240    }
241
242    let ratio = cost_usd / cap;
243    if ratio >= 1.0 {
244        Some(Color::Red)
245    } else if ratio >= COST_WARNING_RATIO {
246        Some(Color::Yellow)
247    } else {
248        None
249    }
250}
251
252/// Build the table cell for a run's spend, highlighted when close to its cap.
253fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
254    let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
255    match cost_color(cost_usd, max_cost_usd) {
256        Some(color) => cell.fg(color),
257        None => cell,
258    }
259}
260
261pub fn runs_table(runs: &[RunResponse]) -> Table {
262    let mut table = base_table();
263    table.set_header(vec![
264        "ID",
265        "Workflow",
266        "Status",
267        "Triggered by",
268        "Duration",
269        "Cost",
270        "Created",
271        "Started",
272    ]);
273
274    for run in runs {
275        let status_cell = Cell::new(run.status)
276            .fg(status_color(&run.status))
277            .set_alignment(CellAlignment::Center);
278
279        table.add_row(vec![
280            Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
281            Cell::new(&run.workflow_name),
282            status_cell,
283            Cell::new(&run.created_by.label),
284            Cell::new(format_duration_ms(run.duration_ms)),
285            cost_cell(run.cost_usd, run.max_cost_usd),
286            Cell::new(format_datetime(&run.created_at)),
287            Cell::new(format_optional_datetime(&run.started_at)),
288        ]);
289    }
290
291    table
292}
293
294/// Render a single run detail as a table.
295pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
296    let run = &detail.run;
297    let mut table = base_table();
298    table.set_header(vec!["Field", "Value"]);
299
300    let status_cell = Cell::new(run.status).fg(status_color(&run.status));
301
302    table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
303    table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
304    table.add_row(vec![Cell::new("Status"), status_cell]);
305    table.add_row(vec![
306        Cell::new("Trigger"),
307        Cell::new(format!("{:?}", run.trigger)),
308    ]);
309    table.add_row(vec![
310        Cell::new("Triggered by"),
311        Cell::new(&run.created_by.label),
312    ]);
313    table.add_row(vec![
314        Cell::new("Duration"),
315        Cell::new(format_duration_ms(run.duration_ms)),
316    ]);
317    table.add_row(vec![
318        Cell::new("Cost"),
319        cost_cell(run.cost_usd, run.max_cost_usd),
320    ]);
321    table.add_row(vec![
322        Cell::new("Created"),
323        Cell::new(format_datetime(&run.created_at)),
324    ]);
325    table.add_row(vec![
326        Cell::new("Started"),
327        Cell::new(format_optional_datetime(&run.started_at)),
328    ]);
329    table.add_row(vec![
330        Cell::new("Completed"),
331        Cell::new(format_optional_datetime(&run.completed_at)),
332    ]);
333    table.add_row(vec![
334        Cell::new("Retries"),
335        Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
336    ]);
337
338    if !run.concurrency_limits.is_empty() {
339        table.add_row(vec![
340            Cell::new("Concurrency groups"),
341            Cell::new(format_concurrency_limits(&run.concurrency_limits)),
342        ]);
343    }
344
345    if !run.worker_tags.is_empty() {
346        table.add_row(vec![
347            Cell::new("Worker tags"),
348            Cell::new(run.worker_tags.join(", ")),
349        ]);
350    }
351
352    if let Some(warning) = detail.worker_routing.as_ref().and_then(routing_warning) {
353        table.add_row(vec![
354            Cell::new("Workers"),
355            Cell::new(warning).fg(Color::Yellow),
356        ]);
357    }
358
359    if let Some(ref kind) = run.capacity_wait_kind {
360        let resumes = format_optional_datetime(&run.scheduled_at);
361        let reason = format!("{kind}, resumes at {resumes}");
362        table.add_row(vec![
363            Cell::new("Waiting for capacity"),
364            Cell::new(reason).fg(Color::DarkCyan),
365        ]);
366    }
367
368    if let Some(ref error) = run.error {
369        table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
370    }
371
372    if let Some(ref output) = run.output {
373        table.add_row(vec![Cell::new("Output"), Cell::new(output)]);
374    }
375
376    if !detail.steps.is_empty() {
377        table.add_row(vec![
378            Cell::new("Steps"),
379            Cell::new(format!("{} step(s)", detail.steps.len())),
380        ]);
381    }
382
383    table
384}
385
386/// Explain why a queued run may not be picked, or `None` when an eligible
387/// worker was seen recently.
388fn routing_warning(routing: &WorkerRouting) -> Option<&'static str> {
389    if routing.seen_workers == 0 {
390        Some("No worker seen recently")
391    } else if routing.eligible_workers == 0 {
392        Some("No eligible worker seen: none registers this workflow with every required tag")
393    } else {
394        None
395    }
396}
397
398/// List the concurrency groups of a run as `group (limit)`, comma separated.
399fn format_concurrency_limits(limits: &[ConcurrencyLimit]) -> String {
400    limits
401        .iter()
402        .map(|l| format!("{} ({})", l.group, l.limit))
403        .collect::<Vec<_>>()
404        .join(", ")
405}
406
407/// Summarize a step's artifacts as a count and a total size.
408///
409/// A dash when the step produced none, so the column stays scannable.
410fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
411    if artifacts.is_empty() {
412        return "-".to_string();
413    }
414
415    let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
416    format!("{} ({})", artifacts.len(), format_bytes(total))
417}
418
419/// Human-readable file size, using 1024-based units.
420fn format_bytes(bytes: i64) -> String {
421    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
422
423    if bytes < 1024 {
424        return format!("{bytes} B");
425    }
426
427    let mut value = bytes as f64;
428    let mut unit = 0;
429    while value >= 1024.0 && unit < UNITS.len() - 1 {
430        value /= 1024.0;
431        unit += 1;
432    }
433
434    let decimals = if value < 10.0 { 1 } else { 0 };
435    format!("{value:.decimals$} {}", UNITS[unit])
436}
437
438/// Render a run's steps as a table.
439pub fn steps_table(steps: &[StepResponse]) -> Table {
440    let mut table = base_table();
441    table.set_header(vec![
442        "ID",
443        "Name",
444        "Status",
445        "SLA",
446        "Attempt",
447        "Duration",
448        "Cost",
449        "Artifacts",
450        "Started",
451        "Completed",
452    ]);
453
454    for step in steps {
455        let color = step_status_color(&step.status);
456
457        let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
458        if let Some(sla_fg) = sla_color(step) {
459            sla = sla.fg(sla_fg);
460        }
461
462        table.add_row(vec![
463            Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
464            Cell::new(&step.name),
465            Cell::new(step.status)
466                .fg(color)
467                .set_alignment(CellAlignment::Center),
468            sla,
469            Cell::new(step.attempt).set_alignment(CellAlignment::Center),
470            Cell::new(format_duration_ms(step.duration_ms)),
471            Cell::new(format!("${:.4}", step.cost_usd)),
472            Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
473            Cell::new(format_optional_datetime(&step.started_at)),
474            Cell::new(format_optional_datetime(&step.completed_at)),
475        ]);
476    }
477
478    table
479}
480
481/// Render a list of workflows as a table.
482pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
483    let mut table = base_table();
484    table.set_header(vec!["Name", "Category", "Version"]);
485
486    for wf in workflows {
487        table.add_row(vec![
488            Cell::new(&wf.name),
489            Cell::new(wf.category.as_deref().unwrap_or("-")),
490            Cell::new(wf.version.as_deref().unwrap_or("-")),
491        ]);
492    }
493
494    table
495}
496
497/// Render a workflow detail as a table.
498pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
499    let mut table = base_table();
500    table.set_header(vec!["Field", "Value"]);
501
502    table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
503    table.add_row(vec![
504        Cell::new("Description"),
505        Cell::new(&detail.description),
506    ]);
507    table.add_row(vec![
508        Cell::new("Category"),
509        Cell::new(detail.category.as_deref().unwrap_or("-")),
510    ]);
511    table.add_row(vec![
512        Cell::new("Version"),
513        Cell::new(detail.version.as_deref().unwrap_or("-")),
514    ]);
515
516    if !detail.sub_workflows.is_empty() {
517        let names: Vec<&str> = detail
518            .sub_workflows
519            .iter()
520            .map(|s| s.name.as_str())
521            .collect();
522        table.add_row(vec![
523            Cell::new("Sub-workflows"),
524            Cell::new(names.join(", ")),
525        ]);
526    }
527
528    table
529}
530
531/// Render an execution plan as an indented tree.
532///
533/// One line per step. Members of a parallel wave sit under a `parallel-N`
534/// header and are indented one extra level; sub-workflow steps are indented by
535/// their depth. A step carrying a condition shows why the planner took that
536/// branch.
537///
538/// # Examples
539///
540/// ```no_run
541/// use ironflow_cli::output::execution_plan_tree;
542/// use ironflow_sdk::types::ExecutionPlanResponse;
543///
544/// # fn example(plan: &ExecutionPlanResponse) {
545/// println!("{}", execution_plan_tree(plan));
546/// # }
547/// ```
548pub fn execution_plan_tree(plan: &ExecutionPlanResponse) -> String {
549    let mut lines = Vec::new();
550
551    let mut header = format!("workflow {}", plan.workflow);
552    if let Some(total) = plan.estimated_duration_ms {
553        header.push_str(&format!("  estimated ~{}", format_duration_ms(total)));
554    }
555    lines.push(header);
556
557    let mut current_group: Option<&str> = None;
558    for (index, step) in plan.steps.iter().enumerate() {
559        let group = step.parallel_group.as_deref();
560        if group != current_group {
561            if let Some(name) = group {
562                lines.push(format!("{}├─ {name}", indent(depth_of(step))));
563            }
564            current_group = group;
565        }
566
567        let extra = if group.is_some() { "  " } else { "" };
568        let branch = if is_last_at_depth(plan, index) {
569            "└─ "
570        } else {
571            "├─ "
572        };
573        lines.push(format!(
574            "{}{extra}{branch}{}",
575            indent(depth_of(step)),
576            step_label(step)
577        ));
578    }
579
580    if plan.truncated {
581        let reason = plan
582            .incomplete_reason
583            .as_deref()
584            .unwrap_or("the plan was cut short");
585        lines.push(format!("plan incomplete: {reason}"));
586    }
587
588    lines.join("\n")
589}
590
591/// Two spaces per sub-workflow level.
592fn indent(depth: usize) -> String {
593    "  ".repeat(depth)
594}
595
596/// Sub-workflow depth of a step as an indent level.
597fn depth_of(step: &PlannedStepResponse) -> usize {
598    usize::try_from(step.depth).unwrap_or(0)
599}
600
601/// Whether no later step sits at the same depth, making this the last branch.
602fn is_last_at_depth(plan: &ExecutionPlanResponse, index: usize) -> bool {
603    let depth = plan.steps[index].depth;
604    !plan.steps[index + 1..].iter().any(|s| s.depth == depth)
605}
606
607/// `name [kind] ~duration (condition)` for one planned step.
608fn step_label(step: &PlannedStepResponse) -> String {
609    let mut label = format!("{} [{}]", step.name, step.kind);
610
611    if let Some(ms) = step.estimated_duration_ms {
612        label.push_str(&format!(" ~{}", format_duration_ms(ms)));
613    }
614
615    if let Some(condition) = &step.condition {
616        let suffix = match condition.state.as_str() {
617            "evaluated" => format!(
618                " (when {} = {})",
619                condition.expression.as_deref().unwrap_or("?"),
620                condition.value.unwrap_or(false)
621            ),
622            "skipped" => format!(
623                " (skipped: {})",
624                condition.reason.as_deref().unwrap_or("no reason given")
625            ),
626            _ => format!(
627                " (condition unevaluable: {})",
628                condition.expression.as_deref().unwrap_or("?")
629            ),
630        };
631        label.push_str(&suffix);
632    }
633
634    label
635}
636
637/// Print an execution plan as JSON or as a tree.
638///
639/// # Errors
640///
641/// Returns an error if serialization or writing fails.
642pub fn render_execution_plan<W: Write>(
643    writer: &mut W,
644    json_mode: bool,
645    response: &ApiResponse<ExecutionPlanResponse>,
646) -> Result<()> {
647    if json_mode {
648        let json = to_string_pretty(response)?;
649        writeln!(writer, "{json}")?;
650    } else {
651        writeln!(writer, "{}", execution_plan_tree(&response.data))?;
652    }
653    Ok(())
654}
655
656/// Render stats as a table.
657pub fn stats_table(stats: &StatsResponse) -> Table {
658    let mut table = base_table();
659    table.set_header(vec!["Metric", "Value"]);
660
661    table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
662    table.add_row(vec![
663        Cell::new("Completed"),
664        Cell::new(stats.completed_runs).fg(Color::Green),
665    ]);
666    table.add_row(vec![
667        Cell::new("Failed"),
668        Cell::new(stats.failed_runs).fg(Color::Red),
669    ]);
670    table.add_row(vec![
671        Cell::new("Cancelled"),
672        Cell::new(stats.cancelled_runs).fg(Color::Grey),
673    ]);
674    table.add_row(vec![
675        Cell::new("Active"),
676        Cell::new(stats.active_runs).fg(Color::Blue),
677    ]);
678    table.add_row(vec![
679        Cell::new("Awaiting approval"),
680        Cell::new(stats.awaiting_approval_runs).fg(Color::Magenta),
681    ]);
682    table.add_row(vec![
683        Cell::new("Success rate"),
684        Cell::new(format!("{:.1}%", stats.success_rate_percent)),
685    ]);
686    table.add_row(vec![
687        Cell::new("Total cost"),
688        Cell::new(format!("${:.4}", stats.total_cost_usd)),
689    ]);
690    table.add_row(vec![
691        Cell::new("Total duration"),
692        Cell::new(format_duration_ms(stats.total_duration_ms)),
693    ]);
694
695    table
696}
697
698/// Render historical stats as a table.
699pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
700    let mut table = base_table();
701    table.set_header(vec![
702        "Time",
703        "Completed",
704        "Warning",
705        "Failed",
706        "Cancelled",
707        "Active",
708        "Success %",
709        "Avg (ms)",
710        "P95 (ms)",
711        "Cost",
712    ]);
713
714    for bucket in &history.buckets {
715        let active = bucket.pending
716            + bucket.running
717            + bucket.retrying
718            + bucket.awaiting_approval
719            + bucket.sleeping;
720        table.add_row(vec![
721            Cell::new(bucket.time),
722            Cell::new(bucket.completed).fg(Color::Green),
723            Cell::new(bucket.warning).fg(Color::Yellow),
724            Cell::new(bucket.failed).fg(Color::Red),
725            Cell::new(bucket.cancelled).fg(Color::Grey),
726            Cell::new(active).fg(Color::Blue),
727            Cell::new(format_success_rate(bucket.success_rate_percent)),
728            Cell::new(bucket.avg_duration_ms),
729            Cell::new(bucket.p95_duration_ms),
730            Cell::new(format!("${:.4}", bucket.total_cost_usd)),
731        ]);
732    }
733
734    table
735}
736
737/// Render an optional success rate: `-` when the bucket has no finished run.
738fn format_success_rate(rate: Option<f64>) -> String {
739    rate.map_or_else(|| "-".to_string(), |r| format!("{r:.1}%"))
740}
741
742/// Render a list of key versions as a comma-separated string.
743fn format_versions(versions: &[i32]) -> String {
744    if versions.is_empty() {
745        return "-".to_string();
746    }
747    versions
748        .iter()
749        .map(|v| v.to_string())
750        .collect::<Vec<_>>()
751        .join(", ")
752}
753
754/// Outcome of a `delete` command.
755///
756/// The API answers `204 No Content`, which serializes to nothing useful, so the
757/// CLI reports the deletion itself and keeps `--json` machine-readable.
758///
759/// # Examples
760///
761/// ```
762/// use ironflow_cli::output::Deleted;
763///
764/// let deleted = Deleted::new("secret", "db/password");
765/// assert_eq!(deleted.kind, "secret");
766/// ```
767#[derive(Debug, Serialize)]
768pub struct Deleted {
769    /// What was deleted (`secret`, `api-key`, `user`).
770    pub kind: &'static str,
771    /// Identifier of the deleted resource.
772    pub id: String,
773    /// Always `true`; present so consumers can match on a stable shape.
774    pub deleted: bool,
775}
776
777impl Deleted {
778    /// Build a deletion report.
779    pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
780        Self {
781            kind,
782            id: id.into(),
783            deleted: true,
784        }
785    }
786}
787
788/// Render a deletion report as a table.
789pub fn deleted_table(deleted: &Deleted) -> Table {
790    let mut table = base_table();
791    table.set_header(vec!["Deleted", "ID"]);
792    table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
793    table
794}
795
796/// Report a deletion on stdout, as a table or as JSON.
797///
798/// # Errors
799///
800/// Returns an error if JSON serialization or writing fails.
801///
802/// # Examples
803///
804/// ```no_run
805/// use ironflow_cli::output::report_deletion;
806///
807/// # fn example() -> anyhow::Result<()> {
808/// report_deletion(false, "secret", "db/password")?;
809/// # Ok(())
810/// # }
811/// ```
812pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
813    let deleted = Deleted::new(kind, id);
814    print_output(json_mode, &deleted, || deleted_table(&deleted))
815}
816
817/// Render a list of secrets as a table.
818///
819/// [`SecretResponse`] carries no value field, so no secret material can reach
820/// this table by construction.
821pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
822    let mut table = base_table();
823    table.set_header(vec!["Key", "Created", "Updated"]);
824
825    for secret in secrets {
826        table.add_row(vec![
827            Cell::new(&secret.key),
828            Cell::new(format_datetime(&secret.created_at)),
829            Cell::new(format_datetime(&secret.updated_at)),
830        ]);
831    }
832
833    table
834}
835
836/// Utilization of the unscoped window `name`, as a percentage, `-` when absent.
837fn window_percent(windows: &[AccountWindowResponse], name: &str) -> String {
838    windows
839        .iter()
840        .find(|w| w.window == name && w.model_scope.is_none())
841        .map_or_else(
842            || "-".to_string(),
843            |w| format!("{:.0}%", w.utilization * 100.0),
844        )
845}
846
847/// Colour of an account state.
848fn account_state_color(state: &AccountState) -> Color {
849    match state {
850        AccountState::Ok => Color::Green,
851        AccountState::NearLimit => Color::Yellow,
852        AccountState::Limited | AccountState::TokenInvalid => Color::Red,
853        AccountState::NeverUsed => Color::Grey,
854    }
855}
856
857/// Render Provider Accounts as a table. The credential is never part of the response.
858pub fn provider_accounts_table(accounts: &[ProviderAccountResponse]) -> Table {
859    let mut table = base_table();
860    table.set_header(vec![
861        "Name", "Kind", "State", "Enabled", "Priority", "Tags", "5h", "7d", "Expires",
862    ]);
863
864    for account in accounts {
865        table.add_row(vec![
866            Cell::new(&account.name),
867            Cell::new(&account.kind),
868            Cell::new(account.state.to_string()).fg(account_state_color(&account.state)),
869            Cell::new(if account.enabled { "yes" } else { "no" }),
870            Cell::new(account.priority).set_alignment(CellAlignment::Right),
871            Cell::new(account.tags.join(", ")),
872            Cell::new(window_percent(&account.windows, "five_hour"))
873                .set_alignment(CellAlignment::Right),
874            Cell::new(window_percent(&account.windows, "seven_day"))
875                .set_alignment(CellAlignment::Right),
876            Cell::new(format_datetime(&account.expires_at)),
877        ]);
878    }
879
880    table
881}
882
883/// Render the usage windows of one account as a table.
884pub fn provider_account_windows_table(windows: &[AccountWindowResponse]) -> Table {
885    let mut table = base_table();
886    table.set_header(vec![
887        "Window", "Scope", "Used", "Status", "Resets", "Observed",
888    ]);
889
890    for window in windows {
891        let color = match window.status {
892            AccountWindowStatus::Allowed => Color::Green,
893            AccountWindowStatus::AllowedWarning => Color::Yellow,
894            AccountWindowStatus::Rejected => Color::Red,
895        };
896        table.add_row(vec![
897            Cell::new(&window.window),
898            Cell::new(window.model_scope.as_deref().unwrap_or("-")),
899            Cell::new(format!("{:.0}%", window.utilization * 100.0))
900                .set_alignment(CellAlignment::Right),
901            Cell::new(window.status.to_string()).fg(color),
902            Cell::new(format_optional_datetime(&window.resets_at)),
903            Cell::new(format_datetime(&window.observed_at)),
904        ]);
905    }
906
907    table
908}
909
910/// Join the scopes of an API key into a single cell value.
911fn format_scopes(scopes: &[ApiKeyScope]) -> String {
912    scopes
913        .iter()
914        .map(ToString::to_string)
915        .collect::<Vec<_>>()
916        .join(", ")
917}
918
919/// Render the encryption key ring status as a table.
920pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
921    let mut table = base_table();
922    table.set_header(vec!["Property", "Versions"]);
923
924    table.add_row(vec![
925        Cell::new("Active"),
926        Cell::new(status.active).fg(Color::Green),
927    ]);
928    table.add_row(vec![
929        Cell::new("Configured"),
930        Cell::new(format_versions(&status.configured)),
931    ]);
932    table.add_row(vec![
933        Cell::new("In use"),
934        Cell::new(format_versions(&status.in_use)),
935    ]);
936    table.add_row(vec![
937        Cell::new("Missing"),
938        Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
939            Color::Grey
940        } else {
941            Color::Red
942        }),
943    ]);
944    table.add_row(vec![
945        Cell::new("Retirable"),
946        Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
947            Color::Grey
948        } else {
949            Color::Yellow
950        }),
951    ]);
952
953    table
954}
955
956/// Render a list of API keys as a table.
957///
958/// [`ApiKeyResponse`] never carries the raw key, only its prefix.
959pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
960    let mut table = base_table();
961    table.set_header(vec![
962        "ID",
963        "Name",
964        "Prefix",
965        "Scopes",
966        "Active",
967        "Rate limit",
968        "Last used",
969        "Expires",
970        "Created",
971    ]);
972
973    for key in keys {
974        let active = Cell::new(if key.is_active { "yes" } else { "no" })
975            .fg(if key.is_active {
976                Color::Green
977            } else {
978                Color::Grey
979            })
980            .set_alignment(CellAlignment::Center);
981
982        let rate_limit = key
983            .rate_limit_override
984            .map(|v| v.to_string())
985            .unwrap_or_else(|| "-".to_string());
986
987        table.add_row(vec![
988            Cell::new(key.id),
989            Cell::new(&key.name),
990            Cell::new(&key.key_prefix),
991            Cell::new(format_scopes(&key.scopes)),
992            active,
993            Cell::new(rate_limit),
994            Cell::new(format_optional_datetime(&key.last_used_at)),
995            Cell::new(format_optional_datetime(&key.expires_at)),
996            Cell::new(format_datetime(&key.created_at)),
997        ]);
998    }
999
1000    table
1001}
1002
1003/// Render a freshly created API key, including its one-time raw secret.
1004///
1005/// This is the only place the raw key is ever rendered: the API returns it once
1006/// at creation and never again, so withholding it would make the command
1007/// useless.
1008pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
1009    let mut table = base_table();
1010    table.set_header(vec!["Field", "Value"]);
1011
1012    table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
1013    table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
1014    table.add_row(vec![
1015        Cell::new("Key"),
1016        Cell::new(&key.key).fg(Color::Yellow),
1017    ]);
1018    table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
1019    table.add_row(vec![
1020        Cell::new("Scopes"),
1021        Cell::new(format_scopes(&key.scopes)),
1022    ]);
1023    if let Some(override_val) = key.rate_limit_override {
1024        table.add_row(vec![
1025            Cell::new("Rate limit"),
1026            Cell::new(format!("{override_val} req/min")),
1027        ]);
1028    }
1029    table.add_row(vec![
1030        Cell::new("Expires"),
1031        Cell::new(format_optional_datetime(&key.expires_at)),
1032    ]);
1033    table.add_row(vec![
1034        Cell::new("Created"),
1035        Cell::new(format_datetime(&key.created_at)),
1036    ]);
1037
1038    table
1039}
1040
1041/// Render the available API key scopes as a table.
1042pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
1043    let mut table = base_table();
1044    table.set_header(vec!["Value", "Label", "Description"]);
1045
1046    for scope in scopes {
1047        table.add_row(vec![
1048            Cell::new(&scope.value),
1049            Cell::new(&scope.label),
1050            Cell::new(&scope.description),
1051        ]);
1052    }
1053
1054    table
1055}
1056
1057/// Render a list of users as a table.
1058pub fn users_table(users: &[UserResponse]) -> Table {
1059    let mut table = base_table();
1060    table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
1061
1062    for user in users {
1063        let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
1064            .fg(if user.is_admin {
1065                Color::Magenta
1066            } else {
1067                Color::Grey
1068            })
1069            .set_alignment(CellAlignment::Center);
1070
1071        table.add_row(vec![
1072            Cell::new(user.id),
1073            Cell::new(&user.username),
1074            Cell::new(&user.email),
1075            admin,
1076            Cell::new(format_datetime(&user.created_at)),
1077        ]);
1078    }
1079
1080    table
1081}
1082
1083/// Render a user's group memberships.
1084pub fn user_groups_table(resp: &UserGroupsResponse) -> Table {
1085    let mut table = base_table();
1086    table.set_header(vec!["User ID", "Groups"]);
1087
1088    let groups = if resp.groups.is_empty() {
1089        "-".to_string()
1090    } else {
1091        resp.groups.join(", ")
1092    };
1093    table.add_row(vec![Cell::new(resp.user_id), Cell::new(groups)]);
1094
1095    table
1096}
1097
1098/// Render a side-by-side comparison of two runs of the same workflow.
1099pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
1100    let (ra, rb) = (&a.run, &b.run);
1101    let mut table = base_table();
1102    table.set_header(vec![
1103        "Field",
1104        &format!("Run {}", short_id(ra.id)),
1105        &format!("Run {}", short_id(rb.id)),
1106    ]);
1107
1108    let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
1109        let hl = va != vb;
1110        vec![
1111            Cell::new(f),
1112            if hl {
1113                Cell::new(&va).fg(Color::Yellow)
1114            } else {
1115                Cell::new(&va)
1116            },
1117            if hl {
1118                Cell::new(&vb).fg(Color::Yellow)
1119            } else {
1120                Cell::new(&vb)
1121            },
1122        ]
1123    };
1124
1125    table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
1126    table.add_row(row(
1127        "Duration",
1128        format_duration_ms(ra.duration_ms),
1129        format_duration_ms(rb.duration_ms),
1130    ));
1131    table.add_row(row(
1132        "Cost",
1133        format_cost(ra.cost_usd, ra.max_cost_usd),
1134        format_cost(rb.cost_usd, rb.max_cost_usd),
1135    ));
1136    table.add_row(row(
1137        "Started",
1138        format_optional_datetime(&ra.started_at),
1139        format_optional_datetime(&rb.started_at),
1140    ));
1141    table.add_row(row(
1142        "Completed",
1143        format_optional_datetime(&ra.completed_at),
1144        format_optional_datetime(&rb.completed_at),
1145    ));
1146    table.add_row(row(
1147        "Error",
1148        ra.error.clone().unwrap_or("-".into()),
1149        rb.error.clone().unwrap_or("-".into()),
1150    ));
1151    if a.payload != b.payload {
1152        table.add_row(row(
1153            "Payload",
1154            serde_json::to_string(&a.payload).unwrap_or_default(),
1155            serde_json::to_string(&b.payload).unwrap_or_default(),
1156        ));
1157    }
1158    for i in 0..a.steps.len().max(b.steps.len()) {
1159        let (sa, sb) = (a.steps.get(i), b.steps.get(i));
1160        let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
1161        table.add_row(row(
1162            &format!("{name} status"),
1163            sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
1164            sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
1165        ));
1166        table.add_row(row(
1167            &format!("{name} duration"),
1168            sa.map(|s| format_duration_ms(s.duration_ms))
1169                .unwrap_or("-".into()),
1170            sb.map(|s| format_duration_ms(s.duration_ms))
1171                .unwrap_or("-".into()),
1172        ));
1173        table.add_row(row(
1174            &format!("{name} cost"),
1175            sa.map(|s| format!("${:.4}", s.cost_usd))
1176                .unwrap_or("-".into()),
1177            sb.map(|s| format!("${:.4}", s.cost_usd))
1178                .unwrap_or("-".into()),
1179        ));
1180    }
1181    table
1182}
1183
1184/// Render a UUID as its first hyphen-separated group, enough to spot a row.
1185fn short_id(id: Uuid) -> String {
1186    id.to_string()
1187        .split('-')
1188        .next()
1189        .unwrap_or_default()
1190        .to_string()
1191}
1192
1193/// Render a UUID as a short prefix, or `-` when absent.
1194fn format_optional_id(id: &Option<Uuid>) -> String {
1195    id.map_or_else(|| "-".to_string(), short_id)
1196}
1197
1198/// Render a list of audit log entries as a table.
1199///
1200/// The event payload is omitted: it is arbitrary JSON that would wreck the
1201/// table layout. Use `--json` to get it.
1202pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
1203    let mut table = base_table();
1204    table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
1205
1206    for entry in entries {
1207        table.add_row(vec![
1208            Cell::new(short_id(entry.id)),
1209            Cell::new(entry.event_type.to_string()),
1210            Cell::new(format_optional_id(&entry.run_id)),
1211            Cell::new(format_optional_id(&entry.step_id)),
1212            Cell::new(format_optional_id(&entry.user_id)),
1213            Cell::new(format_datetime(&entry.created_at)),
1214        ]);
1215    }
1216
1217    table
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use std::collections::HashMap;
1223    use std::slice;
1224
1225    use ironflow_sdk::types::{
1226        ApiKeyScope, ConditionResponse, CreatedBy, CreatedByKind, EventKind, TriggerKind,
1227    };
1228    use serde_json::{Map, Value, json};
1229
1230    use super::*;
1231
1232    /// Minimal run whose only meaningful field is its author.
1233    fn run_fixture(created_by: CreatedBy) -> RunResponse {
1234        let now = Utc::now();
1235        RunResponse {
1236            id: Uuid::now_v7(),
1237            workflow_name: "deploy".to_string(),
1238            status: RunStatus::Completed,
1239            trigger: TriggerKind::Api,
1240            error: None,
1241            retry_count: 0,
1242            max_retries: 0,
1243            cost_usd: 0.0,
1244            duration_ms: 0,
1245            created_at: now,
1246            updated_at: now,
1247            started_at: None,
1248            completed_at: None,
1249            handler_version: None,
1250            labels: HashMap::new(),
1251            scheduled_at: None,
1252            capacity_wait_kind: None,
1253            created_by,
1254            idempotency_key: None,
1255            concurrency_key: None,
1256            concurrency_limits: Vec::new(),
1257            max_cost_usd: None,
1258            output: None,
1259            worker_tags: Vec::new(),
1260        }
1261    }
1262
1263    #[test]
1264    fn format_success_rate_renders_dash_when_absent() {
1265        assert_eq!(format_success_rate(None), "-");
1266    }
1267
1268    #[test]
1269    fn format_success_rate_renders_one_decimal() {
1270        assert_eq!(format_success_rate(Some(100.0)), "100.0%");
1271        assert_eq!(format_success_rate(Some(200.0 / 3.0)), "66.7%");
1272        assert_eq!(format_success_rate(Some(0.0)), "0.0%");
1273    }
1274
1275    #[test]
1276    fn format_cost_without_cap_shows_amount_only() {
1277        assert_eq!(format_cost(0.1234, None), "$0.1234");
1278    }
1279
1280    #[test]
1281    fn format_cost_with_cap_shows_both_amounts() {
1282        assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
1283    }
1284
1285    #[test]
1286    fn cost_color_is_absent_without_a_cap() {
1287        assert_eq!(cost_color(999.0, None), None);
1288    }
1289
1290    #[test]
1291    fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
1292        assert_eq!(cost_color(1.0, Some(2.0)), None); // 50%
1293        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
1294        assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
1295        assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); // at cap
1296        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
1297    }
1298
1299    #[test]
1300    fn cost_color_handles_a_zero_cap() {
1301        assert_eq!(cost_color(0.0, Some(0.0)), None);
1302        assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
1303    }
1304
1305    fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
1306        ArtifactResponse {
1307            id: Uuid::now_v7(),
1308            step_id: Uuid::now_v7(),
1309            name: name.to_string(),
1310            content_type: "text/plain".to_string(),
1311            size_bytes,
1312            sha256: "0".repeat(64),
1313            created_at: Utc::now(),
1314        }
1315    }
1316
1317    #[test]
1318    fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1319        assert_eq!(format_bytes(0), "0 B");
1320        assert_eq!(format_bytes(1023), "1023 B");
1321    }
1322
1323    #[test]
1324    fn format_bytes_switches_units_at_each_boundary() {
1325        assert_eq!(format_bytes(1024), "1.0 KB");
1326        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1327        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1328    }
1329
1330    #[test]
1331    fn format_bytes_drops_the_decimal_past_ten() {
1332        assert_eq!(format_bytes(145_408), "142 KB");
1333    }
1334
1335    #[test]
1336    fn format_artifacts_shows_a_dash_when_there_are_none() {
1337        assert_eq!(format_artifacts(&[]), "-");
1338    }
1339
1340    #[test]
1341    fn format_artifacts_shows_the_count_and_total_size() {
1342        let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1343        assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1344    }
1345
1346    #[test]
1347    fn format_duration_ms_millis() {
1348        assert_eq!(format_duration_ms(500), "500ms");
1349        assert_eq!(format_duration_ms(0), "0ms");
1350    }
1351
1352    #[test]
1353    fn format_duration_ms_seconds() {
1354        assert_eq!(format_duration_ms(5000), "5s");
1355        assert_eq!(format_duration_ms(59000), "59s");
1356    }
1357
1358    #[test]
1359    fn format_duration_ms_minutes() {
1360        assert_eq!(format_duration_ms(60000), "1m 0s");
1361        assert_eq!(format_duration_ms(125000), "2m 5s");
1362    }
1363
1364    #[test]
1365    fn format_duration_ms_hours() {
1366        assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1367        assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1368    }
1369
1370    #[test]
1371    fn format_sla_without_a_deadline_is_a_dash() {
1372        assert_eq!(format_remaining_secs(None), "-");
1373    }
1374
1375    #[test]
1376    fn format_sla_reports_an_elapsed_deadline_as_expired() {
1377        assert_eq!(format_remaining_secs(Some(0)), "expired");
1378        assert_eq!(format_remaining_secs(Some(-30)), "expired");
1379    }
1380
1381    #[test]
1382    fn format_sla_uses_coarse_units() {
1383        assert_eq!(format_remaining_secs(Some(45)), "45s");
1384        assert_eq!(format_remaining_secs(Some(59)), "59s");
1385        assert_eq!(format_remaining_secs(Some(60)), "1m");
1386        assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1387        assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1388        assert_eq!(format_remaining_secs(Some(3600)), "1h");
1389        assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1390    }
1391
1392    #[test]
1393    fn sla_has_no_colour_without_a_deadline() {
1394        assert_eq!(remaining_color(None, None), None);
1395        assert_eq!(remaining_color(None, Some(3600)), None);
1396    }
1397
1398    #[test]
1399    fn sla_turns_red_once_expired() {
1400        assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1401        assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1402    }
1403
1404    #[test]
1405    fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1406        assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1407        assert_eq!(remaining_color(Some(360), Some(3600)), None);
1408        assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1409    }
1410
1411    #[test]
1412    fn sla_has_no_colour_without_a_measurable_window() {
1413        assert_eq!(remaining_color(Some(120), None), None);
1414        assert_eq!(remaining_color(Some(120), Some(0)), None);
1415    }
1416
1417    #[test]
1418    fn format_optional_datetime_none() {
1419        assert_eq!(format_optional_datetime(&None), "-");
1420    }
1421
1422    #[test]
1423    fn format_optional_datetime_some() {
1424        let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1425        assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1426    }
1427
1428    #[test]
1429    fn status_colors_are_distinct() {
1430        let statuses = [
1431            RunStatus::Completed,
1432            RunStatus::Failed,
1433            RunStatus::Running,
1434            RunStatus::Pending,
1435            RunStatus::Cancelled,
1436            RunStatus::AwaitingApproval,
1437            RunStatus::Retrying,
1438        ];
1439
1440        let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1441        for (i, c1) in colors.iter().enumerate() {
1442            for (j, c2) in colors.iter().enumerate() {
1443                if i != j {
1444                    assert_ne!(c1, c2, "status colors must be distinct");
1445                }
1446            }
1447        }
1448    }
1449
1450    #[test]
1451    fn empty_runs_table_has_header() {
1452        let table = runs_table(&[]);
1453        let output = table.to_string();
1454        assert!(output.contains("ID"));
1455        assert!(output.contains("Workflow"));
1456        assert!(output.contains("Status"));
1457        assert!(output.contains("Triggered by"));
1458    }
1459
1460    #[test]
1461    fn runs_table_renders_the_author_label() {
1462        let run = run_fixture(CreatedBy {
1463            kind: CreatedByKind::ApiKey,
1464            id: Some(Uuid::now_v7()),
1465            label: "ci-deploy (alice)".to_string(),
1466        });
1467
1468        let output = runs_table(slice::from_ref(&run)).to_string();
1469        assert!(
1470            output.contains("ci-deploy (alice)"),
1471            "author missing from:\n{output}"
1472        );
1473    }
1474
1475    #[test]
1476    fn run_detail_table_renders_the_run_output() {
1477        let mut run = run_fixture(CreatedBy {
1478            kind: CreatedByKind::System,
1479            id: None,
1480            label: "cron".to_string(),
1481        });
1482        run.output = Some(json!({"verdict": "approved"}));
1483        let detail = RunDetailResponse {
1484            run,
1485            steps: Vec::new(),
1486            payload: Value::Object(Map::new()),
1487            active_descendant_count: 0,
1488            worker_routing: None,
1489        };
1490
1491        let output = run_detail_table(&detail).to_string();
1492        assert!(
1493            output.contains("Output"),
1494            "output row missing from:\n{output}"
1495        );
1496        assert!(output.contains(r#"{"verdict":"approved"}"#), "{output}");
1497    }
1498
1499    #[test]
1500    fn run_detail_table_has_no_output_row_without_an_output() {
1501        let detail = RunDetailResponse {
1502            run: run_fixture(CreatedBy {
1503                kind: CreatedByKind::System,
1504                id: None,
1505                label: "cron".to_string(),
1506            }),
1507            steps: Vec::new(),
1508            payload: Value::Object(Map::new()),
1509            active_descendant_count: 0,
1510            worker_routing: None,
1511        };
1512
1513        let output = run_detail_table(&detail).to_string();
1514        assert!(!output.contains("Output"), "{output}");
1515    }
1516
1517    #[test]
1518    fn run_detail_table_renders_the_author_label() {
1519        let detail = RunDetailResponse {
1520            run: run_fixture(CreatedBy {
1521                kind: CreatedByKind::System,
1522                id: None,
1523                label: "/hooks/github".to_string(),
1524            }),
1525            steps: Vec::new(),
1526            payload: Value::Object(Map::new()),
1527            active_descendant_count: 0,
1528            worker_routing: None,
1529        };
1530
1531        let output = run_detail_table(&detail).to_string();
1532        assert!(output.contains("Triggered by"));
1533        assert!(
1534            output.contains("/hooks/github"),
1535            "author missing from:\n{output}"
1536        );
1537    }
1538
1539    #[test]
1540    fn format_concurrency_limits_lists_each_group_with_its_limit() {
1541        let limits = [
1542            ConcurrencyLimit {
1543                group: "repo:acme".to_string(),
1544                limit: 2,
1545            },
1546            ConcurrencyLimit {
1547                group: "tenant:42".to_string(),
1548                limit: 1,
1549            },
1550        ];
1551        assert_eq!(
1552            format_concurrency_limits(&limits),
1553            "repo:acme (2), tenant:42 (1)"
1554        );
1555    }
1556
1557    #[test]
1558    fn run_detail_table_shows_concurrency_groups_only_when_present() {
1559        let mut detail = RunDetailResponse {
1560            run: run_fixture(CreatedBy {
1561                kind: CreatedByKind::System,
1562                id: None,
1563                label: "api".to_string(),
1564            }),
1565            steps: Vec::new(),
1566            payload: Value::Object(Map::new()),
1567            active_descendant_count: 0,
1568            worker_routing: None,
1569        };
1570        let output = run_detail_table(&detail).to_string();
1571        assert!(
1572            !output.contains("Concurrency groups"),
1573            "unexpected row in:\n{output}"
1574        );
1575
1576        detail.run.concurrency_limits = vec![ConcurrencyLimit {
1577            group: "repo:acme".to_string(),
1578            limit: 2,
1579        }];
1580        let output = run_detail_table(&detail).to_string();
1581        assert!(
1582            output.contains("Concurrency groups"),
1583            "row missing from:\n{output}"
1584        );
1585        assert!(
1586            output.contains("repo:acme (2)"),
1587            "group missing from:\n{output}"
1588        );
1589    }
1590
1591    #[test]
1592    fn run_detail_table_shows_the_capacity_wait_only_when_waiting() {
1593        let mut detail = RunDetailResponse {
1594            run: run_fixture(CreatedBy {
1595                kind: CreatedByKind::System,
1596                id: None,
1597                label: "api".to_string(),
1598            }),
1599            steps: Vec::new(),
1600            payload: Value::Object(Map::new()),
1601            active_descendant_count: 0,
1602            worker_routing: None,
1603        };
1604        let output = run_detail_table(&detail).to_string();
1605        assert!(
1606            !output.contains("Waiting for capacity"),
1607            "unexpected row in:\n{output}"
1608        );
1609
1610        let wake_at = Utc::now();
1611        detail.run.status = RunStatus::Sleeping;
1612        detail.run.scheduled_at = Some(wake_at);
1613        detail.run.capacity_wait_kind = Some("claude_subscription".to_string());
1614        let output = run_detail_table(&detail).to_string();
1615        assert!(
1616            output.contains("Waiting for capacity"),
1617            "row missing from:\n{output}"
1618        );
1619        let expected = format!(
1620            "claude_subscription, resumes at {}",
1621            format_datetime(&wake_at)
1622        );
1623        assert!(
1624            output.contains(&expected),
1625            "{expected} missing from:\n{output}"
1626        );
1627    }
1628
1629    #[test]
1630    fn routing_warning_covers_each_case() {
1631        let none_seen = WorkerRouting {
1632            seen_workers: 0,
1633            eligible_workers: 0,
1634        };
1635        let warning = routing_warning(&none_seen);
1636        assert_eq!(warning, Some("No worker seen recently"));
1637
1638        let none_eligible = WorkerRouting {
1639            seen_workers: 3,
1640            eligible_workers: 0,
1641        };
1642        let warning = routing_warning(&none_eligible).expect("a warning");
1643        assert!(warning.starts_with("No eligible worker seen"), "{warning}");
1644
1645        let eligible = WorkerRouting {
1646            seen_workers: 3,
1647            eligible_workers: 1,
1648        };
1649        assert_eq!(routing_warning(&eligible), None);
1650    }
1651
1652    #[test]
1653    fn run_detail_table_shows_worker_tags_only_when_present() {
1654        let mut detail = RunDetailResponse {
1655            run: run_fixture(CreatedBy {
1656                kind: CreatedByKind::System,
1657                id: None,
1658                label: "api".to_string(),
1659            }),
1660            steps: Vec::new(),
1661            payload: Value::Object(Map::new()),
1662            active_descendant_count: 0,
1663            worker_routing: None,
1664        };
1665        let output = run_detail_table(&detail).to_string();
1666        assert!(
1667            !output.contains("Worker tags"),
1668            "unexpected row in:\n{output}"
1669        );
1670
1671        detail.run.worker_tags = vec!["gpu".to_string(), "region:eu".to_string()];
1672        let output = run_detail_table(&detail).to_string();
1673        assert!(
1674            output.contains("Worker tags"),
1675            "row missing from:\n{output}"
1676        );
1677        assert!(
1678            output.contains("gpu, region:eu"),
1679            "tags missing from:\n{output}"
1680        );
1681    }
1682
1683    #[test]
1684    fn run_detail_table_warns_when_no_worker_can_take_the_run() {
1685        let mut detail = RunDetailResponse {
1686            run: run_fixture(CreatedBy {
1687                kind: CreatedByKind::System,
1688                id: None,
1689                label: "api".to_string(),
1690            }),
1691            steps: Vec::new(),
1692            payload: Value::Object(Map::new()),
1693            active_descendant_count: 0,
1694            worker_routing: None,
1695        };
1696        let output = run_detail_table(&detail).to_string();
1697        assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1698
1699        detail.worker_routing = Some(WorkerRouting {
1700            seen_workers: 2,
1701            eligible_workers: 1,
1702        });
1703        let output = run_detail_table(&detail).to_string();
1704        assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1705
1706        detail.worker_routing = Some(WorkerRouting {
1707            seen_workers: 2,
1708            eligible_workers: 0,
1709        });
1710        let output = run_detail_table(&detail).to_string();
1711        assert!(output.contains("Workers"), "row missing from:\n{output}");
1712        assert!(
1713            output.contains("No eligible worker seen"),
1714            "warning missing from:\n{output}"
1715        );
1716    }
1717
1718    #[test]
1719    fn empty_workflows_table_has_header() {
1720        let table = workflows_table(&[]);
1721        let output = table.to_string();
1722        assert!(output.contains("Name"));
1723        assert!(output.contains("Category"));
1724    }
1725
1726    // ── Secrets ────────────────────────────────────────────────
1727
1728    fn secret_fixture(key: &str) -> SecretResponse {
1729        let now = Utc::now();
1730        SecretResponse {
1731            id: Uuid::now_v7(),
1732            key: key.to_string(),
1733            created_at: now,
1734            updated_at: now,
1735        }
1736    }
1737
1738    #[test]
1739    fn empty_secrets_table_has_header() {
1740        let output = secrets_table(&[]).to_string();
1741        assert!(output.contains("Key"));
1742        assert!(output.contains("Created"));
1743        assert!(output.contains("Updated"));
1744    }
1745
1746    #[test]
1747    fn secrets_table_renders_the_key() {
1748        let secret = secret_fixture("workflows/inbox/gmail_token");
1749        let output = secrets_table(slice::from_ref(&secret)).to_string();
1750        assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1751    }
1752
1753    /// The value never even reaches this layer: `SecretResponse` has no such
1754    /// field. Rendering it as JSON proves the whole payload is value-free.
1755    #[test]
1756    fn a_secret_response_carries_no_value_at_all() {
1757        let secret = secret_fixture("db/password");
1758        let json = serde_json::to_string(&secret).unwrap();
1759        assert!(!json.contains("value"), "{json}");
1760    }
1761
1762    // ── API keys ───────────────────────────────────────────────
1763
1764    fn api_key_fixture() -> ApiKeyResponse {
1765        ApiKeyResponse {
1766            id: Uuid::now_v7(),
1767            name: "ci-deploy".to_string(),
1768            key_prefix: "ifk_abcd".to_string(),
1769            scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1770            is_active: true,
1771            created_at: Utc::now(),
1772            expires_at: None,
1773            last_used_at: None,
1774            rate_limit_override: None,
1775        }
1776    }
1777
1778    #[test]
1779    fn empty_api_keys_table_has_header() {
1780        let output = api_keys_table(&[]).to_string();
1781        for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1782            assert!(output.contains(header), "missing {header} in {output}");
1783        }
1784    }
1785
1786    #[test]
1787    fn api_keys_table_joins_the_scopes() {
1788        let key = api_key_fixture();
1789        let output = api_keys_table(slice::from_ref(&key)).to_string();
1790        assert!(output.contains("runs_read, runs_write"), "{output}");
1791        assert!(output.contains("ifk_abcd"), "{output}");
1792    }
1793
1794    #[test]
1795    fn created_api_key_table_shows_the_raw_key() {
1796        let created = CreateApiKeyResponse {
1797            id: Uuid::now_v7(),
1798            name: "ci-deploy".to_string(),
1799            key: "ifk_full_raw_key".to_string(),
1800            key_prefix: "ifk_full".to_string(),
1801            scopes: vec![ApiKeyScope::Admin],
1802            created_at: Utc::now(),
1803            expires_at: None,
1804            rate_limit_override: None,
1805        };
1806
1807        let output = created_api_key_table(&created).to_string();
1808        assert!(output.contains("ifk_full_raw_key"), "{output}");
1809    }
1810
1811    #[test]
1812    fn empty_scopes_table_has_header() {
1813        let output = scopes_table(&[]).to_string();
1814        assert!(output.contains("Value"));
1815        assert!(output.contains("Description"));
1816    }
1817
1818    // ── Users ──────────────────────────────────────────────────
1819
1820    fn user_fixture(is_admin: bool) -> UserResponse {
1821        let now = Utc::now();
1822        UserResponse {
1823            id: Uuid::now_v7(),
1824            username: "alice".to_string(),
1825            email: "alice@example.com".to_string(),
1826            is_admin,
1827            created_at: now,
1828            updated_at: now,
1829        }
1830    }
1831
1832    #[test]
1833    fn empty_users_table_has_header() {
1834        let output = users_table(&[]).to_string();
1835        for header in ["ID", "Username", "Email", "Admin", "Created"] {
1836            assert!(output.contains(header), "missing {header} in {output}");
1837        }
1838    }
1839
1840    #[test]
1841    fn users_table_spells_out_the_role() {
1842        let admin = user_fixture(true);
1843        assert!(
1844            users_table(slice::from_ref(&admin))
1845                .to_string()
1846                .contains("yes")
1847        );
1848
1849        let member = user_fixture(false);
1850        assert!(
1851            users_table(slice::from_ref(&member))
1852                .to_string()
1853                .contains("no")
1854        );
1855    }
1856
1857    #[test]
1858    fn user_groups_table_has_header_and_lists_the_groups() {
1859        let resp = UserGroupsResponse {
1860            user_id: Uuid::now_v7(),
1861            groups: vec!["finance".to_string(), "sre".to_string()],
1862        };
1863        let output = user_groups_table(&resp).to_string();
1864        for header in ["User ID", "Groups"] {
1865            assert!(output.contains(header), "missing {header} in {output}");
1866        }
1867        assert!(output.contains(&resp.user_id.to_string()), "{output}");
1868        assert!(output.contains("finance, sre"), "{output}");
1869    }
1870
1871    #[test]
1872    fn user_groups_table_shows_a_dash_without_groups() {
1873        let resp = UserGroupsResponse {
1874            user_id: Uuid::now_v7(),
1875            groups: Vec::new(),
1876        };
1877        let output = user_groups_table(&resp).to_string();
1878        assert!(output.contains("Groups"), "{output}");
1879        assert!(output.contains(" - "), "{output}");
1880        assert!(!output.contains("finance"), "{output}");
1881    }
1882
1883    // ── Audit logs ─────────────────────────────────────────────
1884
1885    #[test]
1886    fn empty_audit_logs_table_has_header() {
1887        let output = audit_logs_table(&[]).to_string();
1888        for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1889            assert!(output.contains(header), "missing {header} in {output}");
1890        }
1891    }
1892
1893    #[test]
1894    fn audit_logs_table_omits_the_payload() {
1895        let entry = AuditLogEntry {
1896            id: Uuid::now_v7(),
1897            event_type: EventKind::RunCreated,
1898            payload: Value::Object(Map::new()),
1899            run_id: Some(Uuid::now_v7()),
1900            step_id: None,
1901            user_id: None,
1902            created_at: Utc::now(),
1903        };
1904
1905        let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1906        assert!(output.contains("run_created"), "{output}");
1907        // Absent IDs collapse to a dash rather than an empty cell.
1908        assert!(output.contains(" - "), "{output}");
1909    }
1910
1911    #[test]
1912    fn format_optional_id_shortens_and_falls_back() {
1913        assert_eq!(format_optional_id(&None), "-");
1914        let id = Uuid::now_v7();
1915        let short = format_optional_id(&Some(id));
1916        assert_eq!(short, id.to_string().split('-').next().unwrap());
1917    }
1918
1919    // ── Deletions ──────────────────────────────────────────────
1920
1921    #[test]
1922    fn deleted_table_reports_the_kind_and_id() {
1923        let deleted = Deleted::new("secret", "db/password");
1924        let output = deleted_table(&deleted).to_string();
1925        assert!(output.contains("secret"), "{output}");
1926        assert!(output.contains("db/password"), "{output}");
1927
1928        let json = serde_json::to_string(&deleted).unwrap();
1929        assert!(json.contains(r#""deleted":true"#), "{json}");
1930    }
1931
1932    // ── Execution plans ────────────────────────────────────────
1933
1934    fn planned_step(name: &str, kind: &str, parallel_group: Option<&str>) -> PlannedStepResponse {
1935        PlannedStepResponse {
1936            name: name.to_string(),
1937            kind: kind.to_string(),
1938            workflow: "deploy".to_string(),
1939            depth: 0,
1940            depends_on: Vec::new(),
1941            condition: None,
1942            parallel_group: parallel_group.map(str::to_string),
1943            estimated_duration_ms: None,
1944        }
1945    }
1946
1947    fn plan_fixture(steps: Vec<PlannedStepResponse>) -> ExecutionPlanResponse {
1948        ExecutionPlanResponse {
1949            workflow: "deploy".to_string(),
1950            steps,
1951            estimated_duration_ms: None,
1952            max_depth: 3,
1953            truncated: false,
1954            incomplete_reason: None,
1955        }
1956    }
1957
1958    #[test]
1959    fn execution_plan_tree_lists_step_names_and_kinds() {
1960        let plan = plan_fixture(vec![
1961            planned_step("build", "shell", None),
1962            planned_step("deploy", "shell", None),
1963        ]);
1964
1965        let output = execution_plan_tree(&plan);
1966        assert!(output.contains("workflow deploy"), "{output}");
1967        assert!(output.contains("build [shell]"), "{output}");
1968        assert!(output.contains("deploy [shell]"), "{output}");
1969    }
1970
1971    #[test]
1972    fn execution_plan_tree_prints_a_parallel_group_header_once() {
1973        let plan = plan_fixture(vec![
1974            planned_step("build", "shell", None),
1975            planned_step("test", "shell", Some("parallel-1")),
1976            planned_step("lint", "shell", Some("parallel-1")),
1977        ]);
1978
1979        let output = execution_plan_tree(&plan);
1980        assert_eq!(output.matches("parallel-1").count(), 1, "{output}");
1981    }
1982
1983    #[test]
1984    fn execution_plan_tree_shows_the_estimate_when_present() {
1985        let mut step = planned_step("build", "shell", None);
1986        step.estimated_duration_ms = Some(5000);
1987        let mut plan = plan_fixture(vec![step]);
1988        plan.estimated_duration_ms = Some(5000);
1989
1990        let output = execution_plan_tree(&plan);
1991        assert!(output.contains("estimated ~5s"), "{output}");
1992        assert!(output.contains("build [shell] ~5s"), "{output}");
1993    }
1994
1995    #[test]
1996    fn execution_plan_tree_marks_conditions() {
1997        let mut evaluated = planned_step("deploy-prod", "shell", None);
1998        evaluated.condition = Some(ConditionResponse {
1999            state: "evaluated".to_string(),
2000            expression: Some("env == prod".to_string()),
2001            value: Some(true),
2002            reason: None,
2003        });
2004        let mut skipped = planned_step("deploy-dev", "skip", None);
2005        skipped.condition = Some(ConditionResponse {
2006            state: "skipped".to_string(),
2007            expression: None,
2008            value: None,
2009            reason: Some("not prod".to_string()),
2010        });
2011        let mut unevaluable = planned_step("notify", "http", None);
2012        unevaluable.condition = Some(ConditionResponse {
2013            state: "unevaluable".to_string(),
2014            expression: Some("build succeeded".to_string()),
2015            value: None,
2016            reason: Some("depends on a step output".to_string()),
2017        });
2018
2019        let output = execution_plan_tree(&plan_fixture(vec![evaluated, skipped, unevaluable]));
2020        assert!(output.contains("(when env == prod = true)"), "{output}");
2021        assert!(output.contains("(skipped: not prod)"), "{output}");
2022        assert!(
2023            output.contains("(condition unevaluable: build succeeded)"),
2024            "{output}"
2025        );
2026    }
2027
2028    #[test]
2029    fn execution_plan_tree_reports_an_incomplete_plan() {
2030        let mut plan = plan_fixture(vec![planned_step("build", "shell", None)]);
2031        plan.truncated = true;
2032        plan.incomplete_reason = Some("step cap of 1000 reached".to_string());
2033
2034        let output = execution_plan_tree(&plan);
2035        assert!(
2036            output.contains("plan incomplete: step cap of 1000 reached"),
2037            "{output}"
2038        );
2039    }
2040
2041    #[test]
2042    fn execution_plan_tree_indents_sub_workflow_steps() {
2043        let mut nested = planned_step("child-step", "shell", None);
2044        nested.depth = 1;
2045        let plan = plan_fixture(vec![planned_step("child", "workflow", None), nested]);
2046
2047        let output = execution_plan_tree(&plan);
2048        let nested = output
2049            .lines()
2050            .find(|l| l.contains("child-step"))
2051            .expect("nested line");
2052        assert!(nested.starts_with("  "), "{nested}");
2053    }
2054}