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