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