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