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