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